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 index 4690e8d918..fa6883b014 100644 --- a/.config/wt.toml +++ b/.config/wt.toml @@ -2,20 +2,26 @@ # 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. +# from the primary worktree - patterns selected via .worktreeinclude. [[pre-start]] skills = "./skills/setup.sh --claude" -python = "poetry env use python3.12" envs = "wt step copy-ignored" -# Block 2: install Python deps (requires `poetry env use` from block 1). +# Block 2: install Python deps (uv manages the venv on `uv sync`). [[pre-start]] -deps = "poetry install --with dev" +deps = "uv sync" -# Block 3: reminder — last visible output before `wt switch` returns. +# 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: eval $(poetry env activate)'" +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`. diff --git a/.env b/.env index 18ee1843b6..c6085538a0 100644 --- a/.env +++ b/.env @@ -145,7 +145,7 @@ SENTRY_RELEASE=local NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT} #### Prowler release version #### -NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.27.0 +NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.30.0 # Social login credentials SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google" diff --git a/.github/actions/osv-scanner/action.yml b/.github/actions/osv-scanner/action.yml new file mode 100644 index 0000000000..4bfb5993cb --- /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 HIGH/CRITICAL/UNKNOWN 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: HIGH,CRITICAL,UNKNOWN.' + required: false + default: 'HIGH,CRITICAL,UNKNOWN' + 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 55% rename from .github/actions/setup-python-poetry/action.yml rename to .github/actions/setup-python-uv/action.yml index fd6c082c7b..e23388d86e 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,23 +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.3.4' + default: '0.11.14' install-dependencies: - description: 'Install Python dependencies with Poetry' - required: false - default: 'true' - update-lock: - description: 'Run `poetry lock` during setup. Only enable when a prior step mutates pyproject.toml (e.g. API `@master` VCS rewrite). Default: false.' - required: false - default: 'false' - enable-cache: - description: 'Whether to enable Poetry dependency caching via actions/setup-python' + description: 'Install Python dependencies with uv' required: false default: 'true' @@ -47,54 +39,52 @@ 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 }} run: | LATEST_COMMIT=$(curl -s "https://api.github.com/repos/prowler-cloud/prowler/commits/master" | jq -r '.sha') 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' && inputs.update-lock == 'true' + - 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 }} - run: poetry lock + run: | + LATEST_COMMIT=$(curl -s "https://api.github.com/repos/prowler-cloud/prowler/commits/master" | jq -r '.sha') + echo "Latest commit hash: $LATEST_COMMIT" + 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: Install uv + shell: bash + 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 }} - # Disable cache when callers skip dependency install: Poetry 2.3.4 creates - # the venv in a path setup-python can't hash, breaking the post-step save-cache. - cache: ${{ inputs.enable-cache == 'true' && 'poetry' || '' }} - cache-dependency-path: ${{ inputs.enable-cache == 'true' && format('{0}/poetry.lock', inputs.working-directory) || '' }} + 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/dependabot.yml b/.github/dependabot.yml index 953c0742f6..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,17 +66,17 @@ 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 + # - 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 diff --git a/.github/labeler.yml b/.github/labeler.yml index 49ec92e0d5..1b0dfd0400 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -72,6 +72,11 @@ 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/**" + github_actions: - changed-files: - any-glob-to-any-file: ".github/workflows/*" @@ -109,6 +114,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..d9c2e1a901 --- /dev/null +++ b/.github/scripts/osv-scan.sh @@ -0,0 +1,122 @@ +#!/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: HIGH,CRITICAL,UNKNOWN — preserves prior .safety-policy.yml policy +# (ignore-cvss-severity-below: 7 + ignore-cvss-unknown-severity: False). +# 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:-HIGH,CRITICAL,UNKNOWN}" + +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 6abdc8c84c..0000000000 --- a/.github/workflows/api-bump-version.yml +++ /dev/null @@ -1,291 +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 - -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 }} - 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 1724e51d70..362f0dbbbf 100644 --- a/.github/workflows/api-code-quality.yml +++ b/.github/workflows/api-code-quality.yml @@ -35,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: > @@ -43,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 @@ -52,7 +53,7 @@ jobs: - name: Check for API changes id: check-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | api/** @@ -63,26 +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 - update-lock: 'true' - - 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 915a5b2722..634c63cb11 100644 --- a/.github/workflows/api-codeql.yml +++ b/.github/workflows/api-codeql.yml @@ -44,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: > @@ -61,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 ee73f97f1c..d4835db7d5 100644 --- a/.github/workflows/api-container-build-push.yml +++ b/.github/workflows/api-container-build-push.yml @@ -46,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 @@ -65,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 @@ -108,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: > @@ -122,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 @@ -132,14 +133,18 @@ 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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -150,7 +155,7 @@ jobs: - 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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: ${{ env.WORKING_DIRECTORY }} push: true @@ -170,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: > @@ -179,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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -230,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 @@ -277,7 +283,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/api-container-checks.yml b/.github/workflows/api-container-checks.yml index a7705fdeef..44482e2428 100644 --- a/.github/workflows/api-container-checks.yml +++ b/.github/workflows/api-container-checks.yml @@ -36,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: > @@ -50,7 +50,7 @@ jobs: - name: Check if Dockerfile changed id: dockerfile-changed - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: api/Dockerfile @@ -72,7 +72,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: > @@ -83,6 +83,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 @@ -103,7 +104,7 @@ jobs: - name: Check for API changes id: check-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: api/** files_ignore: | @@ -118,7 +119,7 @@ jobs: - name: Build container if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: ${{ env.API_WORKING_DIR }} push: false diff --git a/.github/workflows/api-security.yml b/.github/workflows/api-security.yml index d3bb9e6108..ba7f5fe58a 100644 --- a/.github/workflows/api-security.yml +++ b/.github/workflows/api-security.yml @@ -9,7 +9,9 @@ on: - 'api/**' - '.github/workflows/api-tests.yml' - '.github/workflows/api-security.yml' - - '.github/actions/setup-python-poetry/**' + - '.github/actions/setup-python-uv/**' + - '.github/actions/osv-scanner/**' + - '.github/scripts/osv-scan.sh' pull_request: branches: - "master" @@ -18,7 +20,9 @@ on: - 'api/**' - '.github/workflows/api-tests.yml' - '.github/workflows/api-security.yml' - - '.github/actions/setup-python-poetry/**' + - '.github/actions/setup-python-uv/**' + - '.github/actions/osv-scanner/**' + - '.github/scripts/osv-scan.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -35,6 +39,7 @@ jobs: timeout-minutes: 15 permissions: contents: read + pull-requests: write # osv-scanner action posts/updates a PR comment with findings strategy: matrix: python-version: @@ -45,17 +50,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 @@ -65,35 +73,39 @@ jobs: - name: Check for API changes id: check-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | api/** .github/workflows/api-security.yml - .safety-policy.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 - update-lock: 'true' - 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' - # Accepted CVEs, severity threshold, and ignore expirations live in ../.safety-policy.yml - run: poetry run safety check --policy-file ../.safety-policy.yml + 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 3d4e7e1799..36937bdc68 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -78,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: > @@ -87,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 @@ -101,7 +102,7 @@ jobs: - name: Check for API changes id: check-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | api/** @@ -112,17 +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 - update-lock: 'true' - 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 ff46749641..b1ea9ec7a2 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -30,7 +30,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/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 a66af9684c..c0c68d21b9 100644 --- a/.github/workflows/ci-zizmor.yml +++ b/.github/workflows/ci-zizmor.yml @@ -36,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: > @@ -51,6 +51,6 @@ jobs: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2 + 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 9649d18cfc..0af688b412 100644 --- a/.github/workflows/comment-label-update.yml +++ b/.github/workflows/comment-label-update.yml @@ -22,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 b0d6946387..502881bc73 100644 --- a/.github/workflows/conventional-commit.yml +++ b/.github/workflows/conventional-commit.yml @@ -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 a9ee7f7f95..4af9fefd5f 100644 --- a/.github/workflows/create-backport-label.yml +++ b/.github/workflows/create-backport-label.yml @@ -25,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 diff --git a/.github/workflows/docs-bump-version.yml b/.github/workflows/docs-bump-version.yml deleted file mode 100644 index 80c482b6c7..0000000000 --- a/.github/workflows/docs-bump-version.yml +++ /dev/null @@ -1,97 +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 - DOCS_FILE: docs/getting-started/installation/prowler-app.mdx - -permissions: {} - -jobs: - bump-version: - 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: Validate release version - run: | - if [[ ! $PROWLER_VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - echo "::error::Invalid version syntax: '$PROWLER_VERSION' (must be X.Y.Z)" - exit 1 - fi - if (( ${BASH_REMATCH[1]} != 5 )); then - echo "::error::Releasing another Prowler major version, aborting..." - exit 1 - fi - - - name: Checkout master branch - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ env.BASE_BRANCH }} - persist-credentials: false - - - name: Read current docs version on master - id: docs_version - run: | - CURRENT_DOCS_VERSION=$(grep -oP 'PROWLER_UI_VERSION="\K[^"]+' "${DOCS_FILE}") - echo "CURRENT_DOCS_VERSION=${CURRENT_DOCS_VERSION}" >> "${GITHUB_ENV}" - echo "Current docs version on master: $CURRENT_DOCS_VERSION" - echo "Target release version: $PROWLER_VERSION" - - # Skip if master is already at or ahead of the release version - # (re-run, or patch shipped against an older minor line) - HIGHEST=$(printf '%s\n%s\n' "${CURRENT_DOCS_VERSION}" "${PROWLER_VERSION}" | sort -V | tail -n1) - if [[ "${CURRENT_DOCS_VERSION}" == "${PROWLER_VERSION}" || "${HIGHEST}" != "${PROWLER_VERSION}" ]]; then - echo "skip=true" >> "${GITHUB_OUTPUT}" - echo "Skipping bump: current ($CURRENT_DOCS_VERSION) >= release ($PROWLER_VERSION)" - else - echo "skip=false" >> "${GITHUB_OUTPUT}" - fi - - - name: Bump versions in documentation - if: steps.docs_version.outputs.skip == 'false' - run: | - set -e - sed -i "s|PROWLER_UI_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_UI_VERSION=\"${PROWLER_VERSION}\"|" "${DOCS_FILE}" - sed -i "s|PROWLER_API_VERSION=\"${CURRENT_DOCS_VERSION}\"|PROWLER_API_VERSION=\"${PROWLER_VERSION}\"|" "${DOCS_FILE}" - echo "Files modified:" - git --no-pager diff - - - name: Create PR for documentation update to master - if: steps.docs_version.outputs.skip == 'false' - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 - with: - author: prowler-bot <179230569+prowler-bot@users.noreply.github.com> - token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} - base: ${{ env.BASE_BRANCH }} - commit-message: 'chore(docs): Bump version to v${{ env.PROWLER_VERSION }}' - branch: docs-version-bump-to-v${{ env.PROWLER_VERSION }} - title: 'chore(docs): Bump version to v${{ env.PROWLER_VERSION }}' - labels: no-changelog,skip-sync - body: | - ### Description - - 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 e166dbb673..6f8b47d17e 100644 --- a/.github/workflows/find-secrets.yml +++ b/.github/workflows/find-secrets.yml @@ -25,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: # We can't block as Trufflehog needs to verify secrets against vendors egress-policy: audit @@ -44,6 +44,6 @@ jobs: - 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@ef6e76c3c4023279497fab4721ffa071a722fd05 # v3.92.4 + uses: trufflesecurity/trufflehog@37b77001d0174ebec2fcca2bd83ff83a6d45a3ab # v3.95.3 with: extra_args: --results=verified,unknown diff --git a/.github/workflows/helm-chart-checks.yml b/.github/workflows/helm-chart-checks.yml index 66f5954f69..1691f21d35 100644 --- a/.github/workflows/helm-chart-checks.yml +++ b/.github/workflows/helm-chart-checks.yml @@ -33,7 +33,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/helm-chart-release.yml b/.github/workflows/helm-chart-release.yml index 43dcf4d355..ca179adeef 100644 --- a/.github/workflows/helm-chart-release.yml +++ b/.github/workflows/helm-chart-release.yml @@ -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/issue-lock-on-close.yml b/.github/workflows/issue-lock-on-close.yml index 1848585745..3778c77d05 100644 --- a/.github/workflows/issue-lock-on-close.yml +++ b/.github/workflows/issue-lock-on-close.yml @@ -22,7 +22,7 @@ jobs: issues: 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: > diff --git a/.github/workflows/issue-triage.lock.yml b/.github/workflows/issue-triage.lock.yml index b694e8ecef..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 @@ -870,12 +870,12 @@ 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 @@ -982,12 +982,12 @@ 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 @@ -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,12 +1164,12 @@ 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 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index d55e60ae88..5d519b20d1 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -27,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 @@ -46,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 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 1e1338e61c..784d8b9595 100644 --- a/.github/workflows/mcp-container-build-push.yml +++ b/.github/workflows/mcp-container-build-push.yml @@ -45,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 @@ -64,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 @@ -106,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: > @@ -114,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 @@ -125,7 +126,7 @@ jobs: persist-credentials: false - name: Login to DockerHub - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -136,7 +137,7 @@ jobs: - 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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: ${{ env.WORKING_DIRECTORY }} push: true @@ -164,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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -225,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 @@ -272,7 +274,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/mcp-container-checks.yml b/.github/workflows/mcp-container-checks.yml index 5b750f0998..7493e1b763 100644 --- a/.github/workflows/mcp-container-checks.yml +++ b/.github/workflows/mcp-container-checks.yml @@ -36,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: > @@ -50,7 +50,7 @@ jobs: - name: Check if Dockerfile changed id: dockerfile-changed - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: mcp_server/Dockerfile @@ -71,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: > @@ -79,6 +79,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 @@ -98,7 +99,7 @@ jobs: - name: Check for MCP changes id: check-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: mcp_server/** files_ignore: | @@ -111,7 +112,7 @@ jobs: - name: Build MCP container if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: ${{ env.MCP_WORKING_DIR }} push: false diff --git a/.github/workflows/mcp-pypi-release.yml b/.github/workflows/mcp-pypi-release.yml index 92015873b5..124f67eb19 100644 --- a/.github/workflows/mcp-pypi-release.yml +++ b/.github/workflows/mcp-pypi-release.yml @@ -29,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 @@ -67,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 @@ -88,7 +88,8 @@ jobs: # 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, but this publish workflow still runs on every release. + # 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 @@ -112,7 +113,7 @@ jobs: - name: Publish prowler-mcp package to PyPI if: steps.pypi-check.outputs.skip != 'true' - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + 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..66a5cd8c0d --- /dev/null +++ b/.github/workflows/mcp-security.yml @@ -0,0 +1,75 @@ +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.*' + paths: + - 'mcp_server/pyproject.toml' + - 'mcp_server/uv.lock' + - '.github/workflows/mcp-security.yml' + - '.github/actions/osv-scanner/**' + - '.github/scripts/osv-scan.sh' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + mcp-security-scans: + if: github.repository == 'prowler-cloud/prowler' + 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 index c3ad0a92d5..061ec61ff2 100644 --- a/.github/workflows/nightly-arm64-container-builds.yml +++ b/.github/workflows/nightly-arm64-container-builds.yml @@ -48,7 +48,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 @@ -61,7 +61,7 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Build ${{ matrix.component }} container (linux/arm64) - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: ${{ matrix.context }} file: ${{ matrix.dockerfile }} @@ -83,7 +83,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/pr-check-changelog.yml b/.github/workflows/pr-check-changelog.yml index 076ffea55c..0b9bc20552 100644 --- a/.github/workflows/pr-check-changelog.yml +++ b/.github/workflows/pr-check-changelog.yml @@ -31,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: > @@ -52,14 +52,14 @@ jobs: - name: Get changed files id: changed-files - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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 @@ -84,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 d906a522f1..4df61f49b0 100644 --- a/.github/workflows/pr-check-compliance-mapping.yml +++ b/.github/workflows/pr-check-compliance-mapping.yml @@ -35,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: > @@ -56,7 +56,7 @@ jobs: - name: Get changed files id: changed-files - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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 6eeac30d82..ff3871bf73 100644 --- a/.github/workflows/pr-conflict-checker.yml +++ b/.github/workflows/pr-conflict-checker.yml @@ -28,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 @@ -47,7 +47,7 @@ jobs: - name: Get changed files id: changed-files - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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 4a98ac70c4..2a1f3d77f1 100644 --- a/.github/workflows/pr-merged.yml +++ b/.github/workflows/pr-merged.yml @@ -26,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 07caea7255..ca324aa006 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -29,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 @@ -40,12 +40,11 @@ jobs: token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }} persist-credentials: false - - name: Setup Python with Poetry - uses: ./.github/actions/setup-python-poetry + - name: Setup Python with uv + uses: ./.github/actions/setup-python-uv with: python-version: '3.12' install-dependencies: 'false' - enable-cache: 'false' - name: Configure Git run: | @@ -54,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]} @@ -300,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: | @@ -339,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 }}' @@ -357,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 @@ -366,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. 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 c3f018c0d3..0000000000 --- a/.github/workflows/sdk-bump-version.yml +++ /dev/null @@ -1,247 +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 - -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@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(sdk): Bump version to v${{ env.NEXT_MINOR_VERSION }}' - branch: sdk-version-bump-to-v${{ env.NEXT_MINOR_VERSION }} - title: 'chore(sdk): Bump version to v${{ env.NEXT_MINOR_VERSION }}' - labels: no-changelog,skip-sync - body: | - ### Description - - 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(sdk): Bump version to v${{ env.FIRST_PATCH_VERSION }}' - branch: sdk-version-bump-to-v${{ env.FIRST_PATCH_VERSION }} - title: 'chore(sdk): Bump version to v${{ env.FIRST_PATCH_VERSION }}' - labels: no-changelog,skip-sync - body: | - ### Description - - 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(sdk): Bump version to v${{ env.NEXT_PATCH_VERSION }}' - branch: sdk-version-bump-to-v${{ env.NEXT_PATCH_VERSION }} - title: 'chore(sdk): Bump version to v${{ env.NEXT_PATCH_VERSION }}' - labels: no-changelog,skip-sync - body: | - ### Description - - 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 e5e5506df3..7c81e3ae3f 100644 --- a/.github/workflows/sdk-check-duplicate-test-names.yml +++ b/.github/workflows/sdk-check-duplicate-test-names.yml @@ -25,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 b777ee1657..5c76547989 100644 --- a/.github/workflows/sdk-code-quality.yml +++ b/.github/workflows/sdk-code-quality.yml @@ -32,7 +32,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: > @@ -48,7 +48,7 @@ jobs: - name: Check for SDK changes id: check-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: ./** files_ignore: | @@ -71,24 +71,26 @@ jobs: contrib/** **/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 }} - - name: Check Poetry lock file + - name: Check uv 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 0985ec1228..0696f7c6a5 100644 --- a/.github/workflows/sdk-codeql.yml +++ b/.github/workflows/sdk-codeql.yml @@ -51,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: > @@ -66,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 c55c3cceca..2dbaeec4a5 100644 --- a/.github/workflows/sdk-container-build-push.yml +++ b/.github/workflows/sdk-container-build-push.yml @@ -60,7 +60,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: > @@ -73,20 +73,10 @@ jobs: with: persist-credentials: false - - name: Setup Python with Poetry - uses: ./.github/actions/setup-python-poetry - with: - python-version: ${{ env.PYTHON_VERSION }} - install-dependencies: 'false' - enable-cache: 'false' - - - name: Inject poetry-bumpversion plugin - run: 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}" PROWLER_VERSION_MAJOR="${PROWLER_VERSION%%.*}" @@ -108,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 @@ -151,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: > @@ -159,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 @@ -177,13 +168,13 @@ jobs: persist-credentials: false - name: Login to DockerHub - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: public.ecr.aws username: ${{ secrets.PUBLIC_ECR_AWS_ACCESS_KEY_ID }} @@ -197,7 +188,7 @@ jobs: - 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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: ${{ env.DOCKERFILE_PATH }} @@ -218,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: > @@ -226,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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: public.ecr.aws username: ${{ secrets.PUBLIC_ECR_AWS_ACCESS_KEY_ID }} @@ -275,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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.TONIBLYX_DOCKERHUB_USERNAME }} password: ${{ secrets.TONIBLYX_DOCKERHUB_PASSWORD }} @@ -300,7 +292,7 @@ jobs: # Re-login as prowlercloud for cleanup of intermediate tags - name: Login to DockerHub (prowlercloud) if: always() - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -328,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 diff --git a/.github/workflows/sdk-container-checks.yml b/.github/workflows/sdk-container-checks.yml index 218c4e7ebc..42709ac6f7 100644 --- a/.github/workflows/sdk-container-checks.yml +++ b/.github/workflows/sdk-container-checks.yml @@ -9,7 +9,7 @@ on: - 'prowler/**' - 'Dockerfile*' - 'pyproject.toml' - - 'poetry.lock' + - 'uv.lock' - '.github/workflows/sdk-container-checks.yml' pull_request: branches: @@ -19,7 +19,7 @@ on: - 'prowler/**' - 'Dockerfile*' - 'pyproject.toml' - - 'poetry.lock' + - 'uv.lock' - '.github/workflows/sdk-container-checks.yml' concurrency: @@ -41,7 +41,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: > @@ -55,7 +55,7 @@ jobs: - name: Check if Dockerfile changed id: dockerfile-changed - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: Dockerfile @@ -77,7 +77,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 +85,7 @@ 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 @@ -108,7 +109,7 @@ jobs: - name: Check for SDK changes id: check-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: ./** files_ignore: | @@ -137,7 +138,7 @@ jobs: - name: Build SDK container if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . push: false diff --git a/.github/workflows/sdk-pypi-release.yml b/.github/workflows/sdk-pypi-release.yml index 7916431dec..06e3908be0 100644 --- a/.github/workflows/sdk-pypi-release.yml +++ b/.github/workflows/sdk-pypi-release.yml @@ -28,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 @@ -66,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 @@ -75,18 +75,17 @@ jobs: with: persist-credentials: false - - name: Setup Python with Poetry - uses: ./.github/actions/setup-python-poetry + - name: Setup Python with uv + uses: ./.github/actions/setup-python-uv with: python-version: ${{ env.PYTHON_VERSION }} install-dependencies: 'false' - enable-cache: '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 @@ -103,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 @@ -112,12 +111,11 @@ jobs: with: persist-credentials: false - - name: Setup Python with Poetry - uses: ./.github/actions/setup-python-poetry + - name: Setup Python with uv + uses: ./.github/actions/setup-python-uv with: python-version: ${{ env.PYTHON_VERSION }} install-dependencies: 'false' - enable-cache: '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 3ece09e8a5..41ec249a53 100644 --- a/.github/workflows/sdk-refresh-aws-services-regions.yml +++ b/.github/workflows/sdk-refresh-aws-services-regions.yml @@ -27,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 @@ -47,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 }} @@ -58,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 958d5beb80..65a36c7714 100644 --- a/.github/workflows/sdk-refresh-oci-regions.yml +++ b/.github/workflows/sdk-refresh-oci-regions.yml @@ -25,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 @@ -55,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 81504a4ec1..c18f56ea8f 100644 --- a/.github/workflows/sdk-security.yml +++ b/.github/workflows/sdk-security.yml @@ -9,10 +9,12 @@ on: - 'prowler/**' - 'tests/**' - 'pyproject.toml' - - 'poetry.lock' + - 'uv.lock' - '.github/workflows/sdk-tests.yml' - '.github/workflows/sdk-security.yml' - - '.github/actions/setup-python-poetry/**' + - '.github/actions/setup-python-uv/**' + - '.github/actions/osv-scanner/**' + - '.github/scripts/osv-scan.sh' pull_request: branches: - 'master' @@ -21,10 +23,12 @@ on: - 'prowler/**' - 'tests/**' - 'pyproject.toml' - - 'poetry.lock' + - 'uv.lock' - '.github/workflows/sdk-tests.yml' - '.github/workflows/sdk-security.yml' - - '.github/actions/setup-python-poetry/**' + - '.github/actions/setup-python-uv/**' + - '.github/actions/osv-scanner/**' + - '.github/scripts/osv-scan.sh' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -39,20 +43,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 @@ -62,7 +69,7 @@ jobs: - name: Check for SDK changes id: check-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: ./** @@ -87,21 +94,23 @@ jobs: contrib/** **/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: '3.12' - 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' - # Accepted CVEs, severity threshold, and ignore expirations live in .safety-policy.yml - run: poetry run safety check -r pyproject.toml --policy-file .safety-policy.yml + 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 0646e5f8b5..59316c0d95 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -32,7 +32,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: > @@ -46,6 +46,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 @@ -69,7 +70,7 @@ jobs: - name: Check for SDK changes id: check-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: ./** files_ignore: | @@ -92,9 +93,9 @@ jobs: contrib/** **/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 }} @@ -102,12 +103,12 @@ jobs: - name: Check if AWS files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-aws - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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' @@ -209,11 +210,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 }} @@ -232,16 +233,16 @@ jobs: - name: Check if Azure files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-azure - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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' @@ -256,16 +257,16 @@ jobs: - name: Check if GCP files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-gcp - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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' @@ -280,16 +281,16 @@ jobs: - name: Check if Kubernetes files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-kubernetes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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' @@ -304,16 +305,16 @@ jobs: - name: Check if GitHub files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-github - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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' @@ -324,20 +325,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@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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' @@ -352,16 +377,16 @@ jobs: - name: Check if M365 files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-m365 - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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' @@ -376,16 +401,16 @@ jobs: - name: Check if IaC files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-iac - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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' @@ -400,16 +425,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@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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' @@ -424,16 +449,16 @@ jobs: - name: Check if OCI files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-oraclecloud - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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' @@ -448,16 +473,16 @@ jobs: - name: Check if OpenStack files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-openstack - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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' @@ -472,16 +497,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@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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' @@ -496,16 +521,16 @@ jobs: - name: Check if Vercel files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-vercel - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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' @@ -515,21 +540,96 @@ jobs: with: 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 + + # 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@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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' @@ -544,16 +644,16 @@ jobs: - name: Check if Config files changed if: steps.check-changes.outputs.any_changed == 'true' id: changed-config - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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 88500551f2..625d0f483f 100644 --- a/.github/workflows/test-impact-analysis.yml +++ b/.github/workflows/test-impact-analysis.yml @@ -52,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: > @@ -68,7 +68,7 @@ jobs: - name: Get changed files id: changed-files - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + 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 273778e1c2..0000000000 --- a/.github/workflows/ui-bump-version.yml +++ /dev/null @@ -1,253 +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 - -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@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 c8b65e77d3..3dcb4f42eb 100644 --- a/.github/workflows/ui-codeql.yml +++ b/.github/workflows/ui-codeql.yml @@ -47,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: > @@ -62,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 d8db786d49..984256af7f 100644 --- a/.github/workflows/ui-container-build-push.yml +++ b/.github/workflows/ui-container-build-push.yml @@ -48,7 +48,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 @@ -67,7 +67,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 @@ -110,12 +110,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 @@ -129,7 +130,7 @@ jobs: persist-credentials: false - name: Login to DockerHub - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -140,7 +141,7 @@ jobs: - 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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: ${{ env.WORKING_DIRECTORY }} build-args: | @@ -163,7 +164,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: > @@ -172,9 +173,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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -224,7 +226,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 @@ -271,7 +273,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/ui-container-checks.yml b/.github/workflows/ui-container-checks.yml index d6ebaf46a3..0eae703fce 100644 --- a/.github/workflows/ui-container-checks.yml +++ b/.github/workflows/ui-container-checks.yml @@ -36,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: > @@ -50,7 +50,7 @@ jobs: - name: Check if Dockerfile changed id: dockerfile-changed - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: ui/Dockerfile @@ -72,7 +72,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 +80,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 @@ -99,7 +100,7 @@ jobs: - name: Check for UI changes id: check-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: ui/** files_ignore: | @@ -113,7 +114,7 @@ jobs: - name: Build UI container if: steps.check-changes.outputs.any_changed == 'true' - uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: ${{ env.UI_WORKING_DIR }} target: prod diff --git a/.github/workflows/ui-e2e-tests-v2.yml b/.github/workflows/ui-e2e-tests-v2.yml index aab93057c7..5e50adb19c 100644 --- a/.github/workflows/ui-e2e-tests-v2.yml +++ b/.github/workflows/ui-e2e-tests-v2.yml @@ -85,7 +85,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 @@ -130,6 +130,12 @@ 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. + run: docker build -t prowlercloud/prowler-api:latest ./api + - name: Start API services run: | export PROWLER_API_VERSION=latest @@ -158,7 +164,7 @@ 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 ' @@ -166,7 +172,7 @@ jobs: - name: Setup Node.js 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@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 @@ -178,7 +184,7 @@ jobs: run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV - name: Setup pnpm and Next.js cache - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.STORE_PATH }} @@ -198,7 +204,7 @@ jobs: run: pnpm run build - name: Cache Playwright browsers - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 id: playwright-cache with: path: ~/.cache/ms-playwright @@ -289,7 +295,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..470fae1f9c --- /dev/null +++ b/.github/workflows/ui-security.yml @@ -0,0 +1,75 @@ +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.*' + paths: + - 'ui/package.json' + - 'ui/pnpm-lock.yaml' + - '.github/workflows/ui-security.yml' + - '.github/actions/osv-scanner/**' + - '.github/scripts/osv-scan.sh' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + ui-security-scans: + if: github.repository == 'prowler-cloud/prowler' + 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 4abbfa72af..918755a629 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -16,7 +16,6 @@ concurrency: env: UI_WORKING_DIR: ./ui - NODE_VERSION: "24.13.0" permissions: {} @@ -32,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: > @@ -54,7 +53,7 @@ jobs: - name: Check for UI changes id: check-changes - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ui/** @@ -67,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@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ui/**/*.ts @@ -83,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@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 with: files: | ui/lib/** @@ -93,11 +92,11 @@ 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@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' @@ -113,7 +112,7 @@ jobs: - name: Setup pnpm and Next.js cache if: steps.check-changes.outputs.any_changed == 'true' - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: | ${{ env.STORE_PATH }} @@ -132,6 +131,10 @@ jobs: if: steps.check-changes.outputs.any_changed == 'true' run: pnpm run healthcheck + - 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: | @@ -158,7 +161,7 @@ jobs: - name: Cache Playwright browsers if: steps.check-changes.outputs.any_changed == 'true' id: playwright-cache - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('ui/pnpm-lock.yaml') }} diff --git a/.github/zizmor.yml b/.github/zizmor.yml index ed0d3682d8..0cfc6215e1 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,22 +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 9e0e4da849..4d73ffe990 100644 --- a/.gitignore +++ b/.gitignore @@ -60,7 +60,6 @@ htmlcov/ **/mcp-config.json **/mcpServers.json .mcp/ -.mcp.json # AI Coding Assistants - Cursor .cursorignore 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 b9c382cf51..4e79141bb7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -49,6 +49,14 @@ repos: 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.11.0 @@ -107,37 +115,30 @@ repos: files: { glob: ["{api,mcp_server}/**/*.py"] } priority: 20 - ## PYTHON — Poetry - - repo: https://github.com/python-poetry/poetry - rev: 2.3.4 + ## PYTHON — uv (API + SDK) + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.11.14 hooks: - - id: poetry-check - name: API - poetry-check - args: ["--directory=./api"] - files: { glob: ["api/{pyproject.toml,poetry.lock}"] } + - 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-lock - name: API - poetry-lock - args: ["--directory=./api"] - files: { glob: ["api/{pyproject.toml,poetry.lock}"] } + - id: uv-lock + name: SDK - uv-lock + args: ["--check", "--project=./"] + files: { glob: ["{pyproject.toml,uv.lock}"] } pass_filenames: false priority: 50 - - id: poetry-check - name: SDK - poetry-check - args: ["--directory=./"] - files: { glob: ["{pyproject.toml,poetry.lock}"] } - pass_filenames: false - priority: 50 - - - id: poetry-lock - name: SDK - poetry-lock - args: ["--directory=./"] - files: { glob: ["{pyproject.toml,poetry.lock}"] } - pass_filenames: false - priority: 50 + ## MARKDOWN + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.45.0 + hooks: + - id: markdownlint + priority: 30 ## CONTAINERS - repo: https://github.com/hadolint/hadolint @@ -179,16 +180,6 @@ repos: exclude: { glob: ["{contrib,skills}/**", "**/.venv/**", "**/*_test.py"] } priority: 40 - - id: safety - name: safety - description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities" - # Accepted CVEs, severity threshold, and ignore expirations live in .safety-policy.yml - entry: safety check --policy-file .safety-policy.yml - language: system - pass_filenames: false - files: { glob: ["**/pyproject.toml", "**/poetry.lock", "**/requirements*.txt", ".safety-policy.yml"] } - priority: 40 - - id: vulture name: vulture description: "Vulture finds unused code in Python programs." diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 5d375cd0df..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==2.3.4 + - 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/.safety-policy.yml b/.safety-policy.yml deleted file mode 100644 index fec97e2fb9..0000000000 --- a/.safety-policy.yml +++ /dev/null @@ -1,58 +0,0 @@ -# Safety policy for `safety check` (Safety CLI 3.x, v2 schema). -# Applied in: .pre-commit-config.yaml, .github/workflows/api-security.yml, -# .github/workflows/sdk-security.yml via `--policy-file`. -# -# Validate: poetry run safety validate policy_file --path .safety-policy.yml - -security: - # Scan unpinned requirements too. Prowler pins via poetry.lock, so this is - # defensive against accidental unpinned entries. - ignore-unpinned-requirements: False - - # CVSS severity filter. 7 = report only HIGH (7.0–8.9) and CRITICAL (9.0–10.0). - # Reference: 9=CRITICAL only, 7=CRITICAL+HIGH, 4=CRITICAL+HIGH+MEDIUM. - ignore-cvss-severity-below: 7 - - # Unknown severity is unrated, not safe. Keep False so unrated CVEs still fail - # the build and get a human eye. Flip to True only if noise is unmanageable. - ignore-cvss-unknown-severity: False - - # Fail the build when a non-ignored vulnerability is found. - continue-on-vulnerability-error: False - - # Explicit accepted vulnerabilities. Each entry MUST have a reason and an - # expiry. Expired entries fail the scan, forcing re-audit. - ignore-vulnerabilities: - 77744: - reason: "Botocore requires urllib3 1.X. Remove once upgraded to urllib3 2.X." - expires: '2026-10-22' - 77745: - reason: "Botocore requires urllib3 1.X. Remove once upgraded to urllib3 2.X." - expires: '2026-10-22' - 79023: - reason: "knack ReDoS; blocked until azure-cli-core (via cartography) allows knack >=0.13.0." - expires: '2026-10-22' - 79027: - reason: "knack ReDoS; blocked until azure-cli-core (via cartography) allows knack >=0.13.0." - expires: '2026-10-22' - 86217: - reason: "alibabacloud-tea-openapi==0.4.3 blocks upgrade to cryptography >=46.0.0." - expires: '2026-10-22' - 71600: - reason: "CVE-2024-1135 false positive. Fixed in gunicorn 22.0.0; project uses 23.0.0." - expires: '2026-10-22' - 70612: - reason: "TBD - audit required. Reason not documented in prior --ignore list." - expires: '2026-07-22' - 66963: - reason: "TBD - audit required. Reason not documented in prior --ignore list." - expires: '2026-07-22' - 74429: - reason: "TBD - audit required. Reason not documented in prior --ignore list." - expires: '2026-07-22' - 76352: - reason: "TBD - audit required. Reason not documented in prior --ignore list." - expires: '2026-07-22' - 76353: - reason: "TBD - audit required. Reason not documented in prior --ignore list." - expires: '2026-07-22' diff --git a/.worktreeinclude b/.worktreeinclude index a9fce75e0f..b2828287f7 100644 --- a/.worktreeinclude +++ b/.worktreeinclude @@ -1,2 +1,3 @@ .envrc ui/.env.local +openspec/ diff --git a/AGENTS.md b/AGENTS.md index 47b7e12dcb..1cf023889c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ 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) | @@ -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) | @@ -148,7 +150,7 @@ Prowler is an open-source cloud security assessment tool supporting AWS, Azure, | Component | Location | Tech Stack | |-----------|----------|------------| -| SDK | `prowler/` | Python 3.10+, Poetry 2.3+ | +| SDK | `prowler/` | Python 3.10+, uv | | API | `api/` | Django 5.1, DRF, Celery | | UI | `ui/` | Next.js 16, React 19, Tailwind 4 | | MCP Server | `mcp_server/` | FastMCP, Python 3.12+ | @@ -160,13 +162,13 @@ Prowler is an open-source cloud security assessment tool supporting AWS, Azure, ```bash # Setup -poetry install --with dev -poetry run prek install +uv sync +uv run prek install # Code quality -poetry run make lint -poetry run make format -poetry run prek 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 199d7ee13c..90c1ed1954 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,4 @@ -# Do you want to learn on how to... +# Do you want to learn on how to - [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) @@ -32,5 +32,6 @@ Provider-specific developer notes: 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 ca2fee8903..0ab4458fad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,28 +76,28 @@ 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==2.3.4 + 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 # 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..8bdb9bf156 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ format: ## Format Code lint: ## Lint Code @echo "Running flake8..." - flake8 . --ignore=E266,W503,E203,E501,W605,E128 --exclude contrib + flake8 . --ignore=E266,W503,E203,E501,W605,E128 --exclude .venv,contrib @echo "Running black... " black --check . @echo "Running pylint..." @@ -35,7 +35,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 +56,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 8f4565481d..d2808daf84 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- - + 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. @@ -22,8 +22,8 @@ PyPI Downloads Docker Pulls AWS ECR Gallery - - + Codecov coverage + Linux Foundation insights health score

Version @@ -36,7 +36,7 @@


- + Prowler Cloud demo

# Description @@ -104,22 +104,25 @@ 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 | 595 | 84 | 43 | 17 | Official | UI, API, CLI | +| AWS | 600 | 84 | 44 | 18 | Official | UI, API, CLI | | Azure | 167 | 22 | 19 | 16 | Official | UI, API, CLI | | GCP | 102 | 18 | 17 | 12 | Official | UI, API, CLI | | Kubernetes | 83 | 7 | 7 | 11 | Official | UI, API, CLI | | GitHub | 24 | 3 | 1 | 5 | Official | UI, API, CLI | -| M365 | 101 | 10 | 4 | 10 | Official | UI, API, CLI | +| M365 | 102 | 10 | 4 | 10 | Official | UI, API, CLI | | OCI | 51 | 14 | 4 | 10 | Official | UI, API, CLI | -| Alibaba Cloud | 61 | 9 | 4 | 9 | Official | UI, API, CLI | +| Alibaba Cloud | 63 | 9 | 4 | 9 | Official | UI, API, CLI | | Cloudflare | 29 | 3 | 0 | 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 | | 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 | 25 | 4 | 2 | 4 | Official | CLI | +| Google Workspace | 39 | 5 | 2 | 5 | Official | UI, API, CLI | | OpenStack | 34 | 5 | 0 | 9 | Official | UI, API, CLI | -| Vercel | 26 | 6 | 0 | 5 | Official | CLI | +| Vercel | 26 | 6 | 0 | 8 | Official | UI, API, CLI | +| Okta | 1 | 1 | 0 | 1 | Official | CLI | +| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 0 | 1 | Unofficial | CLI | +| StackIT [Contact us](https://prowler.com/contact) | 4 | 1 | 0 | 1 | Unofficial | CLI | | NHN | 6 | 2 | 1 | 0 | Unofficial | CLI | > [!Note] @@ -144,11 +147,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 +163,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 +188,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 +209,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,7 +250,7 @@ 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: @@ -260,14 +270,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. @@ -282,27 +292,21 @@ 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-app-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. +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 @@ -347,7 +351,7 @@ Full configuration, per-provider authentication, and SARIF examples: [Prowler Gi ## 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 index 3b5b8d8bdf..2be0649402 100644 --- a/action.yml +++ b/action.yml @@ -22,7 +22,7 @@ inputs: 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-app-import-findings#using-the-cli + 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: @@ -167,7 +167,7 @@ runs: - 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@d4b3ca9fa7f69d38bfcd667bdc45bc373d16277e # v4 + uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 with: sarif_file: ${{ steps.find-sarif.outputs.sarif_path }} category: ${{ inputs.sarif-category }} @@ -299,7 +299,7 @@ runs: 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-app-import-findings#using-the-cli))" + 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." 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 6d9ac36263..dc404079ec 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,15 +2,88 @@ All notable changes to the **Prowler API** are documented in this file. -## [1.28.0] (Prowler UNRELEASED) +## [1.31.0] (Prowler UNRELEASED) + +### 🚀 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) + +### 🔄 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) + +### 🔐 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) flagged by osv-scanner in `api/uv.lock` [(#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) --- diff --git a/api/Dockerfile b/api/Dockerfile index 55b14dcf16..259492cb08 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -14,6 +14,7 @@ ENV ZIZMOR_VERSION=${ZIZMOR_VERSION} # hadolint ignore=DL3008 RUN apt-get update && apt-get install -y --no-install-recommends \ wget \ + git \ libicu72 \ gcc \ g++ \ @@ -88,21 +89,21 @@ 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==2.3.4 + 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 +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..ea9661366f 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,11 @@ 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 +### Docker deployment This method requires `docker` and `docker compose`. -### Clone the repository +#### Clone the repository ```console # HTTPS @@ -214,13 +211,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 +230,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 +238,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 +278,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 +298,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 fb1e1693b4..9b2964b479 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,19 @@ 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}" + exec uv run python manage.py runserver 0.0.0.0:"${DJANGO_PORT:-8080}" } 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.wsgi:application } resolve_worker_hostname() { @@ -47,7 +47,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,7 +56,7 @@ start_worker() { start_worker_beat() { echo "Starting the worker-beat..." - 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() { @@ -64,10 +64,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..a47b4f36a9 --- /dev/null +++ b/api/docs/orphan-task-recovery.md @@ -0,0 +1,105 @@ +# 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_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 8009adffbd..0000000000 --- a/api/poetry.lock +++ /dev/null @@ -1,9427 +0,0 @@ -# This file is automatically @generated by Poetry 2.3.4 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.5" -description = "Async http client/server framework (asyncio)" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "aiohttp-3.13.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:02222e7e233295f40e011c1b00e3b0bd451f22cf853a0304c3595633ee47da4b"}, - {file = "aiohttp-3.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bace460460ed20614fa6bc8cb09966c0b8517b8c58ad8046828c6078d25333b5"}, - {file = "aiohttp-3.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f546a4dc1e6a5edbb9fd1fd6ad18134550e096a5a43f4ad74acfbd834fc6670"}, - {file = "aiohttp-3.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86969d012e51b8e415a8c6ce96f7857d6a87d6207303ab02d5d11ef0cad2274"}, - {file = "aiohttp-3.13.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b6f6cd1560c5fa427e3b6074bb24d2c64e225afbb7165008903bd42e4e33e28a"}, - {file = "aiohttp-3.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:636bc362f0c5bbc7372bc3ae49737f9e3030dbce469f0f422c8f38079780363d"}, - {file = "aiohttp-3.13.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a7cbeb06d1070f1d14895eeeed4dac5913b22d7b456f2eb969f11f4b3993796"}, - {file = "aiohttp-3.13.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca9ef7517fd7874a1a08970ae88f497bf5c984610caa0bf40bd7e8450852b95"}, - {file = "aiohttp-3.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:019a67772e034a0e6b9b17c13d0a8fe56ad9fb150fc724b7f3ffd3724288d9e5"}, - {file = "aiohttp-3.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f34ecee82858e41dd217734f0c41a532bd066bcaab636ad830f03a30b2a96f2a"}, - {file = "aiohttp-3.13.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4eac02d9af4813ee289cd63a361576da36dba57f5a1ab36377bc2600db0cbb73"}, - {file = "aiohttp-3.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4beac52e9fe46d6abf98b0176a88154b742e878fdf209d2248e99fcdf73cd297"}, - {file = "aiohttp-3.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c180f480207a9b2475f2b8d8bd7204e47aec952d084b2a2be58a782ffcf96074"}, - {file = "aiohttp-3.13.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2837fb92951564d6339cedae4a7231692aa9f73cbc4fb2e04263b96844e03b4e"}, - {file = "aiohttp-3.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9010032a0b9710f58012a1e9c222528763d860ba2ee1422c03473eab47703e7"}, - {file = "aiohttp-3.13.5-cp310-cp310-win32.whl", hash = "sha256:7c4b6668b2b2b9027f209ddf647f2a4407784b5d88b8be4efcc72036f365baf9"}, - {file = "aiohttp-3.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:cd3db5927bf9167d5a6157ddb2f036f6b6b0ad001ac82355d43e97a4bde76d76"}, - {file = "aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6"}, - {file = "aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d"}, - {file = "aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c"}, - {file = "aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb"}, - {file = "aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6"}, - {file = "aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13"}, - {file = "aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174"}, - {file = "aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc"}, - {file = "aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6"}, - {file = "aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49"}, - {file = "aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8"}, - {file = "aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d"}, - {file = "aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c"}, - {file = "aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac"}, - {file = "aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3"}, - {file = "aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06"}, - {file = "aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8"}, - {file = "aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9"}, - {file = "aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416"}, - {file = "aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2"}, - {file = "aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4"}, - {file = "aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9"}, - {file = "aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5"}, - {file = "aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e"}, - {file = "aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1"}, - {file = "aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286"}, - {file = "aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9"}, - {file = "aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88"}, - {file = "aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3"}, - {file = "aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b"}, - {file = "aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe"}, - {file = "aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14"}, - {file = "aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3"}, - {file = "aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1"}, - {file = "aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61"}, - {file = "aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832"}, - {file = "aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9"}, - {file = "aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090"}, - {file = "aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b"}, - {file = "aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a"}, - {file = "aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8"}, - {file = "aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665"}, - {file = "aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540"}, - {file = "aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb"}, - {file = "aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46"}, - {file = "aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8"}, - {file = "aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d"}, - {file = "aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6"}, - {file = "aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c"}, - {file = "aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc"}, - {file = "aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83"}, - {file = "aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c"}, - {file = "aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be"}, - {file = "aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25"}, - {file = "aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56"}, - {file = "aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2"}, - {file = "aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a"}, - {file = "aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be"}, - {file = "aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b"}, - {file = "aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94"}, - {file = "aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d"}, - {file = "aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7"}, - {file = "aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772"}, - {file = "aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5"}, - {file = "aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1"}, - {file = "aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b"}, - {file = "aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3"}, - {file = "aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162"}, - {file = "aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a"}, - {file = "aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254"}, - {file = "aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36"}, - {file = "aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f"}, - {file = "aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800"}, - {file = "aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf"}, - {file = "aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b"}, - {file = "aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a"}, - {file = "aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8"}, - {file = "aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be"}, - {file = "aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b"}, - {file = "aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6"}, - {file = "aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037"}, - {file = "aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500"}, - {file = "aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9"}, - {file = "aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8"}, - {file = "aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9"}, - {file = "aiohttp-3.13.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:347542f0ea3f95b2a955ee6656461fa1c776e401ac50ebce055a6c38454a0adf"}, - {file = "aiohttp-3.13.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:178c7b5e62b454c2bc790786e6058c3cc968613b4419251b478c153a4aec32b1"}, - {file = "aiohttp-3.13.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af545c2cffdb0967a96b6249e6f5f7b0d92cdfd267f9d5238d5b9ca63e8edb10"}, - {file = "aiohttp-3.13.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:206b7b3ef96e4ce211754f0cd003feb28b7d81f0ad26b8d077a5d5161436067f"}, - {file = "aiohttp-3.13.5-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ee5e86776273de1795947d17bddd6bb19e0365fd2af4289c0d2c5454b6b1d36b"}, - {file = "aiohttp-3.13.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95d14ca7abefde230f7639ec136ade282655431fd5db03c343b19dda72dd1643"}, - {file = "aiohttp-3.13.5-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:912d4b6af530ddb1338a66229dac3a25ff11d4448be3ec3d6340583995f56031"}, - {file = "aiohttp-3.13.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e999f0c88a458c836d5fb521814e92ed2172c649200336a6df514987c1488258"}, - {file = "aiohttp-3.13.5-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39380e12bd1f2fdab4285b6e055ad48efbaed5c836433b142ed4f5b9be71036a"}, - {file = "aiohttp-3.13.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9efcc0f11d850cefcafdd9275b9576ad3bfb539bed96807663b32ad99c4d4b88"}, - {file = "aiohttp-3.13.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:147b4f501d0292077f29d5268c16bb7c864a1f054d7001c4c1812c0421ea1ed0"}, - {file = "aiohttp-3.13.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d147004fede1b12f6013a6dbb2a26a986a671a03c6ea740ddc76500e5f1c399f"}, - {file = "aiohttp-3.13.5-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9277145d36a01653863899c665243871434694bcc3431922c3b35c978061bdb8"}, - {file = "aiohttp-3.13.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4e704c52438f66fdd89588346183d898bb42167cf88f8b7ff1c0f9fc957c348f"}, - {file = "aiohttp-3.13.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8a4d3427e8de1312ddf309cc482186466c79895b3a139fed3259fc01dfa9a5b"}, - {file = "aiohttp-3.13.5-cp39-cp39-win32.whl", hash = "sha256:6f497a6876aa4b1a102b04996ce4c1170c7040d83faa9387dd921c16e30d5c83"}, - {file = "aiohttp-3.13.5-cp39-cp39-win_amd64.whl", hash = "sha256:cb979826071c0986a5f08333a36104153478ce6018c58cba7f9caddaf63d5d67"}, - {file = "aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1"}, -] - -[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.4" -description = "Alibaba Cloud openapi SDK Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "alibabacloud_tea_openapi-0.4.4-py3-none-any.whl", hash = "sha256:cea6bc1fe35b0319a8752cb99eb0ecb0dab7ca1a71b99c12970ba0867410995f"}, - {file = "alibabacloud_tea_openapi-0.4.4.tar.gz", hash = "sha256:1b0917bc03cd49417da64945e92731716d53e2eb8707b235f54e45b7473221ce"}, -] - -[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 = {version = ">=3.0.0,<47.0.0", markers = "python_version >= \"3.8\""} -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.9" -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.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3"}, - {file = "authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04"}, -] - -[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 = "24.0.0" -description = "Microsoft Azure Resource Management Client Library for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "azure_mgmt_resource-24.0.0-py3-none-any.whl", hash = "sha256:27b32cd223e2784269f5a0db3c282042886ee4072d79cedc638438ece7cd0df4"}, - {file = "azure_mgmt_resource-24.0.0.tar.gz", hash = "sha256:cf6b8995fcdd407ac9ff1dd474087129429a1d90dbb1ac77f97c19b96237b265"}, -] - -[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-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.135.0" -description = "Explore assets and their relationships across your technical infrastructure." -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "cartography-0.135.0-py3-none-any.whl", hash = "sha256:c62c32a6917b8f23a8b98fe2b6c7c4a918b50f55918482966c4dae1cf5f538e1"}, - {file = "cartography-0.135.0.tar.gz", hash = "sha256:3f500cd22c3b392d00e8b49f62acc95fd4dcd559ce514aafe2eb8101133c7a49"}, -] - -[package.dependencies] -adal = ">=1.2.4" -aioboto3 = ">=15.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 = ">=24.0.0,<25" -azure-mgmt-security = ">=5.0.0" -azure-mgmt-sql = ">=3.0.1" -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" -crowdstrike-falconpy = ">=0.5.1" -cryptography = ">=45.0.0" -dnspython = ">=2.0.0" -duo-client = ">=5.5.0" -google-api-python-client = ">=2.0.0" -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 = ">=4.0.0" -msgraph-sdk = ">=1.53.0" -msrestazure = ">=0.6.4" -neo4j = ">=6.0.0" -oci = ">=2.71.0" -okta = "<1.0.0" -packageurl-python = ">=0.17.0" -packaging = ">=26.0.0" -pagerduty = ">=4.0.1" -policyuniverse = ">=1.1.0.0" -PyJWT = {version = ">=2.0.0", extras = ["crypto"]} -python-dateutil = ">=2.9.0" -python-digitalocean = ">=1.16.0" -pyyaml = ">=5.3.1" -requests = ">=2.22.0" -scaleway = ">=2.10.0" -slack-sdk = ">=3.37.0" -statsd = ">=4.0.0" -typer = ">=0.9.0" -types-aiobotocore-ecr = ">=3.1.0" -workos = ">=5.44.0" -xmltodict = ">=1.0.0" - -[[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 = "46.0.7" -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.8" -groups = ["main", "dev"] -files = [ - {file = "cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b"}, - {file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85"}, - {file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e"}, - {file = "cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457"}, - {file = "cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b"}, - {file = "cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2"}, - {file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e"}, - {file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee"}, - {file = "cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298"}, - {file = "cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb"}, - {file = "cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0"}, - {file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85"}, - {file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e"}, - {file = "cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246"}, - {file = "cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4"}, - {file = "cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5"}, -] - -[package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox[uv] (>=2024.4.15)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] -sdist = ["build (>=1.0.0)"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==46.0.7)", "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.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 = "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 = "4.3.0" -description = "A lightweight library for converting complex datatypes to and from native Python datatypes." -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "marshmallow-4.3.0-py3-none-any.whl", hash = "sha256:46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46"}, - {file = "marshmallow-4.3.0.tar.gz", hash = "sha256:fb43c53b3fe240b8f6af37223d6ef1636f927ad9bea8ab323afad95dff090880"}, -] - -[[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.55.0" -description = "The Microsoft Graph Python SDK" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "msgraph_sdk-1.55.0-py3-none-any.whl", hash = "sha256:c8e68ebc4b88af5111de312e7fa910a4e76ddf48a4534feadb1fb8a411c48cfc"}, - {file = "msgraph_sdk-1.55.0.tar.gz", hash = "sha256:6df691a31954a050d26b8a678968017e157d940fb377f2a8a4e17a9741b98756"}, -] - -[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.169.0" -description = "Oracle Cloud Infrastructure Python SDK" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "oci-2.169.0-py3-none-any.whl", hash = "sha256:c71bb5143f307791082b3e33cc1545c2490a518cfed85ab1948ef5107c36d30b"}, - {file = "oci-2.169.0.tar.gz", hash = "sha256:f3c5fff00b01783b5325ea7b13bf140053ec1e9f41da20bfb9c8a349ee7662fa"}, -] - -[package.dependencies] -certifi = "*" -circuitbreaker = {version = ">=1.3.1,<3.0.0", markers = "python_version >= \"3.7\""} -cryptography = ">=3.2.1,<47.0.0" -pyOpenSSL = ">=17.5.0,<27.0.0" -python-dateutil = ">=2.5.3,<3.0.0" -pytz = ">=2016.10" -urllib3 = {version = ">=2.6.3", markers = "python_version >= \"3.10.0\""} - -[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 = "prek" -version = "0.3.9" -description = "A Git hook manager written in Rust, designed as a drop-in alternative to pre-commit." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "prek-0.3.9-py3-none-linux_armv6l.whl", hash = "sha256:3ed793d51bfaa27bddb64d525d7acb77a7c8644f549412d82252e3eb0b88aad8"}, - {file = "prek-0.3.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:399c58400c0bd0b82a93a3c09dc1bfd88d8d0cfb242d414d2ed247187b06ead1"}, - {file = "prek-0.3.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ea1ffb124e92f081b8e2ca5b5a623a733efb3be0c5b1f4b7ffe2ee17d1f20c"}, - {file = "prek-0.3.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:aaf639f95b7301639298311d8d44aad0d0b4864e9736083ad3c71ce9765d37ab"}, - {file = "prek-0.3.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff104863b187fa443ea8451ca55d51e2c6e94f99f00d88784b5c3c4c623f1ebe"}, - {file = "prek-0.3.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:039ecaf87c63a3e67cca645ebd5bc5eb6aafa6c9d929e9a27b2921e7849d7ef9"}, - {file = "prek-0.3.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3bde2a3d045705095983c7f78ba04f72a7565fe1c2b4e85f5628502a254754ff"}, - {file = "prek-0.3.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28a0960a21543563e2c8e19aaad176cc8423a87aac3c914d0f313030d7a9244a"}, - {file = "prek-0.3.9-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0dfb5d5171d7523271909246ee306b4dc3d5b63752e7dd7c7e8a8908fc9490d1"}, - {file = "prek-0.3.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82b791bd36c1430c84d3ae7220a85152babc7eaf00f70adcb961bd594e756ba3"}, - {file = "prek-0.3.9-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:6eac6d2f736b041118f053a1487abed468a70dd85a8688eaf87bb42d3dcecf20"}, - {file = "prek-0.3.9-py3-none-musllinux_1_1_i686.whl", hash = "sha256:5517e46e761367a3759b3168eabc120840ffbca9dfbc53187167298a98f87dc4"}, - {file = "prek-0.3.9-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:92024778cf78683ca32687bb249ab6a7d5c33887b5ee1d1a9f6d0c14228f4cf3"}, - {file = "prek-0.3.9-py3-none-win32.whl", hash = "sha256:7f89c55e5f480f5d073769e319924ad69d4bf9f98c5cb46a83082e26e634c958"}, - {file = "prek-0.3.9-py3-none-win_amd64.whl", hash = "sha256:7722f3372eaa83b147e70a43cb7b9fe2128c13d0c78d8a1cdbf2a8ec2ee071eb"}, - {file = "prek-0.3.9-py3-none-win_arm64.whl", hash = "sha256:0bced6278d6cc8a4b46048979e36bc9da034611dc8facd77ab123177b833a929"}, - {file = "prek-0.3.9.tar.gz", hash = "sha256:f82b92d81f42f1f90a47f5fbbf492373e25ef1f790080215b2722dd6da66510e"}, -] - -[[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.26.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.4" -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 = "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-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 = "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" -google-api-python-client = "2.163.0" -google-auth-httplib2 = "0.2.0" -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.55.0" -numpy = "2.0.2" -oci = "2.169.0" -openstacksdk = "4.2.0" -pandas = "2.2.3" -py-iam-expand = "0.1.0" -py-ocsf-models = "0.8.1" -pydantic = "2.12.5" -pygithub = "2.8.0" -python-dateutil = "2.9.0.post0" -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 = "16798e293da365965120961e6539e3a9756564f9" - -[[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.3" -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.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"}, - {file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"}, -] - -[[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.20.0" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.9" -groups = ["main", "dev"] -files = [ - {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, - {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pyjwt" -version = "2.12.1" -description = "JSON Web Token implementation in Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"}, - {file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"}, -] - -[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 = "sys_platform == \"win32\" and (platform_system == \"Windows\" or platform_system == \"Darwin\" or platform_system == \"Linux\")" -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 = "26.0.0" -description = "Python wrapper module around the OpenSSL library" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81"}, - {file = "pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc"}, -] - -[package.dependencies] -cryptography = ">=46.0.0,<47" -typing-extensions = {version = ">=4.9", markers = "python_version < \"3.13\" and python_version >= \"3.8\""} - -[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 = "9.0.3" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9"}, - {file = "pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c"}, -] - -[package.dependencies] -colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} -iniconfig = ">=1.0.1" -packaging = ">=22" -pluggy = ">=1.5,<2" -pygments = ">=2.7.2" - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "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.33.1" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"}, - {file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"}, -] - -[package.dependencies] -certifi = ">=2023.5.7" -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.26,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] - -[[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 = "workos" -version = "6.0.4" -description = "WorkOS Python Client" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "workos-6.0.4-py3-none-any.whl", hash = "sha256:548668b3702673536f853ba72a7b5bbbc269e467aaf9ac4f477b6e0177df5e21"}, - {file = "workos-6.0.4.tar.gz", hash = "sha256:b0bfe8fd212b8567422c4ea3732eb33608794033eb3a69900c6b04db183c32d6"}, -] - -[package.dependencies] -cryptography = ">=46.0,<47.0" -httpx = ">=0.28,<1.0" -pyjwt = ">=2.12,<3.0" - -[[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 = "a3ab982d11a87d951ff15694d2ca7fd51f1f51a451abb0baa067ccf6966367a8" diff --git a/api/pyproject.toml b/api/pyproject.toml index da9743299b..5e18ffac20 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.5.0", + "tqdm==4.67.1", + "vulture==2.14", + "prek==0.3.9" +] [project] authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}] @@ -24,14 +42,14 @@ dependencies = [ "drf-spectacular-jsonapi==0.5.1", "defusedxml==0.7.1", "gunicorn==23.0.0", - "lxml==5.3.2", + "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)", @@ -50,28 +68,392 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.28.0" +version = "1.31.0" -[project.scripts] -celery = "src.backend.config.settings.celery" - -[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" -mypy = "1.10.1" -prek = "0.3.9" -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.5.0" -safety = "3.7.0" -tqdm = "4.67.1" -vulture = "2.14" +[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.13.5", + "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.9", + "autopep8==2.3.2", + "awsipranges==0.3.3", + "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.135.0", + "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", + "detect-secrets==1.5.0", + "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-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==23.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.202602021", + "idna==3.11", + "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", + "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.0.2", + "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.1.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.5.0", + "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", + "vine==5.1.0", + "vulture==2.14", + "wcwidth==0.5.3", + "websocket-client==1.9.0", + "werkzeug==3.1.7", + "workos==6.0.4", + "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; cartography 0.135.0 declares okta<1.0.0 for an +# integration prowler does not import. +# +# 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", + "microsoft-kiota-abstractions==1.9.9", + "dulwich==1.2.5", + "pyjwt[crypto]==2.13.0" +] diff --git a/api/src/backend/api/apps.py b/api/src/backend/api/apps.py index 3209f75888..6c3a4ec2d9 100644 --- a/api/src/backend/api/apps.py +++ b/api/src/backend/api/apps.py @@ -1,12 +1,14 @@ import logging import os import sys + from pathlib import Path +from django.apps import AppConfig +from django.conf import settings + from config.custom_logging import BackendLogger from config.env import env -from django.apps import AppConfig -from django.conf import settings logger = logging.getLogger(BackendLogger.API) @@ -30,7 +32,6 @@ class ApiConfig(AppConfig): 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 # 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,37 +42,8 @@ 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 eager Neo4j init for tests, some Django commands, and Celery (prefork pool: driver must stay lazy, no post_fork hook) - 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 eager Neo4j init: tests, some Django commands, or Celery prefork pool (driver stays lazy)" - ) - - 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 + # Neo4j driver is created lazily on first use (see api.attack_paths.database). + # App init never contacts Neo4j, so a Neo4j outage cannot block API startup. def _ensure_crypto_keys(self): """ diff --git a/api/src/backend/api/attack_paths/database.py b/api/src/backend/api/attack_paths/database.py index f5fddd0613..d5cc1698a7 100644 --- a/api/src/backend/api/attack_paths/database.py +++ b/api/src/backend/api/attack_paths/database.py @@ -1,22 +1,24 @@ import atexit import logging import threading + from contextlib import contextmanager from typing import Any, Iterator from uuid import UUID import neo4j import neo4j.exceptions + from config.env import env from django.conf import settings + +from api.attack_paths.retryable_session import RetryableSession from tasks.jobs.attack_paths.config import ( BATCH_SIZE, PROVIDER_RESOURCE_LABEL, get_provider_label, ) -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 @@ -28,6 +30,9 @@ 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) +# Shorter than CONN_ACQUISITION_TIMEOUT — the driver requires acquisition to be +# the longer of the two (it may include opening a new connection). +CONNECTION_TIMEOUT = env.int("NEO4J_CONNECTION_TIMEOUT", default=5) CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15) READ_EXCEPTION_CODES = [ "Neo.ClientError.Statement.AccessMode", @@ -58,15 +63,24 @@ def init_driver() -> neo4j.Driver: uri = get_uri() config = settings.DATABASES["neo4j"] - _driver = neo4j.GraphDatabase.driver( + driver = neo4j.GraphDatabase.driver( uri, auth=(config["USER"], config["PASSWORD"]), keep_alive=True, max_connection_lifetime=7200, + connection_timeout=CONNECTION_TIMEOUT, connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT, max_connection_pool_size=50, ) - _driver.verify_connectivity() + # Publish the singleton only after connectivity is verified so a + # failed probe does not leave an unverified driver behind. Close the + # driver on failure so a repeatedly-probed outage cannot leak pools. + try: + driver.verify_connectivity() + except Exception: + driver.close() + raise + _driver = driver # Register cleanup handler (only runs once since we're inside the _driver is None block) atexit.register(close_driver) diff --git a/api/src/backend/api/attack_paths/queries/aws.py b/api/src/backend/api/attack_paths/queries/aws.py index f50935c49f..81f91de24f 100644 --- a/api/src/backend/api/attack_paths/queries/aws.py +++ b/api/src/backend/api/attack_paths/queries/aws.py @@ -484,8 +484,8 @@ AWS_BEDROCK_PRIVESC_PASSROLE_CODE_INTERPRETER = AttackPathsQueryDefinition( OR action = '*' ) - // 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'}}) + // 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 @@ -536,8 +536,8 @@ AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER = AttackPathsQueryDefinition( OR action = '*' ) - // 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 UNWIND paths AS p diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py index 25b8fb6735..678aff8d57 100644 --- a/api/src/backend/api/compliance.py +++ b/api/src/backend/api/compliance.py @@ -1,7 +1,9 @@ from collections.abc import Iterable, Mapping from api.models import Provider -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 AVAILABLE_COMPLIANCE_FRAMEWORKS = {} @@ -94,25 +96,22 @@ PROWLER_CHECKS = LazyChecksMapping() def get_compliance_frameworks(provider_type: Provider.ProviderChoices) -> list[str]: - """List compliance frameworks the API can load for `provider_type`. + """List compliance framework identifiers available for `provider_type`. - The list is sourced from `Compliance.get_bulk` so that the names - returned here are guaranteed to be loadable by the bulk loader. This - prevents downstream key mismatches (e.g. CSV report generation iterating - framework names and looking them up in the bulk dict). + Includes both per-provider frameworks and universal top-level frameworks + (e.g. ``dora``, ``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"). """ global AVAILABLE_COMPLIANCE_FRAMEWORKS if provider_type not in AVAILABLE_COMPLIANCE_FRAMEWORKS: AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] = list( - Compliance.get_bulk(provider_type).keys() + get_bulk_compliance_frameworks_universal(provider_type).keys() ) return AVAILABLE_COMPLIANCE_FRAMEWORKS[provider_type] @@ -139,18 +138,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]: @@ -209,8 +204,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: @@ -290,24 +285,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 = {check: None for check in 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, @@ -325,15 +336,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/health.py b/api/src/backend/api/health.py new file mode 100644 index 0000000000..8ca3936f94 --- /dev/null +++ b/api/src/backend/api/health.py @@ -0,0 +1,254 @@ +"""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 Neo4j are reachable and returns per-dependency detail when any +of them is unreachable. +""" + +from __future__ import annotations + +import logging +import threading +import time +from contextlib import suppress +from datetime import datetime, timezone +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. +# Neo4j inherits its driver-level ``connection_acquisition_timeout``. +VALKEY_PROBE_TIMEOUT_SECONDS = 2 + +# 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(timezone.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 _probe_neo4j() -> None: + # Lazy import: avoids pulling attack_paths into the boot import graph. + from api.attack_paths.database import get_driver + + get_driver().verify_connectivity() + + +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] + + postgres_result, postgres_ms = _measure("postgres", _probe_postgres) + valkey_result, valkey_ms = _measure("valkey", _probe_valkey) + neo4j_result, neo4j_ms = _measure("neo4j", _probe_neo4j) + + entries = [ + _build_check_entry("postgres", "datastore", postgres_result, postgres_ms), + _build_check_entry("valkey", "datastore", valkey_result, valkey_ms), + _build_check_entry("neo4j", "datastore", neo4j_result, neo4j_ms), + ] + overall = _aggregate_status(entries) + + payload = _base_payload(overall) + payload["checks"] = { + "postgres:responseTime": [entries[0]], + "valkey:responseTime": [entries[1]], + "neo4j: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 Neo4j 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/reconcile_orphan_tasks.py b/api/src/backend/api/management/commands/reconcile_orphan_tasks.py new file mode 100644 index 0000000000..8ba8f5b342 --- /dev/null +++ b/api/src/backend/api/management/commands/reconcile_orphan_tasks.py @@ -0,0 +1,59 @@ +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/migrations/0093_okta_provider.py b/api/src/backend/api/migrations/0093_okta_provider.py new file mode 100644 index 0000000000..d3e4a9e397 --- /dev/null +++ b/api/src/backend/api/migrations/0093_okta_provider.py @@ -0,0 +1,41 @@ +from django.db import migrations + +import api.db_utils + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0092_findings_arrays_gin_index_parent"), + ] + + operations = [ + migrations.AlterField( + model_name="provider", + name="provider", + field=api.db_utils.ProviderEnumField( + choices=[ + ("aws", "AWS"), + ("azure", "Azure"), + ("gcp", "GCP"), + ("kubernetes", "Kubernetes"), + ("m365", "M365"), + ("github", "GitHub"), + ("mongodbatlas", "MongoDB Atlas"), + ("iac", "IaC"), + ("oraclecloud", "Oracle Cloud Infrastructure"), + ("alibabacloud", "Alibaba Cloud"), + ("cloudflare", "Cloudflare"), + ("openstack", "OpenStack"), + ("image", "Image"), + ("googleworkspace", "Google Workspace"), + ("vercel", "Vercel"), + ("okta", "Okta"), + ], + default="aws", + ), + ), + migrations.RunSQL( + "ALTER TYPE provider ADD VALUE IF NOT EXISTS 'okta';", + reverse_sql=migrations.RunSQL.noop, + ), + ] 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..9d67404258 --- /dev/null +++ b/api/src/backend/api/migrations/0095_reconcile_orphan_tasks_periodic_task.py @@ -0,0 +1,49 @@ +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/models.py b/api/src/backend/api/models.py index 17d38e82f0..3d9a26698e 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -296,6 +296,7 @@ class Provider(RowLevelSecurityProtectedModel): IMAGE = "image", _("Image") GOOGLEWORKSPACE = "googleworkspace", _("Google Workspace") VERCEL = "vercel", _("Vercel") + OKTA = "okta", _("Okta") @staticmethod def validate_aws_uid(value): @@ -354,6 +355,26 @@ class Provider(RowLevelSecurityProtectedModel): pointer="/data/attributes/uid", ) + @staticmethod + def validate_okta_uid(value): + if not re.match( + r"^[a-z0-9][a-z0-9-]*\.(" + r"okta\.com|oktapreview\.com|okta-emea\.com|" + r"okta-gov\.com|okta\.mil|okta-miltest\.com|trex-govcloud\.com" + r")$", + value, + ): + raise ModelValidationError( + detail=( + "Okta provider ID must be a valid Okta-managed org domain " + "(e.g., acme.okta.com, also .oktapreview.com / .okta-emea.com " + "/ .okta-gov.com / .okta.mil / .okta-miltest.com / " + ".trex-govcloud.com), without scheme or path." + ), + code="okta-uid", + pointer="/data/attributes/uid", + ) + @staticmethod def validate_kubernetes_uid(value): if not re.match( @@ -480,6 +501,12 @@ class Provider(RowLevelSecurityProtectedModel): def clean(self): super().clean() + if self.provider == self.ProviderChoices.OKTA and self.uid: + # Mirror the SDK, which lowercases the org domain before connecting. + # Without this the API would reject Acme.okta.com even though the + # SDK would accept it, and stored uids could disagree with the + # authenticated org domain. + self.uid = self.uid.strip().lower() getattr(self, f"validate_{self.provider}_uid")(self.uid) def save(self, *args, **kwargs): diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index a42ed6dcf7..d2a30ca897 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.28.0 + version: 1.31.0 description: |- Prowler API specification. @@ -373,6 +373,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -389,6 +390,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -412,6 +414,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -430,6 +433,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -1453,6 +1457,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -1469,6 +1474,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -1491,6 +1497,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -1509,6 +1516,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -1997,6 +2005,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -2013,6 +2022,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -2035,6 +2045,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -2053,6 +2064,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -2584,6 +2596,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -2600,6 +2613,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -2622,6 +2636,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -2640,6 +2655,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -3134,6 +3150,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -3150,6 +3167,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -3173,6 +3191,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -3191,6 +3210,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -3740,6 +3760,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -3756,6 +3777,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -3779,6 +3801,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -3797,6 +3820,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -4254,6 +4278,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -4270,6 +4295,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -4293,6 +4319,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -4311,6 +4338,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -4766,6 +4794,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -4782,6 +4811,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -4805,6 +4835,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -4823,6 +4854,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -5266,6 +5298,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -5282,6 +5315,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -5305,6 +5339,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -5323,6 +5358,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -7156,6 +7192,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7172,6 +7209,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -7195,6 +7233,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7213,6 +7252,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -7335,6 +7375,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7351,6 +7392,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -7374,6 +7416,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7392,6 +7435,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -7503,6 +7547,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7519,6 +7564,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -7541,6 +7587,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7559,6 +7606,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -7702,6 +7750,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7718,6 +7767,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -7741,6 +7791,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7759,6 +7810,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -7915,6 +7967,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -7931,6 +7984,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -7954,6 +8008,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -7972,6 +8027,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -8122,6 +8178,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8138,6 +8195,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -8160,6 +8218,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -8178,6 +8237,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -8370,6 +8430,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8386,6 +8447,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -8409,6 +8471,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -8427,6 +8490,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -8548,6 +8612,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8564,6 +8629,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -8587,6 +8653,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -8605,6 +8672,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -8750,6 +8818,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -8766,6 +8835,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -8789,6 +8859,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -8807,6 +8878,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -9593,6 +9665,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -9609,6 +9682,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider__in] schema: @@ -9632,6 +9706,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -9650,6 +9725,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -9673,6 +9749,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -9689,6 +9766,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -9712,6 +9790,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -9730,6 +9809,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - name: filter[search] @@ -10400,6 +10480,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -10416,6 +10497,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -10439,6 +10521,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -10457,6 +10540,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -10951,6 +11035,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -10967,6 +11052,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -10990,6 +11076,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -11008,6 +11095,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -11315,6 +11403,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -11331,6 +11420,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -11354,6 +11444,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -11372,6 +11463,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -11685,6 +11777,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -11701,6 +11794,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -11724,6 +11818,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -11742,6 +11837,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -12580,6 +12676,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- * `aws` - AWS * `azure` - Azure @@ -12596,6 +12693,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta - in: query name: filter[provider_type__in] schema: @@ -12619,6 +12717,7 @@ paths: - openstack - oraclecloud - vercel + - okta description: |- Multiple values may be separated by commas. @@ -12637,6 +12736,7 @@ paths: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta explode: false style: form - in: query @@ -13037,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 @@ -20115,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: @@ -21127,6 +21295,7 @@ components: - image - googleworkspace - vercel + - okta type: string description: |- * `aws` - AWS @@ -21144,6 +21313,7 @@ components: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta x-spec-enum-id: 91f917e0c3ab97e8 uid: type: string @@ -21265,6 +21435,7 @@ components: - image - googleworkspace - vercel + - okta type: string x-spec-enum-id: 91f917e0c3ab97e8 description: |- @@ -21285,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 @@ -21337,6 +21509,7 @@ components: - image - googleworkspace - vercel + - okta type: string x-spec-enum-id: 91f917e0c3ab97e8 description: |- @@ -21357,6 +21530,7 @@ components: * `image` - Image * `googleworkspace` - Google Workspace * `vercel` - Vercel + * `okta` - Okta uid: type: string minLength: 3 @@ -22206,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: @@ -22631,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: @@ -23066,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/tests/test_apps.py b/api/src/backend/api/tests/test_apps.py index 5889b4e2cb..2f5b55a6e2 100644 --- a/api/src/backend/api/tests/test_apps.py +++ b/api/src/backend/api/tests/test_apps.py @@ -182,23 +182,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 +204,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_database.py b/api/src/backend/api/tests/test_attack_paths_database.py index 8828d23911..3a29a1007d 100644 --- a/api/src/backend/api/tests/test_attack_paths_database.py +++ b/api/src/backend/api/tests/test_attack_paths_database.py @@ -1,15 +1,16 @@ """ Tests for Neo4j database lazy initialization. -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. +The Neo4j driver is created on first use for every process type; app startup +never contacts Neo4j. These tests validate the database module behavior itself. """ import threading + from unittest.mock import MagicMock, patch import neo4j +import neo4j.exceptions import pytest import api.attack_paths.database as db_module @@ -59,6 +60,32 @@ class TestLazyInitialization: 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_leaves_driver_none_when_verify_fails( + self, mock_driver_factory, mock_settings + ): + """A failed verify_connectivity() must not publish or leak the driver.""" + mock_driver = MagicMock() + mock_driver.verify_connectivity.side_effect = ( + neo4j.exceptions.ServiceUnavailable("down") + ) + mock_driver_factory.return_value = mock_driver + mock_settings.DATABASES = { + "neo4j": { + "HOST": "localhost", + "PORT": 7687, + "USER": "neo4j", + "PASSWORD": "password", + } + } + + with pytest.raises(neo4j.exceptions.ServiceUnavailable): + db_module.init_driver() + + assert db_module._driver is None + mock_driver.close.assert_called_once() + @patch("api.attack_paths.database.settings") @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") def test_init_driver_returns_cached_driver_on_subsequent_calls( @@ -116,21 +143,23 @@ class TestConnectionAcquisitionTimeout: @pytest.fixture(autouse=True) def reset_module_state(self): original_driver = db_module._driver - original_timeout = db_module.CONN_ACQUISITION_TIMEOUT + original_acq_timeout = db_module.CONN_ACQUISITION_TIMEOUT + original_conn_timeout = db_module.CONNECTION_TIMEOUT db_module._driver = None yield db_module._driver = original_driver - db_module.CONN_ACQUISITION_TIMEOUT = original_timeout + db_module.CONN_ACQUISITION_TIMEOUT = original_acq_timeout + db_module.CONNECTION_TIMEOUT = original_conn_timeout @patch("api.attack_paths.database.settings") @patch("api.attack_paths.database.neo4j.GraphDatabase.driver") def test_driver_receives_configured_timeout( self, mock_driver_factory, mock_settings ): - """init_driver() should pass CONN_ACQUISITION_TIMEOUT to the neo4j driver.""" + """init_driver() should pass the configured timeouts to the neo4j driver.""" mock_driver_factory.return_value = MagicMock() mock_settings.DATABASES = { "neo4j": { @@ -141,11 +170,13 @@ class TestConnectionAcquisitionTimeout: } } db_module.CONN_ACQUISITION_TIMEOUT = 42 + db_module.CONNECTION_TIMEOUT = 7 db_module.init_driver() _, kwargs = mock_driver_factory.call_args assert kwargs["connection_acquisition_timeout"] == 42 + assert kwargs["connection_timeout"] == 7 class TestAtexitRegistration: diff --git a/api/src/backend/api/tests/test_compliance.py b/api/src/backend/api/tests/test_compliance.py index ce30a3cc52..508e5abaca 100644 --- a/api/src/backend/api/tests/test_compliance.py +++ b/api/src/backend/api/tests/test_compliance.py @@ -12,7 +12,9 @@ from api.compliance import ( load_prowler_checks, ) from api.models import Provider -from prowler.lib.check.compliance_models import Compliance +from prowler.lib.check.compliance_models import ( + get_bulk_compliance_frameworks_universal, +) class TestCompliance: @@ -28,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") @@ -51,9 +53,9 @@ class TestCompliance: prowler_compliance = { "aws": { "compliance1": MagicMock( - Requirements=[ + requirements=[ MagicMock( - Checks=["check1", "check2"], + checks={"aws": ["check1", "check2"]}, ), ], ), @@ -167,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) @@ -271,24 +276,28 @@ def reset_compliance_cache(): class TestGetComplianceFrameworks: def test_returns_keys_from_compliance_get_bulk(self, reset_compliance_cache): - with patch("api.compliance.Compliance") as mock_compliance: - mock_compliance.get_bulk.return_value = { + 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_compliance.get_bulk.assert_called_once_with(Provider.ProviderChoices.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.Compliance") as mock_compliance: - mock_compliance.get_bulk.return_value = {"cis_1.4_aws": MagicMock()} + 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_compliance.get_bulk.call_count == 1 + assert mock_get_bulk.call_count == 1 @pytest.mark.parametrize( "provider_type", @@ -296,17 +305,19 @@ class TestGetComplianceFrameworks: ) def test_listing_is_subset_of_bulk(self, reset_compliance_cache, provider_type): """Regression for CLOUD-API-40S: every name returned by - ``get_compliance_frameworks`` must be loadable via ``Compliance.get_bulk``. + ``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(Compliance.get_bulk(provider_type).keys()) + 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 Compliance.get_bulk: {sorted(missing)}" + f"loadable by get_bulk_compliance_frameworks_universal: " + f"{sorted(missing)}" ) 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_health.py b/api/src/backend/api/tests/test_health.py new file mode 100644 index 0000000000..76b72c0dc7 --- /dev/null +++ b/api/src/backend/api/tests/test_health.py @@ -0,0 +1,445 @@ +"""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 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 + +from api import health + + +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_neo4j") 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_neo4j", 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_neo4j"), + ): + 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", + "neo4j: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 + + 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_neo4j"), + ): + 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"]["neo4j: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_neo4j"), + ): + 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_neo4j_is_down(self, api_client): + with ( + patch("api.health._probe_postgres"), + patch("api.health._probe_valkey"), + patch( + "api.health._probe_neo4j", + 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" + neo_entry = body["checks"]["neo4j:responseTime"][0] + assert neo_entry["status"] == "fail" + assert "output" not in neo_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_neo4j", 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", + "neo4j: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_neo4j"), + ): + 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_neo4j"), + ): + 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_neo4j") 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_neo4j"), + ): + 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_neo4j"), + ): + 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_neo4j"), + 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_neo4j_probe_calls_verify_connectivity(self): + with patch("api.attack_paths.database.get_driver") as mock_get_driver: + mock_get_driver.return_value.verify_connectivity.return_value = None + assert health._probe_neo4j() is None + mock_get_driver.return_value.verify_connectivity.assert_called_once_with() + + def test_neo4j_probe_propagates_driver_errors(self): + with patch("api.attack_paths.database.get_driver") as mock_get_driver: + mock_get_driver.return_value.verify_connectivity.side_effect = RuntimeError( + "unreachable" + ) + with pytest.raises(RuntimeError, match="unreachable"): + health._probe_neo4j() + + +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_utils.py b/api/src/backend/api/tests/test_utils.py index 86da5567da..3a7f030a9c 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -31,6 +31,7 @@ from prowler.providers.image.image_provider import ImageProvider from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider from prowler.providers.m365.m365_provider import M365Provider from prowler.providers.mongodbatlas.mongodbatlas_provider import MongodbatlasProvider +from prowler.providers.okta.okta_provider import OktaProvider from prowler.providers.openstack.openstack_provider import OpenstackProvider from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider from prowler.providers.vercel.vercel_provider import VercelProvider @@ -130,6 +131,7 @@ class TestReturnProwlerProvider: (Provider.ProviderChoices.OPENSTACK.value, OpenstackProvider), (Provider.ProviderChoices.IMAGE.value, ImageProvider), (Provider.ProviderChoices.VERCEL.value, VercelProvider), + (Provider.ProviderChoices.OKTA.value, OktaProvider), ], ) def test_return_prowler_provider(self, provider_type, expected_provider): @@ -238,6 +240,31 @@ class TestProwlerProviderConnectionTest: raise_on_exception=False, ) + @patch("api.utils.return_prowler_provider") + def test_prowler_provider_connection_test_okta_provider( + self, mock_return_prowler_provider + ): + """Test connection test for Okta provider passes org domain and provider_id.""" + provider = MagicMock() + provider.uid = "acme.okta.com" + provider.provider = Provider.ProviderChoices.OKTA.value + provider.secret.secret = { + "okta_client_id": "0oa123456789abcdef", + "okta_private_key": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + "okta_scopes": ["okta.policies.read"], + } + mock_return_prowler_provider.return_value = MagicMock() + + prowler_provider_connection_test(provider) + mock_return_prowler_provider.return_value.test_connection.assert_called_once_with( + okta_client_id="0oa123456789abcdef", + okta_private_key="-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + okta_scopes=["okta.policies.read"], + okta_org_domain="acme.okta.com", + provider_id="acme.okta.com", + raise_on_exception=False, + ) + @patch("api.utils.return_prowler_provider") def test_prowler_provider_connection_test_image_provider_no_creds( self, mock_return_prowler_provider @@ -308,6 +335,10 @@ class TestGetProwlerProviderKwargs: Provider.ProviderChoices.VERCEL.value, {"team_id": "provider_uid"}, ), + ( + Provider.ProviderChoices.OKTA.value, + {"okta_org_domain": "provider_uid"}, + ), ], ) def test_get_prowler_provider_kwargs(self, provider_type, expected_extra_kwargs): 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 97796c97ca..d213e8c855 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -24,9 +24,11 @@ from conftest import ( 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 rest_framework import status @@ -64,6 +66,7 @@ from api.models import ( ProviderSecret, Resource, ResourceFindingMapping, + ResourceTag, Role, RoleProviderGroupRelationship, SAMLConfiguration, @@ -1625,6 +1628,21 @@ class TestProviderViewSet: "uid": "C12", "alias": "Google Workspace Minimum Length", }, + { + "provider": "okta", + "uid": "acme.okta.com", + "alias": "Okta Org", + }, + { + "provider": "okta", + "uid": "agency.okta-gov.com", + "alias": "Okta Gov Org", + }, + { + "provider": "okta", + "uid": "agency.okta.mil", + "alias": "Okta Mil Org", + }, ] ), ) @@ -2143,6 +2161,24 @@ class TestProviderViewSet: "googleworkspace-uid", "uid", ), + ( + { + "provider": "okta", + "uid": "https://acme.okta.com", + "alias": "test", + }, + "okta-uid", + "uid", + ), + ( + { + "provider": "okta", + "uid": "acme.example.com", + "alias": "test", + }, + "okta-uid", + "uid", + ), ] ), ) @@ -2163,6 +2199,25 @@ class TestProviderViewSet: == f"/data/attributes/{error_pointer}" ) + @pytest.mark.parametrize( + "input_uid,stored_uid", + [ + ("Acme.okta.com", "acme.okta.com"), + (" ACME.OKTA.COM ", "acme.okta.com"), + ("Agency.Okta-Gov.com", "agency.okta-gov.com"), + ], + ) + def test_providers_create_okta_uid_normalized( + self, authenticated_client, input_uid, stored_uid + ): + response = authenticated_client.post( + reverse("provider-list"), + data={"provider": "okta", "uid": input_uid, "alias": "Okta"}, + format="json", + ) + assert response.status_code == status.HTTP_201_CREATED + assert Provider.objects.get().uid == stored_uid + def test_providers_partial_update(self, authenticated_client, providers_fixture): provider1, *_ = providers_fixture new_alias = "This is the new name" @@ -2320,17 +2375,17 @@ class TestProviderViewSet: ), ("alias", "aws_testing_1", 1), ("alias.icontains", "aws", 2), - ("inserted_at", TODAY, 13), + ("inserted_at", TODAY, 14), ( "inserted_at.gte", "2024-01-01", - 13, + 14, ), ("inserted_at.lte", "2024-01-01", 0), ( "updated_at.gte", "2024-01-01", - 13, + 14, ), ("updated_at.lte", "2024-01-01", 0), ] @@ -2963,6 +3018,19 @@ class TestProviderSecretViewSet: "api_token": "fake-vercel-api-token-for-testing", }, ), + # Okta with inline private key credentials + ( + Provider.ProviderChoices.OKTA.value, + ProviderSecret.TypeChoices.STATIC, + { + "okta_client_id": "0oa123456789abcdef", + "okta_private_key": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + "okta_scopes": [ + "okta.policies.read", + "okta.groups.read", + ], + }, + ), ], ) def test_provider_secrets_create_valid( @@ -3075,6 +3143,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 ): @@ -3751,16 +3859,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) @@ -4081,6 +4193,88 @@ class TestScanViewSet: 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=timezone.utc), + }, + { + "Key": latest_key, + "LastModified": datetime(2024, 2, 2, tzinfo=timezone.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 ): @@ -4189,18 +4383,24 @@ class TestScanViewSet: assert cd.startswith('attachment; filename="') assert cd.endswith(f'filename="{fname.name}"') - @patch("api.v1.views.Task.objects.get") @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, @@ -4221,6 +4421,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)}, ) @@ -4241,6 +4442,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=timezone.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( @@ -6811,6 +7057,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", [ @@ -7053,6 +7373,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"}), @@ -9214,6 +9560,16 @@ 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_missing_compliance_id( self, authenticated_client ): @@ -15790,6 +16146,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 @@ -17031,6 +17393,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 @@ -17288,18 +17656,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"] diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index b80d54b08a..8da8d91226 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -37,6 +37,7 @@ if TYPE_CHECKING: from prowler.providers.mongodbatlas.mongodbatlas_provider import ( MongodbatlasProvider, ) + from prowler.providers.okta.okta_provider import OktaProvider from prowler.providers.openstack.openstack_provider import OpenstackProvider from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider from prowler.providers.vercel.vercel_provider import VercelProvider @@ -93,6 +94,7 @@ def return_prowler_provider( | KubernetesProvider | M365Provider | MongodbatlasProvider + | OktaProvider | OpenstackProvider | OraclecloudProvider | VercelProvider @@ -181,6 +183,10 @@ def return_prowler_provider( from prowler.providers.vercel.vercel_provider import VercelProvider prowler_provider = VercelProvider + case Provider.ProviderChoices.OKTA.value: + from prowler.providers.okta.okta_provider import OktaProvider + + prowler_provider = OktaProvider case _: raise ValueError(f"Provider type {provider.provider} not supported") return prowler_provider @@ -246,6 +252,11 @@ def get_prowler_provider_kwargs( **prowler_provider_kwargs, "team_id": provider.uid, } + elif provider.provider == Provider.ProviderChoices.OKTA.value: + prowler_provider_kwargs = { + **prowler_provider_kwargs, + "okta_org_domain": provider.uid, + } elif provider.provider == Provider.ProviderChoices.IMAGE.value: # Detect whether uid is a registry URL (e.g. "docker.io/andoniaf") or # a concrete image reference (e.g. "docker.io/andoniaf/myimage:latest"). @@ -290,6 +301,7 @@ def initialize_prowler_provider( | KubernetesProvider | M365Provider | MongodbatlasProvider + | OktaProvider | OpenstackProvider | OraclecloudProvider | VercelProvider @@ -351,6 +363,14 @@ def prowler_provider_connection_test(provider: Provider) -> Connection: "raise_on_exception": False, } return prowler_provider.test_connection(**vercel_kwargs) + elif provider.provider == Provider.ProviderChoices.OKTA.value: + okta_kwargs = { + **prowler_provider_kwargs, + "okta_org_domain": provider.uid, + "provider_id": provider.uid, + "raise_on_exception": False, + } + return prowler_provider.test_connection(**okta_kwargs) elif provider.provider == Provider.ProviderChoices.IMAGE.value: image_kwargs = { "image": provider.uid, 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 51cb95fbff..03d2fecad2 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -1397,6 +1397,7 @@ class ResourceIncludeSerializer(RLSSerializer): "service", "type_", "tags", + "metadata", "details", "partition", ] @@ -1404,6 +1405,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 +1545,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 +1692,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() diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index b13e909e5e..9c91c3201f 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -21,6 +21,7 @@ from celery import chain, states from celery.result import AsyncResult from config.custom_logging import BackendLogger from config.env import env +from config.version import RELEASE_ID from config.settings.social_login import ( GITHUB_OAUTH_CALLBACK_URL, GOOGLE_OAUTH_CALLBACK_URL, @@ -115,6 +116,7 @@ from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset from api.compliance import ( PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, get_compliance_frameworks, + get_prowler_provider_compliance, ) from api.constants import SEVERITY_ORDER from api.db_router import MainRouter @@ -424,7 +426,7 @@ class SchemaView(SpectacularAPIView): def get(self, request, *args, **kwargs): spectacular_settings.TITLE = "Prowler API" - spectacular_settings.VERSION = "1.28.0" + spectacular_settings.VERSION = RELEASE_ID spectacular_settings.DESCRIPTION = ( "Prowler API specification.\n\nThis file is auto-generated." ) @@ -1848,7 +1850,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' and 'csa_ccm_4.0'); any " + "other framework returns 404." + ), + parameters=[ + OpenApiParameter( + name="name", + type=str, + location=OpenApiParameter.PATH, + required=True, + description="The compliance report name, like 'dora'", + ), + ], + 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, ), @@ -1991,35 +2028,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": - if hasattr(self, "response_serializer_class"): - return self.response_serializer_class - elif self.action == "cis": + + 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): @@ -2058,12 +2083,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 @@ -2138,27 +2168,32 @@ 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; HEAD before presigning to preserve the 404 contract. key = path_pattern @@ -2208,7 +2243,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) @@ -2256,20 +2293,16 @@ class ScanViewSet(BaseRLSViewSet): 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 @@ -2286,25 +2319,66 @@ 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="text/csv", + content_type=content_type, ) 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, 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, @@ -3748,6 +3822,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( @@ -7368,6 +7452,15 @@ class FindingGroupViewSet(BaseRLSViewSet): output_field=IntegerField(), ) + # `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. @@ -7438,15 +7531,6 @@ class FindingGroupViewSet(BaseRLSViewSet): 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")), - ), ) .annotate( # Group is muted only if it has zero non-muted findings. @@ -7483,14 +7567,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) @@ -7502,9 +7589,38 @@ class FindingGroupViewSet(BaseRLSViewSet): - 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( @@ -7550,7 +7666,6 @@ class FindingGroupViewSet(BaseRLSViewSet): _FINDING_GROUP_SORT_MAP = { "check_id": "check_id", - "check_title": "check_title", "severity": "severity_order", "status": "status_order", "muted": "muted", diff --git a/api/src/backend/config/celery.py b/api/src/backend/config/celery.py index c46c8a426c..5d246395a5 100644 --- a/api/src/backend/config/celery.py +++ b/api/src/backend/config/celery.py @@ -26,6 +26,61 @@ celery_app.conf.result_backend_transport_options = { } 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"]) diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 238825591f..38cf047ac2 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -118,6 +118,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"), }, } @@ -304,3 +306,32 @@ SESSION_COOKIE_SECURE = True ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int( "ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880 ) # 48h + +# 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..6921790ca3 100644 --- a/api/src/backend/config/django/devel.py +++ b/api/src/backend/config/django/devel.py @@ -54,6 +54,8 @@ DATABASES = { 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..cb651f6e76 100644 --- a/api/src/backend/config/django/production.py +++ b/api/src/backend/config/django/production.py @@ -58,3 +58,5 @@ DATABASES = { } 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..a1e5c29fb3 100644 --- a/api/src/backend/config/django/testing.py +++ b/api/src/backend/config/django/testing.py @@ -34,3 +34,8 @@ 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" +) diff --git a/api/src/backend/config/urls.py b/api/src/backend/config/urls.py index c307bcc310..113f8bf5fc 100644 --- a/api/src/backend/config/urls.py +++ b/api/src/backend/config/urls.py @@ -1,5 +1,9 @@ from django.urls import include, path +from api.health import LivenessView, ReadinessView + 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 c5228c77be..eb73b0c51f 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -571,6 +571,12 @@ def providers_fixture(tenants_fixture): alias="vercel_testing", tenant_id=tenant.id, ) + provider14 = Provider.objects.create( + provider="okta", + uid="acme.okta.com", + alias="okta_testing", + tenant_id=tenant.id, + ) return ( provider1, @@ -586,6 +592,7 @@ def providers_fixture(tenants_fixture): provider11, provider12, provider13, + provider14, ) diff --git a/api/src/backend/tasks/jobs/attack_paths/cleanup.py b/api/src/backend/tasks/jobs/attack_paths/cleanup.py index 65ba583a3e..fa7670afaa 100644 --- a/api/src/backend/tasks/jobs/attack_paths/cleanup.py +++ b/api/src/backend/tasks/jobs/attack_paths/cleanup.py @@ -1,12 +1,14 @@ from datetime import datetime, timedelta, timezone -from celery import current_app, states +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 from api.attack_paths import database as graph_database from api.db_router import MainRouter @@ -150,32 +152,6 @@ def _cleanup_stale_scheduled_scans(cutoff: datetime) -> list[str]: 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 _revoke_task(task_result, terminate: bool = True) -> None: - """Revoke a Celery task. Non-fatal on failure. - - `terminate=True` SIGTERMs the worker if the task is mid-execution; use - for EXECUTING cleanup. `terminate=False` only marks the task id revoked - across workers, so any worker pulling the queued message discards it; - use for SCHEDULED cleanup where the task hasn't run yet. - """ - 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 _cleanup_scan(scan, task_result, reason: str) -> bool: """ Clean up a single stale `AttackPathsScan`: diff --git a/api/src/backend/tasks/jobs/export.py b/api/src/backend/tasks/jobs/export.py index 1b9295cc67..185acb7f99 100644 --- a/api/src/backend/tasks/jobs/export.py +++ b/api/src/backend/tasks/jobs/export.py @@ -39,11 +39,6 @@ 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.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.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 @@ -102,7 +97,6 @@ COMPLIANCE_CLASS_MAP = { (lambda name: name == "prowler_threatscore_aws", ProwlerThreatScoreAWS), (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": [ @@ -113,7 +107,6 @@ COMPLIANCE_CLASS_MAP = { (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), @@ -123,7 +116,6 @@ COMPLIANCE_CLASS_MAP = { (lambda name: name == "prowler_threatscore_gcp", ProwlerThreatScoreGCP), (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), @@ -152,11 +144,9 @@ 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, 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..1bf5c95df2 --- /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 datetime, timedelta, timezone +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=timezone.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=timezone.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 12f48b7040..36e47829c5 100644 --- a/api/src/backend/tasks/jobs/report.py +++ b/api/src/backend/tasks/jobs/report.py @@ -20,12 +20,19 @@ from tasks.jobs.reports import ( ThreatScoreReportGenerator, ) from tasks.jobs.threatscore import compute_threatscore_metrics -from tasks.jobs.threatscore_utils import _aggregate_requirement_statistics_from_database +from tasks.jobs.threatscore_utils import ( + _aggregate_requirement_statistics_from_database, + _get_compliance_check_ids, +) 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 prowler.lib.check.compliance_models import Compliance +from api.utils import initialize_prowler_provider +from prowler.lib.check.compliance_models import ( + Compliance, + get_bulk_compliance_frameworks_universal, +) from prowler.lib.outputs.finding import Finding as FindingOutput logger = get_task_logger(__name__) @@ -427,6 +434,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. @@ -455,6 +463,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, ) @@ -469,6 +478,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. @@ -495,6 +505,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, ) @@ -510,6 +521,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. @@ -537,6 +549,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, ) @@ -553,6 +566,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. @@ -560,7 +574,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. @@ -580,6 +594,7 @@ 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, ) @@ -596,6 +611,7 @@ def generate_cis_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 a specific CIS Benchmark variant. @@ -627,6 +643,7 @@ def generate_cis_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, ) @@ -771,6 +788,17 @@ 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 @@ -778,7 +806,6 @@ def generate_compliance_reports( latest_cis: str | None = None if generate_cis: try: - frameworks_bulk = Compliance.get_bulk(provider_type) latest_cis = _pick_latest_cis_variant( name for name in frameworks_bulk.keys() if name.startswith("cis_") ) @@ -815,10 +842,86 @@ 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") + # 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 @@ -907,6 +1010,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 @@ -984,9 +1088,15 @@ 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") @@ -1006,6 +1116,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( @@ -1020,9 +1131,15 @@ 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") @@ -1043,6 +1160,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( @@ -1057,14 +1175,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: generated_report_keys.append("csa") csa_path = output_paths["csa"] - compliance_id_csa = f"csa_ccm_4.0_{provider_type}" + 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) @@ -1080,6 +1204,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( @@ -1094,9 +1219,15 @@ 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)} + _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 @@ -1119,6 +1250,7 @@ def generate_compliance_reports( provider_obj=provider_obj, requirement_statistics=requirement_statistics, findings_cache=findings_cache, + prowler_provider=prowler_provider, ) upload_uri_cis = _upload_to_s3( @@ -1147,14 +1279,22 @@ def generate_compliance_reports( ) except Exception as e: - logger.error("Error generating CIS report %s: %s", latest_cis, 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. + # 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 diff --git a/api/src/backend/tasks/jobs/reports/base.py b/api/src/backend/tasks/jobs/reports/base.py index f348c6d0d2..27d1defff4 100644 --- a/api/src/backend/tasks/jobs/reports/base.py +++ b/api/src/backend/tasks/jobs/reports/base.py @@ -1,7 +1,11 @@ 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 celery.utils.log import get_task_logger @@ -23,7 +27,10 @@ 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.check.compliance_models import ( + Compliance, + get_bulk_compliance_frameworks_universal, +) from prowler.lib.outputs.finding import Finding as FindingOutput from .components import ( @@ -41,6 +48,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 +56,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 +226,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 +423,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 +440,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 +478,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 +756,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 +806,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 +885,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 +897,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 +909,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 +987,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 +1059,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 +1070,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..da3f5aae10 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 +import time from typing 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/components.py b/api/src/backend/tasks/jobs/reports/components.py index 049cc043d3..31b808ec5c 100644 --- a/api/src/backend/tasks/jobs/reports/components.py +++ b/api/src/backend/tasks/jobs/reports/components.py @@ -475,8 +475,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) @@ -508,17 +515,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 669f31c287..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 diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index d4bc2afba3..db5018db90 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -42,7 +42,6 @@ from api.db_utils import ( SET_CONFIG_QUERY, psycopg_connection, rls_transaction, - update_objects_in_batches, ) from api.exceptions import ProviderConnectionError from api.models import ( @@ -59,6 +58,7 @@ from api.models import ( ResourceFindingMapping, ResourceScanSummary, ResourceTag, + ResourceTagMapping, Scan, ScanCategorySummary, ScanGroupSummary, @@ -97,8 +97,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 @@ -468,9 +476,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 ) @@ -528,16 +539,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"]) @@ -548,281 +569,411 @@ 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 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=timezone.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=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, + # 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: - 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." + if all_resource_uids_to_touch: + now_utc = datetime.now(tz=timezone.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, + [ + "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})..." ) - - # 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, - ) + 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: @@ -873,7 +1024,7 @@ def perform_prowler_scan( 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.save(update_fields=["state", "started_at", "updated_at"]) # Find the mutelist processor if it exists with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS): @@ -918,7 +1069,13 @@ def perform_prowler_scan( provider_instance.connection_last_checked_at = datetime.now( tz=timezone.utc ) - provider_instance.save() + provider_instance.save( + update_fields=[ + "connected", + "connection_last_checked_at", + "updated_at", + ] + ) # 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 @@ -933,6 +1090,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 (1.0) + # 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) @@ -959,10 +1123,20 @@ 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 + scan_instance.save(update_fields=["progress", "updated_at"]) + last_persisted_progress = progress + last_persisted_progress_at = now scan_instance.state = StateChoices.COMPLETED @@ -976,13 +1150,16 @@ 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 Exception as e: logger.error(f"Error performing scan {scan_id}: {e}") @@ -994,7 +1171,16 @@ def perform_prowler_scan( 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() + scan_instance.save( + update_fields=[ + "state", + "duration", + "completed_at", + "unique_resource_count", + "progress", + "updated_at", + ] + ) if exception is not None: raise exception @@ -1468,6 +1654,10 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): elif requirement_status == "PASS": requirement_statuses[key]["pass_count"] += 1 + # Idempotent re-run: COPY can't ON CONFLICT, so clear this scan's rows first. + 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) diff --git a/api/src/backend/tasks/jobs/threatscore_utils.py b/api/src/backend/tasks/jobs/threatscore_utils.py index 2ef29484ee..35fb0faeb3 100644 --- a/api/src/backend/tasks/jobs/threatscore_utils.py +++ b/api/src/backend/tasks/jobs/threatscore_utils.py @@ -1,6 +1,8 @@ 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 django.db.models import Count, F, Q, Window +from django.db.models.functions import RowNumber +from tasks.jobs.reports.config import MAX_FINDINGS_PER_CHECK from api.db_router import READ_REPLICA_ALIAS from api.db_utils import rls_transaction @@ -154,6 +156,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 +182,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 +243,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 +340,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 +354,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 3d36d711d5..e617339973 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -46,6 +46,7 @@ from tasks.jobs.lighthouse_providers import ( refresh_lighthouse_provider_models, ) from tasks.jobs.muting import mute_historical_findings +from tasks.jobs.orphan_recovery import reconcile_orphans from tasks.jobs.report import ( STALE_TMP_OUTPUT_MAX_AGE_HOURS, _cleanup_stale_tmp_output_directories, @@ -67,14 +68,20 @@ from tasks.utils import ( get_next_execution_datetime, ) -from api.compliance import get_compliance_frameworks +from api.compliance import ( + get_compliance_frameworks, + get_prowler_provider_compliance, +) from api.db_router import READ_REPLICA_ALIAS -from api.db_utils import rls_transaction +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 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 @@ -253,7 +260,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 @@ -274,6 +283,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, @@ -286,7 +306,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): """ @@ -310,6 +337,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}" ) @@ -441,13 +478,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", ) @@ -492,11 +558,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.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): """ @@ -514,6 +592,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) @@ -568,8 +650,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.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 @@ -645,7 +749,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 @@ -1056,10 +1160,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, 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..abfa920b8e --- /dev/null +++ b/api/src/backend/tasks/tests/test_orphan_recovery.py @@ -0,0 +1,408 @@ +from datetime import datetime, timedelta, timezone +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=timezone.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 b5cb0cc38e..ad8a2ff29e 100644 --- a/api/src/backend/tasks/tests/test_reports.py +++ b/api/src/backend/tasks/tests/test_reports.py @@ -44,6 +44,8 @@ from api.models import ( Finding, Resource, ResourceFindingMapping, + ResourceTag, + ResourceTagMapping, StateChoices, StatusChoices, ) @@ -367,6 +369,317 @@ 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.test.utils import CaptureQueriesContext + from django.db import connections + + 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.""" @@ -855,6 +1168,181 @@ class TestGenerateComplianceReportsOptimized: 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") 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_csa.py b/api/src/backend/tasks/tests/test_reports_csa.py index 602b9bb28e..2e61e9ef84 100644 --- a/api/src/backend/tasks/tests/test_reports_csa.py +++ b/api/src/backend/tasks/tests/test_reports_csa.py @@ -80,7 +80,7 @@ def basic_csa_compliance_data(): tenant_id="tenant-123", scan_id="scan-456", provider_id="provider-789", - compliance_id="csa_ccm_4.0_aws", + compliance_id="csa_ccm_4.0", framework="CSA-CCM", name="CSA Cloud Controls Matrix v4.0", version="4.0", diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 0a7193cd4d..8d3d0be93a 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -1880,6 +1880,62 @@ class TestCreateComplianceRequirements: assert "requirements_created" in result + @pytest.mark.django_db(transaction=True) + def test_create_compliance_requirements_idempotent_on_rerun( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + findings_fixture, + ): + """Re-running compliance materialization must not raise nor duplicate rows. + + Uses transaction=True because the COPY path commits on its own connection, + so the test must use real commits (mirroring production) rather than the + default rollback wrapper. + """ + from api.models import ComplianceRequirementOverview + + with patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template: + tenant_id = str(tenants_fixture[0].id) + scan_id = str(scans_fixture[0].id) + + mock_compliance_template.__getitem__.return_value = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": {"test_check_id": None}, + "checks_status": { + "pass": 2, + "fail": 1, + "manual": 0, + "total": 3, + }, + "status": "FAIL", + }, + }, + } + } + + create_compliance_requirements(tenant_id, scan_id) + count_after_first = ComplianceRequirementOverview.objects.filter( + scan_id=scan_id + ).count() + + # Second run must not raise and must not duplicate rows. + create_compliance_requirements(tenant_id, scan_id) + count_after_second = ComplianceRequirementOverview.objects.filter( + scan_id=scan_id + ).count() + + assert count_after_first > 0 + assert count_after_second == count_after_first + def test_create_compliance_requirements_kubernetes_provider( self, tenants_fixture, diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index bcebc2c9c9..67d2c64555 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -15,12 +15,15 @@ from tasks.jobs.lighthouse_providers import ( from tasks.tasks import ( DJANGO_TMP_OUTPUT_DIRECTORY, STALE_TMP_OUTPUT_MAX_AGE_HOURS, + ScanReportRLSTask, _cleanup_orphan_scheduled_scans, _perform_scan_complete_tasks, + _scan_tmp_output_directory, check_integrations_task, check_lighthouse_provider_connection_task, generate_outputs_task, perform_attack_paths_scan_task, + perform_scan_task, perform_scheduled_scan_task, reaggregate_all_finding_group_summaries_task, refresh_lighthouse_provider_models_task, @@ -320,6 +323,7 @@ class TestGenerateOutputs: mock_transformed_stats = {"some": "stats"} with ( + patch("tasks.tasks.get_prowler_provider_compliance", return_value={}), patch( "tasks.tasks.FindingOutput._transform_findings_stats", return_value=mock_transformed_stats, @@ -438,6 +442,7 @@ class TestGenerateOutputs: mock_provider.uid = "test-provider-uid" with ( + patch("tasks.tasks.get_prowler_provider_compliance", return_value={}), patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter, patch("tasks.tasks.Provider.objects.get", return_value=mock_provider), patch("tasks.tasks.initialize_prowler_provider"), @@ -593,6 +598,7 @@ class TestGenerateOutputs: ] with ( + patch("tasks.tasks.get_prowler_provider_compliance", return_value={}), patch("tasks.tasks.ScanSummary.objects.filter") as mock_summary, patch( "tasks.tasks.Provider.objects.get", @@ -667,6 +673,7 @@ class TestGenerateOutputs: mock_provider.uid = "test-provider-uid" with ( + patch("tasks.tasks.get_prowler_provider_compliance", return_value={}), patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter, patch("tasks.tasks.Provider.objects.get", return_value=mock_provider), patch("tasks.tasks.initialize_prowler_provider"), @@ -770,6 +777,38 @@ class TestGenerateOutputs: mock_s3_task.assert_called_once() +class TestScanReportRLSTaskOnFailure: + def test_on_failure_removes_scan_tmp_directory(self): + task = ScanReportRLSTask() + + with patch("tasks.tasks.rmtree") as mock_rmtree: + task.on_failure( + exc=OSError("No space left on device"), + task_id="task-abc", + args=(), + kwargs={"tenant_id": "t-1", "scan_id": "s-1"}, + _einfo=None, + ) + + mock_rmtree.assert_called_once_with( + _scan_tmp_output_directory("t-1", "s-1"), ignore_errors=True + ) + + def test_on_failure_skips_when_missing_kwargs(self): + task = ScanReportRLSTask() + + with patch("tasks.tasks.rmtree") as mock_rmtree: + task.on_failure( + exc=OSError("No space left on device"), + task_id="task-abc", + args=(), + kwargs={}, + _einfo=None, + ) + + mock_rmtree.assert_not_called() + + class TestScanCompleteTasks: @patch("tasks.tasks.aggregate_attack_surface_task.apply_async") @patch("tasks.tasks.chain") @@ -1078,6 +1117,7 @@ class TestCheckIntegrationsTask: enabled=True, ) + @patch("tasks.tasks.get_prowler_provider_compliance", return_value={}) @patch("tasks.tasks.s3_integration_task") @patch("tasks.tasks.Integration.objects.filter") @patch("tasks.tasks.ScanSummary.objects.filter") @@ -1110,6 +1150,7 @@ class TestCheckIntegrationsTask: mock_scan_summary, mock_integration_filter, mock_s3_task, + mock_get_prowler_compliance, ): """Test that ASFF output is generated for AWS providers with SecurityHub integration.""" # Setup @@ -1206,6 +1247,7 @@ class TestCheckIntegrationsTask: assert result == {"upload": True} + @patch("tasks.tasks.get_prowler_provider_compliance", return_value={}) @patch("tasks.tasks.s3_integration_task") @patch("tasks.tasks.Integration.objects.filter") @patch("tasks.tasks.ScanSummary.objects.filter") @@ -1238,6 +1280,7 @@ class TestCheckIntegrationsTask: mock_scan_summary, mock_integration_filter, mock_s3_task, + mock_get_prowler_compliance, ): """Test that ASFF output is NOT generated for AWS providers without SecurityHub integration.""" # Setup @@ -1331,6 +1374,7 @@ class TestCheckIntegrationsTask: assert result == {"upload": True} + @patch("tasks.tasks.get_prowler_provider_compliance", return_value={}) @patch("tasks.tasks.ScanSummary.objects.filter") @patch("tasks.tasks.Provider.objects.get") @patch("tasks.tasks.initialize_prowler_provider") @@ -1359,6 +1403,7 @@ class TestCheckIntegrationsTask: mock_initialize_provider, mock_provider_get, mock_scan_summary, + mock_get_prowler_compliance, ): """Test that ASFF output is NOT generated for non-AWS providers (e.g., Azure, GCP).""" # Setup @@ -2454,6 +2499,57 @@ class TestPerformScheduledScanTask: == 1 ) + def test_no_op_when_provider_does_not_exist(self, tenants_fixture): + """Return None without raising when the provider was already deleted.""" + tenant = tenants_fixture[0] + missing_provider_id = str(uuid.uuid4()) + task_id = str(uuid.uuid4()) + self._create_task_result(tenant.id, task_id) + # Orphan PeriodicTask left behind from a previous lifecycle. + self._create_periodic_task(missing_provider_id, tenant.id) + orphan_name = f"scan-perform-scheduled-{missing_provider_id}" + assert PeriodicTask.objects.filter(name=orphan_name).exists() + + with ( + patch("tasks.tasks.perform_prowler_scan") as mock_scan, + patch("tasks.tasks._perform_scan_complete_tasks") as mock_complete_tasks, + self._override_task_request(perform_scheduled_scan_task, id=task_id), + ): + result = perform_scheduled_scan_task.run( + tenant_id=str(tenant.id), provider_id=missing_provider_id + ) + + assert result is None + mock_scan.assert_not_called() + mock_complete_tasks.assert_not_called() + # Orphan PeriodicTask is cleaned up so beat stops re-firing it. + assert not PeriodicTask.objects.filter(name=orphan_name).exists() + + +@pytest.mark.django_db +class TestPerformScanTask: + """Unit tests for perform_scan_task.""" + + def test_no_op_when_provider_does_not_exist(self, tenants_fixture): + """Return None without raising when the provider was already deleted.""" + tenant = tenants_fixture[0] + missing_provider_id = str(uuid.uuid4()) + scan_id = str(uuid.uuid4()) + + with ( + patch("tasks.tasks.perform_prowler_scan") as mock_scan, + patch("tasks.tasks._perform_scan_complete_tasks") as mock_complete_tasks, + ): + result = perform_scan_task.run( + tenant_id=str(tenant.id), + scan_id=scan_id, + provider_id=missing_provider_id, + ) + + assert result is None + mock_scan.assert_not_called() + mock_complete_tasks.assert_not_called() + @pytest.mark.django_db class TestReaggregateAllFindingGroupSummaries: @@ -2620,3 +2716,36 @@ class TestReaggregateAllFindingGroupSummaries: assert result == {"scans_reaggregated": 0} mock_group.assert_not_called() mock_chain.assert_not_called() + + +class TestTaskTimeLimits: + """The per-task limits in task_annotations must actually take effect. + + Celery applies a "*" annotation after the per-task one, so a "*" entry would + silently overwrite every specific limit and cap long scans at the default. The + default is set as the global limit instead, and these per-task limits must win. + """ + + def test_long_running_tasks_exceed_the_default_limit(self): + from config.celery import celery_app + + default = celery_app.conf.task_time_limit + for name in ( + "scan-perform", + "scan-perform-scheduled", + "provider-deletion", + "tenant-deletion", + ): + assert celery_app.tasks[name].time_limit > default + + def test_connection_checks_stay_below_the_default_limit(self): + from config.celery import celery_app + + default = celery_app.conf.task_time_limit + for name in ( + "provider-connection-check", + "integration-connection-check", + "lighthouse-connection-check", + "lighthouse-provider-connection-check", + ): + assert celery_app.tasks[name].time_limit < default diff --git a/api/uv.lock b/api/uv.lock new file mode 100644 index 0000000000..9689a9cd0a --- /dev/null +++ b/api/uv.lock @@ -0,0 +1,5997 @@ +version = 1 +revision = 3 +requires-python = ">=3.11, <3.13" +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'win32'", + "python_full_version >= '3.12' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform != 'win32'", +] + +[manifest] +constraints = [ + { name = "about-time", specifier = "==4.2.1" }, + { name = "adal", specifier = "==1.2.7" }, + { name = "aioboto3", specifier = "==15.5.0" }, + { name = "aiobotocore", specifier = "==2.25.1" }, + { name = "aiofiles", specifier = "==24.1.0" }, + { name = "aiohappyeyeballs", specifier = "==2.6.1" }, + { name = "aiohttp", specifier = "==3.13.5" }, + { name = "aioitertools", specifier = "==0.13.0" }, + { name = "aiosignal", specifier = "==1.4.0" }, + { name = "alibabacloud-actiontrail20200706", specifier = "==2.4.1" }, + { name = "alibabacloud-credentials", specifier = "==1.0.3" }, + { name = "alibabacloud-credentials-api", specifier = "==1.0.0" }, + { name = "alibabacloud-cs20151215", specifier = "==6.1.0" }, + { name = "alibabacloud-darabonba-array", specifier = "==0.1.0" }, + { name = "alibabacloud-darabonba-encode-util", specifier = "==0.0.2" }, + { name = "alibabacloud-darabonba-map", specifier = "==0.0.1" }, + { name = "alibabacloud-darabonba-signature-util", specifier = "==0.0.4" }, + { name = "alibabacloud-darabonba-string", specifier = "==0.0.4" }, + { name = "alibabacloud-darabonba-time", specifier = "==0.0.1" }, + { name = "alibabacloud-ecs20140526", specifier = "==7.2.5" }, + { name = "alibabacloud-endpoint-util", specifier = "==0.0.4" }, + { name = "alibabacloud-gateway-oss", specifier = "==0.0.17" }, + { name = "alibabacloud-gateway-oss-util", specifier = "==0.0.3" }, + { name = "alibabacloud-gateway-sls", specifier = "==0.4.0" }, + { name = "alibabacloud-gateway-sls-util", specifier = "==0.4.0" }, + { name = "alibabacloud-gateway-spi", specifier = "==0.0.3" }, + { name = "alibabacloud-openapi-util", specifier = "==0.2.4" }, + { name = "alibabacloud-oss-util", specifier = "==0.0.6" }, + { name = "alibabacloud-oss20190517", specifier = "==1.0.6" }, + { name = "alibabacloud-ram20150501", specifier = "==1.2.0" }, + { name = "alibabacloud-rds20140815", specifier = "==12.0.0" }, + { name = "alibabacloud-sas20181203", specifier = "==6.1.0" }, + { name = "alibabacloud-sls20201230", specifier = "==5.9.0" }, + { name = "alibabacloud-sts20150401", specifier = "==1.1.6" }, + { name = "alibabacloud-tea", specifier = "==0.4.3" }, + { name = "alibabacloud-tea-openapi", specifier = "==0.4.4" }, + { name = "alibabacloud-tea-util", specifier = "==0.3.14" }, + { name = "alibabacloud-tea-xml", specifier = "==0.0.3" }, + { name = "alibabacloud-vpc20160428", specifier = "==6.13.0" }, + { name = "alive-progress", specifier = "==3.3.0" }, + { name = "aliyun-log-fastpb", specifier = "==0.2.0" }, + { name = "amqp", specifier = "==5.3.1" }, + { name = "annotated-types", specifier = "==0.7.0" }, + { name = "anyio", specifier = "==4.12.1" }, + { name = "applicationinsights", specifier = "==0.11.10" }, + { name = "apscheduler", specifier = "==3.11.2" }, + { name = "argcomplete", specifier = "==3.5.3" }, + { name = "asgiref", specifier = "==3.11.0" }, + { name = "astroid", specifier = "==3.2.4" }, + { name = "async-timeout", specifier = "==5.0.1" }, + { name = "attrs", specifier = "==25.4.0" }, + { name = "authlib", specifier = "==1.6.9" }, + { name = "autopep8", specifier = "==2.3.2" }, + { name = "awsipranges", specifier = "==0.3.3" }, + { name = "azure-cli-core", specifier = "==2.83.0" }, + { name = "azure-cli-telemetry", specifier = "==1.1.0" }, + { name = "azure-common", specifier = "==1.1.28" }, + { name = "azure-core", specifier = "==1.38.1" }, + { name = "azure-identity", specifier = "==1.21.0" }, + { name = "azure-keyvault-certificates", specifier = "==4.10.0" }, + { name = "azure-keyvault-keys", specifier = "==4.10.0" }, + { name = "azure-keyvault-secrets", specifier = "==4.10.0" }, + { name = "azure-mgmt-apimanagement", specifier = "==5.0.0" }, + { name = "azure-mgmt-applicationinsights", specifier = "==4.1.0" }, + { name = "azure-mgmt-authorization", specifier = "==4.0.0" }, + { name = "azure-mgmt-compute", specifier = "==34.0.0" }, + { name = "azure-mgmt-containerinstance", specifier = "==10.1.0" }, + { name = "azure-mgmt-containerregistry", specifier = "==12.0.0" }, + { name = "azure-mgmt-containerservice", specifier = "==34.1.0" }, + { name = "azure-mgmt-core", specifier = "==1.6.0" }, + { name = "azure-mgmt-cosmosdb", specifier = "==9.7.0" }, + { name = "azure-mgmt-databricks", specifier = "==2.0.0" }, + { name = "azure-mgmt-datafactory", specifier = "==9.2.0" }, + { name = "azure-mgmt-eventgrid", specifier = "==10.4.0" }, + { name = "azure-mgmt-eventhub", specifier = "==11.2.0" }, + { name = "azure-mgmt-keyvault", specifier = "==10.3.1" }, + { name = "azure-mgmt-loganalytics", specifier = "==12.0.0" }, + { name = "azure-mgmt-logic", specifier = "==10.0.0" }, + { name = "azure-mgmt-monitor", specifier = "==6.0.2" }, + { name = "azure-mgmt-network", specifier = "==28.1.0" }, + { name = "azure-mgmt-postgresqlflexibleservers", specifier = "==1.1.0" }, + { name = "azure-mgmt-rdbms", specifier = "==10.1.0" }, + { name = "azure-mgmt-recoveryservices", specifier = "==3.1.0" }, + { name = "azure-mgmt-recoveryservicesbackup", specifier = "==9.2.0" }, + { name = "azure-mgmt-resource", specifier = "==24.0.0" }, + { name = "azure-mgmt-search", specifier = "==9.1.0" }, + { name = "azure-mgmt-security", specifier = "==7.0.0" }, + { name = "azure-mgmt-sql", specifier = "==3.0.1" }, + { name = "azure-mgmt-storage", specifier = "==22.1.1" }, + { name = "azure-mgmt-subscription", specifier = "==3.1.1" }, + { name = "azure-mgmt-synapse", specifier = "==2.0.0" }, + { name = "azure-mgmt-web", specifier = "==8.0.0" }, + { name = "azure-monitor-query", specifier = "==2.0.0" }, + { name = "azure-storage-blob", specifier = "==12.24.1" }, + { name = "azure-synapse-artifacts", specifier = "==0.21.0" }, + { name = "backoff", specifier = "==2.2.1" }, + { name = "bandit", specifier = "==1.7.9" }, + { name = "billiard", specifier = "==4.2.4" }, + { name = "blinker", specifier = "==1.9.0" }, + { name = "boto3", specifier = "==1.40.61" }, + { name = "botocore", specifier = "==1.40.61" }, + { name = "cartography", specifier = "==0.135.0" }, + { name = "celery", specifier = "==5.6.2" }, + { name = "certifi", specifier = "==2026.1.4" }, + { name = "cffi", specifier = "==2.0.0" }, + { name = "charset-normalizer", specifier = "==3.4.4" }, + { name = "circuitbreaker", specifier = "==2.1.3" }, + { name = "click", specifier = "==8.3.1" }, + { name = "click-didyoumean", specifier = "==0.3.1" }, + { name = "click-plugins", specifier = "==1.1.1.2" }, + { name = "click-repl", specifier = "==0.3.0" }, + { name = "cloudflare", specifier = "==4.3.1" }, + { name = "colorama", specifier = "==0.4.6" }, + { name = "contextlib2", specifier = "==21.6.0" }, + { name = "contourpy", specifier = "==1.3.3" }, + { name = "coverage", specifier = "==7.5.4" }, + { name = "cron-descriptor", specifier = "==1.4.5" }, + { name = "crowdstrike-falconpy", specifier = "==1.6.0" }, + { name = "cryptography", specifier = "==46.0.7" }, + { name = "cycler", specifier = "==0.12.1" }, + { name = "darabonba-core", specifier = "==1.0.5" }, + { name = "dash", specifier = "==3.1.1" }, + { name = "dash-bootstrap-components", specifier = "==2.0.3" }, + { name = "debugpy", specifier = "==1.8.20" }, + { name = "decorator", specifier = "==5.2.1" }, + { name = "defusedxml", specifier = "==0.7.1" }, + { name = "detect-secrets", specifier = "==1.5.0" }, + { name = "dill", specifier = "==0.4.1" }, + { name = "distro", specifier = "==1.9.0" }, + { name = "dj-rest-auth", specifier = "==7.0.1" }, + { name = "django", specifier = "==5.1.15" }, + { name = "django-allauth", specifier = "==65.15.0" }, + { name = "django-celery-beat", specifier = "==2.9.0" }, + { name = "django-celery-results", specifier = "==2.6.0" }, + { name = "django-cors-headers", specifier = "==4.4.0" }, + { name = "django-environ", specifier = "==0.11.2" }, + { name = "django-filter", specifier = "==24.3" }, + { name = "django-guid", specifier = "==3.5.0" }, + { name = "django-postgres-extra", specifier = "==2.0.9" }, + { name = "django-silk", specifier = "==5.3.2" }, + { name = "django-timezone-field", specifier = "==7.2.1" }, + { name = "djangorestframework", specifier = "==3.15.2" }, + { name = "djangorestframework-jsonapi", specifier = "==7.0.2" }, + { name = "djangorestframework-simplejwt", specifier = "==5.5.1" }, + { name = "dnspython", specifier = "==2.8.0" }, + { name = "docker", specifier = "==7.1.0" }, + { name = "dogpile-cache", specifier = "==1.5.0" }, + { name = "dparse", specifier = "==0.6.4" }, + { name = "drf-extensions", specifier = "==0.8.0" }, + { name = "drf-nested-routers", specifier = "==0.95.0" }, + { name = "drf-simple-apikey", specifier = "==2.2.1" }, + { name = "drf-spectacular", specifier = "==0.27.2" }, + { name = "drf-spectacular-jsonapi", specifier = "==0.5.1" }, + { name = "dulwich", specifier = "==1.2.5" }, + { name = "duo-client", specifier = "==5.5.0" }, + { name = "durationpy", specifier = "==0.10" }, + { name = "email-validator", specifier = "==2.2.0" }, + { name = "execnet", specifier = "==2.1.2" }, + { name = "filelock", specifier = "==3.20.3" }, + { name = "flask", specifier = "==3.1.3" }, + { name = "fonttools", specifier = "==4.62.1" }, + { name = "freezegun", specifier = "==1.5.1" }, + { name = "frozenlist", specifier = "==1.8.0" }, + { name = "gevent", specifier = "==25.9.1" }, + { name = "google-api-core", specifier = "==2.29.0" }, + { name = "google-api-python-client", specifier = "==2.163.0" }, + { name = "google-auth", specifier = "==2.48.0" }, + { name = "google-auth-httplib2", specifier = "==0.2.0" }, + { name = "google-cloud-access-context-manager", specifier = "==0.3.0" }, + { name = "google-cloud-asset", specifier = "==4.2.0" }, + { name = "google-cloud-org-policy", specifier = "==1.16.0" }, + { name = "google-cloud-os-config", specifier = "==1.23.0" }, + { name = "google-cloud-resource-manager", specifier = "==1.16.0" }, + { name = "googleapis-common-protos", specifier = "==1.72.0" }, + { name = "gprof2dot", specifier = "==2025.4.14" }, + { name = "graphemeu", specifier = "==0.7.2" }, + { name = "greenlet", specifier = "==3.3.1" }, + { name = "grpc-google-iam-v1", specifier = "==0.14.3" }, + { name = "grpcio", specifier = "==1.76.0" }, + { name = "grpcio-status", specifier = "==1.76.0" }, + { name = "gunicorn", specifier = "==23.0.0" }, + { name = "h11", specifier = "==0.16.0" }, + { name = "h2", specifier = "==4.3.0" }, + { name = "hpack", specifier = "==4.1.0" }, + { name = "httpcore", specifier = "==1.0.9" }, + { name = "httplib2", specifier = "==0.31.2" }, + { name = "httpx", specifier = "==0.28.1" }, + { name = "humanfriendly", specifier = "==10.0" }, + { name = "hyperframe", specifier = "==6.1.0" }, + { name = "iamdata", specifier = "==0.1.202602021" }, + { name = "idna", specifier = "==3.11" }, + { name = "importlib-metadata", specifier = "==8.7.1" }, + { name = "inflection", specifier = "==0.5.1" }, + { name = "iniconfig", specifier = "==2.3.0" }, + { name = "iso8601", specifier = "==2.1.0" }, + { name = "isodate", specifier = "==0.7.2" }, + { name = "isort", specifier = "==5.13.2" }, + { name = "itsdangerous", specifier = "==2.2.0" }, + { name = "jinja2", specifier = "==3.1.6" }, + { name = "jiter", specifier = "==0.13.0" }, + { name = "jmespath", specifier = "==1.1.0" }, + { name = "joblib", specifier = "==1.5.3" }, + { name = "jsonpatch", specifier = "==1.33" }, + { name = "jsonpickle", specifier = "==4.1.1" }, + { name = "jsonpointer", specifier = "==3.0.0" }, + { name = "jsonschema", specifier = "==4.23.0" }, + { name = "jsonschema-specifications", specifier = "==2025.9.1" }, + { name = "keystoneauth1", specifier = "==5.13.0" }, + { name = "kiwisolver", specifier = "==1.4.9" }, + { name = "knack", specifier = "==0.11.0" }, + { name = "kombu", specifier = "==5.6.2" }, + { name = "kubernetes", specifier = "==32.0.1" }, + { name = "lxml", specifier = "==6.1.0" }, + { name = "lz4", specifier = "==4.4.5" }, + { name = "markdown", specifier = "==3.10.2" }, + { name = "markdown-it-py", specifier = "==4.0.0" }, + { name = "markupsafe", specifier = "==3.0.3" }, + { name = "marshmallow", specifier = "==4.3.0" }, + { name = "matplotlib", specifier = "==3.10.8" }, + { name = "mccabe", specifier = "==0.7.0" }, + { name = "mdurl", specifier = "==0.1.2" }, + { name = "microsoft-kiota-abstractions", specifier = "==1.9.9" }, + { name = "microsoft-kiota-authentication-azure", specifier = "==1.9.9" }, + { name = "microsoft-kiota-http", specifier = "==1.9.9" }, + { name = "microsoft-kiota-serialization-form", specifier = "==1.9.9" }, + { name = "microsoft-kiota-serialization-json", specifier = "==1.9.9" }, + { name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.9" }, + { name = "microsoft-kiota-serialization-text", specifier = "==1.9.9" }, + { name = "microsoft-security-utilities-secret-masker", specifier = "==1.0.0b4" }, + { name = "msal", specifier = "==1.35.0b1" }, + { name = "msal-extensions", specifier = "==1.2.0" }, + { name = "msgraph-core", specifier = "==1.3.8" }, + { name = "msgraph-sdk", specifier = "==1.55.0" }, + { name = "msrest", specifier = "==0.7.1" }, + { name = "msrestazure", specifier = "==0.6.4.post1" }, + { name = "multidict", specifier = "==6.7.1" }, + { name = "mypy", specifier = "==1.10.1" }, + { name = "mypy-extensions", specifier = "==1.1.0" }, + { name = "narwhals", specifier = "==2.16.0" }, + { name = "neo4j", specifier = "==6.1.0" }, + { name = "nest-asyncio", specifier = "==1.6.0" }, + { name = "nltk", specifier = "==3.9.4" }, + { name = "numpy", specifier = "==2.0.2" }, + { name = "oauthlib", specifier = "==3.3.1" }, + { name = "oci", specifier = "==2.169.0" }, + { name = "openai", specifier = "==1.109.1" }, + { name = "openstacksdk", specifier = "==4.2.0" }, + { name = "opentelemetry-api", specifier = "==1.39.1" }, + { name = "opentelemetry-sdk", specifier = "==1.39.1" }, + { name = "opentelemetry-semantic-conventions", specifier = "==0.60b1" }, + { name = "os-service-types", specifier = "==1.8.2" }, + { name = "packageurl-python", specifier = "==0.17.6" }, + { name = "packaging", specifier = "==26.0" }, + { name = "pagerduty", specifier = "==6.1.0" }, + { name = "pandas", specifier = "==2.2.3" }, + { name = "pbr", specifier = "==7.0.3" }, + { name = "pillow", specifier = "==12.2.0" }, + { name = "pkginfo", specifier = "==1.12.1.2" }, + { name = "platformdirs", specifier = "==4.5.1" }, + { name = "plotly", specifier = "==6.5.2" }, + { name = "pluggy", specifier = "==1.6.0" }, + { name = "policyuniverse", specifier = "==1.5.1.20231109" }, + { name = "portalocker", specifier = "==2.10.1" }, + { name = "prek", specifier = "==0.3.9" }, + { name = "prompt-toolkit", specifier = "==3.0.52" }, + { name = "propcache", specifier = "==0.4.1" }, + { name = "proto-plus", specifier = "==1.27.0" }, + { name = "protobuf", specifier = "==6.33.5" }, + { name = "psutil", specifier = "==7.2.2" }, + { name = "psycopg2-binary", specifier = "==2.9.9" }, + { name = "py-deviceid", specifier = "==0.1.1" }, + { name = "py-iam-expand", specifier = "==0.1.0" }, + { name = "py-ocsf-models", specifier = "==0.8.1" }, + { name = "pyasn1", specifier = "==0.6.3" }, + { name = "pyasn1-modules", specifier = "==0.4.2" }, + { name = "pycodestyle", specifier = "==2.14.0" }, + { name = "pycparser", specifier = "==3.0" }, + { name = "pydantic", specifier = "==2.12.5" }, + { name = "pydantic-core", specifier = "==2.41.5" }, + { name = "pygithub", specifier = "==2.8.0" }, + { name = "pygments", specifier = "==2.20.0" }, + { name = "pyjwt", specifier = "==2.13.0" }, + { name = "pylint", specifier = "==3.2.5" }, + { name = "pymsalruntime", specifier = "==0.18.1" }, + { name = "pynacl", specifier = "==1.6.2" }, + { name = "pyopenssl", specifier = "==26.0.0" }, + { name = "pyparsing", specifier = "==3.3.2" }, + { name = "pyreadline3", specifier = "==3.5.4" }, + { name = "pysocks", specifier = "==1.7.1" }, + { name = "pytest", specifier = "==9.0.3" }, + { name = "pytest-celery", specifier = "==1.3.0" }, + { name = "pytest-cov", specifier = "==5.0.0" }, + { name = "pytest-django", specifier = "==4.8.0" }, + { name = "pytest-docker-tools", specifier = "==3.1.9" }, + { name = "pytest-env", specifier = "==1.1.3" }, + { name = "pytest-randomly", specifier = "==3.15.0" }, + { name = "pytest-xdist", specifier = "==3.6.1" }, + { name = "python-crontab", specifier = "==3.3.0" }, + { name = "python-dateutil", specifier = "==2.9.0.post0" }, + { name = "python-digitalocean", specifier = "==1.17.0" }, + { name = "python3-saml", specifier = "==1.16.0" }, + { name = "pytz", specifier = "==2025.1" }, + { name = "pywin32", specifier = "==311" }, + { name = "pyyaml", specifier = "==6.0.3" }, + { name = "redis", specifier = "==7.1.0" }, + { name = "referencing", specifier = "==0.37.0" }, + { name = "regex", specifier = "==2026.1.15" }, + { name = "reportlab", specifier = "==4.4.10" }, + { name = "requests", specifier = "==2.33.1" }, + { name = "requests-file", specifier = "==3.0.1" }, + { name = "requests-oauthlib", specifier = "==2.0.0" }, + { name = "requestsexceptions", specifier = "==1.4.0" }, + { name = "retrying", specifier = "==1.4.2" }, + { name = "rich", specifier = "==14.3.2" }, + { name = "rpds-py", specifier = "==0.30.0" }, + { name = "rsa", specifier = "==4.9.1" }, + { name = "ruamel-yaml", specifier = "==0.19.1" }, + { name = "ruff", specifier = "==0.5.0" }, + { name = "s3transfer", specifier = "==0.14.0" }, + { name = "scaleway", specifier = "==2.10.3" }, + { name = "scaleway-core", specifier = "==2.10.3" }, + { name = "schema", specifier = "==0.7.5" }, + { name = "sentry-sdk", specifier = "==2.56.0" }, + { name = "setuptools", specifier = "==80.10.2" }, + { name = "shellingham", specifier = "==1.5.4" }, + { name = "shodan", specifier = "==1.31.0" }, + { name = "six", specifier = "==1.17.0" }, + { name = "slack-sdk", specifier = "==3.39.0" }, + { name = "sniffio", specifier = "==1.3.1" }, + { name = "sqlparse", specifier = "==0.5.5" }, + { name = "statsd", specifier = "==4.0.1" }, + { name = "std-uritemplate", specifier = "==2.0.8" }, + { name = "stevedore", specifier = "==5.6.0" }, + { name = "tabulate", specifier = "==0.9.0" }, + { name = "tenacity", specifier = "==9.1.2" }, + { name = "tldextract", specifier = "==5.3.1" }, + { name = "tomlkit", specifier = "==0.14.0" }, + { name = "tqdm", specifier = "==4.67.1" }, + { name = "typer", specifier = "==0.21.1" }, + { name = "types-aiobotocore-ecr", specifier = "==3.1.1" }, + { name = "typing-extensions", specifier = "==4.15.0" }, + { name = "typing-inspection", specifier = "==0.4.2" }, + { name = "tzdata", specifier = "==2025.3" }, + { name = "tzlocal", specifier = "==5.3.1" }, + { name = "uritemplate", specifier = "==4.2.0" }, + { name = "urllib3", specifier = "==2.7.0" }, + { name = "uuid6", specifier = "==2024.7.10" }, + { name = "vine", specifier = "==5.1.0" }, + { name = "vulture", specifier = "==2.14" }, + { name = "wcwidth", specifier = "==0.5.3" }, + { name = "websocket-client", specifier = "==1.9.0" }, + { name = "werkzeug", specifier = "==3.1.7" }, + { name = "workos", specifier = "==6.0.4" }, + { name = "wrapt", specifier = "==1.17.3" }, + { name = "xlsxwriter", specifier = "==3.2.9" }, + { name = "xmlsec", specifier = "==1.3.17" }, + { name = "xmltodict", specifier = "==1.0.2" }, + { name = "yarl", specifier = "==1.22.0" }, + { name = "zipp", specifier = "==3.23.0" }, + { name = "zope-event", specifier = "==6.1" }, + { name = "zope-interface", specifier = "==8.2" }, + { name = "zstd", specifier = "==1.5.7.3" }, +] +overrides = [ + { name = "dulwich", specifier = "==1.2.5" }, + { name = "microsoft-kiota-abstractions", specifier = "==1.9.9" }, + { name = "okta", specifier = "==3.4.2" }, + { name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" }, +] + +[[package]] +name = "about-time" +version = "4.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3f/ccb16bdc53ebb81c1bf837c1ee4b5b0b69584fd2e4a802a2a79936691c0a/about-time-4.2.1.tar.gz", hash = "sha256:6a538862d33ce67d997429d14998310e1dbfda6cb7d9bbfbf799c4709847fece", size = 15380, upload-time = "2022-12-21T04:15:54.991Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/cd/7ee00d6aa023b1d0551da0da5fee3bc23c3eeea632fbfc5126d1fec52b7e/about_time-4.2.1-py3-none-any.whl", hash = "sha256:8bbf4c75fe13cbd3d72f49a03b02c5c7dca32169b6d49117c257e7eb3eaee341", size = 13295, upload-time = "2022-12-21T04:15:53.613Z" }, +] + +[[package]] +name = "adal" +version = "1.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-dateutil" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/d7/a829bc5e8ff28f82f9e2dc9b363f3b7b9c1194766d5a75105e3885bfa9a8/adal-1.2.7.tar.gz", hash = "sha256:d74f45b81317454d96e982fd1c50e6fb5c99ac2223728aea8764433a39f566f1", size = 35196, upload-time = "2021-04-05T16:33:40.88Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/8d/58008a9a86075827f99aa8bb75d8db515bb9c34654f95e647cda31987db7/adal-1.2.7-py2.py3-none-any.whl", hash = "sha256:2a7451ed7441ddbc57703042204a3e30ef747478eea022c70f789fc7f084bc3d", size = 55539, upload-time = "2021-04-05T16:33:39.544Z" }, +] + +[[package]] +name = "aenum" +version = "3.1.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/e9/8b283567c1fef7c24d1f390b37daede8b61593d8cdaffb8e95d571699e83/aenum-3.1.17.tar.gz", hash = "sha256:a969a4516b194895de72c875ece355f17c0d272146f7fda346ef74f93cf4d5ba", size = 137648, upload-time = "2026-03-20T20:43:29.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/8d/1fe30c6fd8999b9d462547c4a1bb6690bda24af38f2913c4bec7decb81f2/aenum-3.1.17-py3-none-any.whl", hash = "sha256:8b883a37a04e74cc838ac442bdd28c266eae5bbf13e1342c7ef123ed25230139", size = 165560, upload-time = "2026-03-20T20:43:27.681Z" }, +] + +[[package]] +name = "aioboto3" +version = "15.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiobotocore", extra = ["boto3"] }, + { name = "aiofiles" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/01/92e9ab00f36e2899315f49eefcd5b4685fbb19016c7f19a9edf06da80bb0/aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979", size = 255069, upload-time = "2025-10-30T13:37:16.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/3e/e8f5b665bca646d43b916763c901e00a07e40f7746c9128bdc912a089424/aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6", size = 35913, upload-time = "2025-10-30T13:37:14.549Z" }, +] + +[[package]] +name = "aiobotocore" +version = "2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aioitertools" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "multidict" }, + { name = "python-dateutil" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/94/2e4ec48cf1abb89971cb2612d86f979a6240520f0a659b53a43116d344dc/aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc", size = 120560, upload-time = "2025-10-28T22:33:21.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/2a/d275ec4ce5cd0096665043995a7d76f5d0524853c76a3d04656de49f8808/aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f", size = 86039, upload-time = "2025-10-28T22:33:19.949Z" }, +] + +[package.optional-dependencies] +boto3 = [ + { name = "boto3" }, +] + +[[package]] +name = "aiofiles" +version = "24.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/f5/a20c4ac64aeaef1679e25c9983573618ff765d7aa829fa2b84ae7573169e/aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6", size = 757513, upload-time = "2026-03-31T21:57:02.146Z" }, + { url = "https://files.pythonhosted.org/packages/75/0a/39fa6c6b179b53fcb3e4b3d2b6d6cad0180854eda17060c7218540102bef/aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d", size = 506748, upload-time = "2026-03-31T21:57:04.275Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/e38ce072e724fd7add6243613f8d1810da084f54175353d25ccf9f9c7e5a/aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c", size = 501673, upload-time = "2026-03-31T21:57:06.208Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ba/3bc7525d7e2beaa11b309a70d48b0d3cfc3c2089ec6a7d0820d59c657053/aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb", size = 1763757, upload-time = "2026-03-31T21:57:07.882Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ab/e87744cf18f1bd78263aba24924d4953b41086bd3a31d22452378e9028a0/aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6", size = 1720152, upload-time = "2026-03-31T21:57:09.946Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f3/ed17a6f2d742af17b50bae2d152315ed1b164b07a5fd5cc1754d99e4dfa5/aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13", size = 1818010, upload-time = "2026-03-31T21:57:12.157Z" }, + { url = "https://files.pythonhosted.org/packages/53/06/ecbc63dc937192e2a5cb46df4d3edb21deb8225535818802f210a6ea5816/aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174", size = 1907251, upload-time = "2026-03-31T21:57:14.023Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a5/0521aa32c1ddf3aa1e71dcc466be0b7db2771907a13f18cddaa45967d97b/aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc", size = 1759969, upload-time = "2026-03-31T21:57:16.146Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/a38f8c9105199dd3b9706745865a8a59d0041b6be0ca0cc4b2ccf1bab374/aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6", size = 1616871, upload-time = "2026-03-31T21:57:17.856Z" }, + { url = "https://files.pythonhosted.org/packages/6f/41/27392a61ead8ab38072105c71aa44ff891e71653fe53d576a7067da2b4e8/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49", size = 1739844, upload-time = "2026-03-31T21:57:19.679Z" }, + { url = "https://files.pythonhosted.org/packages/6e/55/5564e7ae26d94f3214250009a0b1c65a0c6af4bf88924ccb6fdab901de28/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8", size = 1731969, upload-time = "2026-03-31T21:57:22.006Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c5/705a3929149865fc941bcbdd1047b238e4a72bcb215a9b16b9d7a2e8d992/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d", size = 1795193, upload-time = "2026-03-31T21:57:24.256Z" }, + { url = "https://files.pythonhosted.org/packages/a6/19/edabed62f718d02cff7231ca0db4ef1c72504235bc467f7b67adb1679f48/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c", size = 1606477, upload-time = "2026-03-31T21:57:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/de/fc/76f80ef008675637d88d0b21584596dc27410a990b0918cb1e5776545b5b/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac", size = 1813198, upload-time = "2026-03-31T21:57:28.316Z" }, + { url = "https://files.pythonhosted.org/packages/e5/67/5b3ac26b80adb20ea541c487f73730dc8fa107d632c998f25bbbab98fcda/aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3", size = 1752321, upload-time = "2026-03-31T21:57:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/88/06/e4a2e49255ea23fa4feeb5ab092d90240d927c15e47b5b5c48dff5a9ce29/aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06", size = 439069, upload-time = "2026-03-31T21:57:32.388Z" }, + { url = "https://files.pythonhosted.org/packages/c0/43/8c7163a596dab4f8be12c190cf467a1e07e4734cf90eebb39f7f5d53fc6a/aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8", size = 462859, upload-time = "2026-03-31T21:57:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876, upload-time = "2026-03-31T21:57:36.319Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557, upload-time = "2026-03-31T21:57:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258, upload-time = "2026-03-31T21:57:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199, upload-time = "2026-03-31T21:57:41.938Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013, upload-time = "2026-03-31T21:57:43.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501, upload-time = "2026-03-31T21:57:46.285Z" }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981, upload-time = "2026-03-31T21:57:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934, upload-time = "2026-03-31T21:57:51.171Z" }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671, upload-time = "2026-03-31T21:57:53.326Z" }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219, upload-time = "2026-03-31T21:57:55.385Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049, upload-time = "2026-03-31T21:57:57.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557, upload-time = "2026-03-31T21:57:59.626Z" }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931, upload-time = "2026-03-31T21:58:01.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125, upload-time = "2026-03-31T21:58:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, +] + +[[package]] +name = "aioitertools" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "alibabacloud-actiontrail20200706" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-endpoint-util" }, + { name = "alibabacloud-openapi-util" }, + { name = "alibabacloud-tea-openapi" }, + { name = "alibabacloud-tea-util" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/97/ce0fb7ee2507fa9e6d41c8bc5bbc224d15af6c3c47047af6c1254b17b969/alibabacloud_actiontrail20200706-2.4.1.tar.gz", hash = "sha256:b65c6b37a96443fbe625dd5a4dd1be52a7476006a411db75206908b11588ffa8", size = 30016, upload-time = "2025-08-18T02:11:13.404Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/f8/c4b886644f5f3d69c6cc9cb8fabe286d9c3d1ec8ab7796205e2f6e038437/alibabacloud_actiontrail20200706-2.4.1-py3-none-any.whl", hash = "sha256:5dee0009db9b7cba182fbac742820f6a949287a8faafb843b5107f7dc89136da", size = 28861, upload-time = "2025-08-18T02:11:12.49Z" }, +] + +[[package]] +name = "alibabacloud-credentials" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "alibabacloud-credentials-api" }, + { name = "alibabacloud-tea" }, + { name = "apscheduler" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/82/45ec98bd19387507cf058ce47f62d6fea288bf0511c5a101b832e13d3edd/alibabacloud-credentials-1.0.3.tar.gz", hash = "sha256:9d8707e96afc6f348e23f5677ed15a21c2dfce7cfe6669776548ee4c80e1dfaf", size = 35831, upload-time = "2025-10-14T06:39:58.97Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/df/dbd9ae9d531a40d5613573c5a22ef774ecfdcaa0dc43aad42189f89c04ce/alibabacloud_credentials-1.0.3-py3-none-any.whl", hash = "sha256:30c8302f204b663c655d97e1c283ee9f9f84a6257d7901b931477d6cf34445a8", size = 41875, upload-time = "2025-10-14T06:39:58.029Z" }, +] + +[[package]] +name = "alibabacloud-credentials-api" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/87/1d7019d23891897cb076b2f7e3c81ab3c2ba91de3bb067196f675d60d34c/alibabacloud-credentials-api-1.0.0.tar.gz", hash = "sha256:8c340038d904f0218d7214a8f4088c31912bfcf279af2cbc7d9be4897a97dd2f", size = 2330, upload-time = "2025-01-13T05:53:04.931Z" } + +[[package]] +name = "alibabacloud-cs20151215" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-endpoint-util" }, + { name = "alibabacloud-openapi-util" }, + { name = "alibabacloud-tea-openapi" }, + { name = "alibabacloud-tea-util" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/51/6a6aa2c0c1e392e137fbde77c1d5f6bf16e665b3d5e6e3ab747664e67cdf/alibabacloud_cs20151215-6.1.0.tar.gz", hash = "sha256:5b3d99306701bf499ddd57cd9f2905b7721cb1bb4bb38ffe4d051f7b4e80e355", size = 191423, upload-time = "2025-11-05T11:49:31.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/51/00c07f61c64deb1c1a780c8fe5e34cf57950ea47a545647dc7c00314be93/alibabacloud_cs20151215-6.1.0-py3-none-any.whl", hash = "sha256:75e90b1bb9acca2236244bb0e44234ca4805d456ea4303ba4225ac15152a458e", size = 189706, upload-time = "2025-11-05T11:49:29.848Z" }, +] + +[[package]] +name = "alibabacloud-darabonba-array" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/be/1813d7553e11e20a1422ffaaead392cfa7239a855c7e67c6a6b5776cfa64/alibabacloud_darabonba_array-0.1.0.tar.gz", hash = "sha256:7f9a7c632518ff4f0cebb0d4e825a48c12e7cf0b9016ea25054dd73732e155aa", size = 2306, upload-time = "2022-11-01T06:32:47.928Z" } + +[[package]] +name = "alibabacloud-darabonba-encode-util" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/d8/22543b2ade9aa68fef028a9f0c4154bfdb970926f918f63d7b85bae527a9/alibabacloud_darabonba_encode_util-0.0.2.tar.gz", hash = "sha256:f1c484f276d60450fa49b4b2987194e741fcb2f7faae7f287c0ae65abc85fd4d", size = 3990, upload-time = "2022-12-10T04:43:48.086Z" } + +[[package]] +name = "alibabacloud-darabonba-map" +version = "0.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/bc/f11d56adffffade9a0d33ccca155ce82ca950b97cdce27a75228715c4639/alibabacloud_darabonba_map-0.0.1.tar.gz", hash = "sha256:adb17384658a1a8f72418f1838d4b6a5fd2566bfd392a3ef06d9dbb0a595a23f", size = 2056, upload-time = "2021-12-04T03:41:20.369Z" } + +[[package]] +name = "alibabacloud-darabonba-signature-util" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/09/2118a2df631eaa06a291013ea61f31e449ba7a3cc3d6061477a43420e93a/alibabacloud_darabonba_signature_util-0.0.4.tar.gz", hash = "sha256:71d79b2ae65957bcfbf699ced894fda782b32f9635f1616635533e5a90d5feb0", size = 4170, upload-time = "2022-12-10T04:44:42.979Z" } + +[[package]] +name = "alibabacloud-darabonba-string" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/d4/3d22bd2ff88985f970a10f8cedf2ea326d11d4d95e244f7665dc83d26465/alibabacloud-darabonba-string-0.0.4.tar.gz", hash = "sha256:ec6614c0448dadcbc5e466485838a1f8cfdd911135bea739e20b14511270c6f7", size = 2852, upload-time = "2021-12-13T13:30:06.114Z" } + +[[package]] +name = "alibabacloud-darabonba-time" +version = "0.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/df/33a239384353130eef51af14cec1fa99058b2c5f4c7f3d0b283e5f17b874/alibabacloud_darabonba_time-0.0.1.tar.gz", hash = "sha256:0ad9c7b0696570d1a3f40106cc7777f755fd92baa0d1dcab5b7df78dde5b922d", size = 2885, upload-time = "2021-01-05T02:58:06.121Z" } + +[[package]] +name = "alibabacloud-ecs20140526" +version = "7.2.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-endpoint-util" }, + { name = "alibabacloud-openapi-util" }, + { name = "alibabacloud-tea-openapi" }, + { name = "alibabacloud-tea-util" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/f1/721627d499eb8ed5f8ad3ba0dbf57a0da10ff01deaf64f3536f70edbab8c/alibabacloud_ecs20140526-7.2.5.tar.gz", hash = "sha256:2abbe630ce42d69061821f38950b938c5982cc31902ccd7132d05be328765a55", size = 651634, upload-time = "2025-10-29T17:38:48.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/8e/e3709447c737d7deb3cd34dc97f6ea2b3b57e0c8c3973ab9018c113ca68c/alibabacloud_ecs20140526-7.2.5-py3-none-any.whl", hash = "sha256:10bda5e185f6ba899e7d51477373595c629d66db7530a8a37433fb4e9034a96f", size = 659546, upload-time = "2025-10-29T17:38:35.768Z" }, +] + +[[package]] +name = "alibabacloud-endpoint-util" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/7d/8cc92a95c920e344835b005af6ea45a0db98763ad6ad19299d26892e6c8d/alibabacloud_endpoint_util-0.0.4.tar.gz", hash = "sha256:a593eb8ddd8168d5dc2216cd33111b144f9189fcd6e9ca20e48f358a739bbf90", size = 2813, upload-time = "2025-06-12T07:20:52.572Z" } + +[[package]] +name = "alibabacloud-gateway-oss" +version = "0.0.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-credentials" }, + { name = "alibabacloud-darabonba-array" }, + { name = "alibabacloud-darabonba-encode-util" }, + { name = "alibabacloud-darabonba-map" }, + { name = "alibabacloud-darabonba-signature-util" }, + { name = "alibabacloud-darabonba-string" }, + { name = "alibabacloud-darabonba-time" }, + { name = "alibabacloud-gateway-oss-util" }, + { name = "alibabacloud-gateway-spi" }, + { name = "alibabacloud-openapi-util" }, + { name = "alibabacloud-oss-util" }, + { name = "alibabacloud-tea-util" }, + { name = "alibabacloud-tea-xml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/c3/4172567b96d885117d93b5ac7b849dda7f0421af7410e074f3ccf3469357/alibabacloud_gateway_oss-0.0.17.tar.gz", hash = "sha256:8c4b66c8c7dd285fc210ee232ab3f062b5573258752804d19382000746531e29", size = 8030, upload-time = "2025-04-07T03:16:28.346Z" } + +[[package]] +name = "alibabacloud-gateway-oss-util" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/1e/caf81e07d285dbef4dc62e63763448cc84a9b483a69b1a1a576efbbe8795/alibabacloud_gateway_oss_util-0.0.3.tar.gz", hash = "sha256:5eb7fa450dc7350d5c71577974b9d7f489479e5c5ec7efc1c5376385e8c1c0a5", size = 105567, upload-time = "2025-01-03T07:59:41.501Z" } + +[[package]] +name = "alibabacloud-gateway-sls" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-credentials" }, + { name = "alibabacloud-darabonba-array" }, + { name = "alibabacloud-darabonba-encode-util" }, + { name = "alibabacloud-darabonba-map" }, + { name = "alibabacloud-darabonba-signature-util" }, + { name = "alibabacloud-darabonba-string" }, + { name = "alibabacloud-gateway-sls-util" }, + { name = "alibabacloud-gateway-spi" }, + { name = "alibabacloud-openapi-util" }, + { name = "alibabacloud-tea-util" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ca/38effb3f7e81f1abc78f0c283ba2d1a19cb9fbdc3a0ad7b8f6300a0a413c/alibabacloud_gateway_sls-0.4.0.tar.gz", hash = "sha256:9d2aceb377c9b3ed0558149fda16fe39fa114cc0a22e22a88dc76efdda34633b", size = 7660, upload-time = "2025-11-18T11:51:08.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/ec/f2c170b70ff5e375bdf869bf1b976b0e027640742887817dc62a58bb1d97/alibabacloud_gateway_sls-0.4.0-py3-none-any.whl", hash = "sha256:a0299a83a5528025983b42b7533a28028461bced5e180a66f97999e0134760a6", size = 6905, upload-time = "2025-11-18T11:51:07.357Z" }, +] + +[[package]] +name = "alibabacloud-gateway-sls-util" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aliyun-log-fastpb" }, + { name = "lz4" }, + { name = "zstd" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/ef/590fee606b0ce16e28a0ece8c81338718b87c47d6bb1553f383ec493fafa/alibabacloud_gateway_sls_util-0.4.0.tar.gz", hash = "sha256:f8b683a36a2ae3fe9a8225d3d97773ea769bdf9cdf4f4d033eab2eb6062ddd1f", size = 3400, upload-time = "2025-11-18T11:07:12.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/4f/fd5ce522c7cec21e500a8e1c161b16f2762cdc343c370c05249af545239f/alibabacloud_gateway_sls_util-0.4.0-py3-none-any.whl", hash = "sha256:c91ab7fe55af526a01d25b0d431088c4d241b160db055da3d8cb7330bd74595a", size = 3499, upload-time = "2025-11-18T11:07:11.813Z" }, +] + +[[package]] +name = "alibabacloud-gateway-spi" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-credentials" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/98/d7111245f17935bf72ee9bea60bbbeff2bc42cdfe24d2544db52bc517e1a/alibabacloud_gateway_spi-0.0.3.tar.gz", hash = "sha256:10d1c53a3fc5f87915fbd6b4985b98338a776e9b44a0263f56643c5048223b8b", size = 4249, upload-time = "2025-02-23T16:29:54.222Z" } + +[[package]] +name = "alibabacloud-openapi-util" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-tea-util" }, + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/51/be5802851a4ed20ac2c6db50ac8354a6e431e93db6e714ca39b50983626f/alibabacloud_openapi_util-0.2.4.tar.gz", hash = "sha256:87022b9dcb7593a601f7a40ca698227ac3ccb776b58cb7b06b8dc7f510995c34", size = 7981, upload-time = "2026-01-15T08:05:03.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/46/9b217343648b366eb93447f5d93116e09a61956005794aed5ef95a2e9e2e/alibabacloud_openapi_util-0.2.4-py3-none-any.whl", hash = "sha256:a2474f230b5965ae9a8c286e0dc86132a887928d02d20b8182656cf6b1b6c5bd", size = 7661, upload-time = "2026-01-15T08:05:01.374Z" }, +] + +[[package]] +name = "alibabacloud-oss-util" +version = "0.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-tea" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/7c/d7e812b9968247a302573daebcfef95d0f9a718f7b4bfcca8d3d83e266be/alibabacloud_oss_util-0.0.6.tar.gz", hash = "sha256:d3ecec36632434bd509a113e8cf327dc23e830ac8d9dd6949926f4e334c8b5d6", size = 10008, upload-time = "2021-04-28T09:25:04.056Z" } + +[[package]] +name = "alibabacloud-oss20190517" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-gateway-oss" }, + { name = "alibabacloud-gateway-spi" }, + { name = "alibabacloud-openapi-util" }, + { name = "alibabacloud-tea-openapi" }, + { name = "alibabacloud-tea-util" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/d7/2f9ad0b13bd196b0f45bdb4a0d1ecc14d1723ad1685e37d975cfbb566919/alibabacloud_oss20190517-1.0.6.tar.gz", hash = "sha256:7cd0fb16af613ceb38d2e0e529aa1f58038c7cf59eb67c8c8775ae44ea717852", size = 51772, upload-time = "2023-07-27T06:41:50.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/ca/bf1cb50f736684711fefeb10dfb95152fe9c1c0d30e80356fd45bfd77e9e/alibabacloud_oss20190517-1.0.6-py3-none-any.whl", hash = "sha256:365fda353de6658a1a289f4d70dcd0394e2a8e2921b6b5834ba6d9772121d2f6", size = 50948, upload-time = "2023-07-27T06:41:48.645Z" }, +] + +[[package]] +name = "alibabacloud-ram20150501" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-endpoint-util" }, + { name = "alibabacloud-openapi-util" }, + { name = "alibabacloud-tea-openapi" }, + { name = "alibabacloud-tea-util" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fd/5b18adb76bbe55fc6da2913f22f4862f296eea8ed84c605f106aaff50daa/alibabacloud_ram20150501-1.2.0.tar.gz", hash = "sha256:6253513c8880769f4fd5b36fedddb362a9ca628ad9ae9c05c0eeacf5fbc95b42", size = 44340, upload-time = "2025-06-12T07:59:39.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/ca/50b3d365a2d73841cead4f1ca777f883d237d7191c51f4010247ec2b69e0/alibabacloud_ram20150501-1.2.0-py3-none-any.whl", hash = "sha256:03a0f2a0259848787c1f74e802b486184a88e04183486bd9398766971e5eb00a", size = 43138, upload-time = "2025-06-12T07:59:38.72Z" }, +] + +[[package]] +name = "alibabacloud-rds20140815" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-endpoint-util" }, + { name = "alibabacloud-openapi-util" }, + { name = "alibabacloud-tea-openapi" }, + { name = "alibabacloud-tea-util" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/6d/167b4e285f74f2426ea137388220f484e41263bbc21f42c8280cfa95cb0d/alibabacloud_rds20140815-12.0.0.tar.gz", hash = "sha256:e7421d94f18a914c0a06b0e7fad0daff557713f1c97d415d463a78c1270e9b98", size = 466530, upload-time = "2025-07-04T10:12:23.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/f9/5dd141ebf043d10b83a0913fd9e689733b5e81b0074bf12333f3797a1016/alibabacloud_rds20140815-12.0.0-py3-none-any.whl", hash = "sha256:0bd7e2018a428d86b1b0681087336e74665b48fc3eb0a13c4f4377ed5eab2b08", size = 472145, upload-time = "2025-07-04T10:12:21.875Z" }, +] + +[[package]] +name = "alibabacloud-sas20181203" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-endpoint-util" }, + { name = "alibabacloud-openapi-util" }, + { name = "alibabacloud-tea-openapi" }, + { name = "alibabacloud-tea-util" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/c4/7cdd75a2f7afceecc6652ca3c35c6ec3a4a095cd84d02b07749c52317814/alibabacloud_sas20181203-6.1.0.tar.gz", hash = "sha256:e49ffd53e630274a8bf5a8299ca753023ad118510c80f6d9c6fb018b7479bf37", size = 837657, upload-time = "2025-11-13T17:30:54.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/fb/0f67a5c982f1818001e97caeebece2ad8b658b1f2cb51e948ab89201754f/alibabacloud_sas20181203-6.1.0-py3-none-any.whl", hash = "sha256:1ad735332c50c7961be036b17420d56b5ec3b5557e3aea1daa19491e8b75da20", size = 848025, upload-time = "2025-11-13T17:30:52.387Z" }, +] + +[[package]] +name = "alibabacloud-sls20201230" +version = "5.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-gateway-sls" }, + { name = "alibabacloud-openapi-util" }, + { name = "alibabacloud-tea-openapi" }, + { name = "alibabacloud-tea-util" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/09/49043ef5074e1127b81d133ab606f581f206135541e734b6dccb99b0d66f/alibabacloud_sls20201230-5.9.0.tar.gz", hash = "sha256:bea830b64fbc7ed1719ba386ceeefb120f08d705f03eb0e02409dc6f12a291da", size = 120270, upload-time = "2025-09-11T12:50:32.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/6b/8167bfdd5f35fcee5ac0738627d3d157687a584c660cedea8d45b0b90dcd/alibabacloud_sls20201230-5.9.0-py3-none-any.whl", hash = "sha256:c4ae14096817a9686af5a0ae2389f1f6a8781e60b9edb8643445250cf15c26f1", size = 118782, upload-time = "2025-09-11T12:50:31.17Z" }, +] + +[[package]] +name = "alibabacloud-sts20150401" +version = "1.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-endpoint-util" }, + { name = "alibabacloud-openapi-util" }, + { name = "alibabacloud-tea-openapi" }, + { name = "alibabacloud-tea-util" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/94/3b360c5051d4e4c3f2f410b7631e4a8bf7e508a498acc15be6ec7bf58594/alibabacloud_sts20150401-1.1.6.tar.gz", hash = "sha256:c2529b41e0e4531e21cb393e4df346e19fd6d54cc6337d1138dbcd2191438d4c", size = 12343, upload-time = "2025-06-30T09:19:52.537Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/fa/cc66a8527235e61718e7cdbc43f82d11124ca44c89ce336226d5dc875b80/alibabacloud_sts20150401-1.1.6-py3-none-any.whl", hash = "sha256:627f5ca1f86e19b0bf8ce0e99071a36fb65579fad9256fbee38fdc8d500598e9", size = 11072, upload-time = "2025-06-30T09:19:51.176Z" }, +] + +[[package]] +name = "alibabacloud-tea" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0f3f9d7f26b76b9ed93d4101add7867a2c87ed2534f5/alibabacloud-tea-0.4.3.tar.gz", hash = "sha256:ec8053d0aa8d43ebe1deb632d5c5404339b39ec9a18a0707d57765838418504a", size = 8785, upload-time = "2025-03-24T07:34:42.958Z" } + +[[package]] +name = "alibabacloud-tea-openapi" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-credentials" }, + { name = "alibabacloud-gateway-spi" }, + { name = "alibabacloud-tea-util" }, + { name = "cryptography" }, + { name = "darabonba-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/93/138bcdc8fc596add73e37cf2073798f285284d1240bda9ee02f9384fc6be/alibabacloud_tea_openapi-0.4.4.tar.gz", hash = "sha256:1b0917bc03cd49417da64945e92731716d53e2eb8707b235f54e45b7473221ce", size = 21960, upload-time = "2026-03-26T10:16:16.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/5a/6bfc4506438c1809c486f66217ad11eab78157192b3d5707b4e2f4212f6c/alibabacloud_tea_openapi-0.4.4-py3-none-any.whl", hash = "sha256:cea6bc1fe35b0319a8752cb99eb0ecb0dab7ca1a71b99c12970ba0867410995f", size = 26236, upload-time = "2026-03-26T10:16:15.861Z" }, +] + +[[package]] +name = "alibabacloud-tea-util" +version = "0.3.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-tea" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/ee/ea90be94ad781a5055db29556744681fc71190ef444ae53adba45e1be5f3/alibabacloud_tea_util-0.3.14.tar.gz", hash = "sha256:708e7c9f64641a3c9e0e566365d2f23675f8d7c2a3e2971d9402ceede0408cdb", size = 7515, upload-time = "2025-11-19T06:01:08.504Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/9e/c394b4e2104766fb28a1e44e3ed36e4c7773b4d05c868e482be99d5635c9/alibabacloud_tea_util-0.3.14-py3-none-any.whl", hash = "sha256:10d3e5c340d8f7ec69dd27345eb2fc5a1dab07875742525edf07bbe86db93bfe", size = 6697, upload-time = "2025-11-19T06:01:07.355Z" }, +] + +[[package]] +name = "alibabacloud-tea-xml" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-tea" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/eb/5e82e419c3061823f3feae9b5681588762929dc4da0176667297c2784c1a/alibabacloud_tea_xml-0.0.3.tar.gz", hash = "sha256:979cb51fadf43de77f41c69fc69c12529728919f849723eb0cd24eb7b048a90c", size = 3466, upload-time = "2025-07-01T08:04:55.144Z" } + +[[package]] +name = "alibabacloud-vpc20160428" +version = "6.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alibabacloud-endpoint-util" }, + { name = "alibabacloud-openapi-util" }, + { name = "alibabacloud-tea-openapi" }, + { name = "alibabacloud-tea-util" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/27/1d8dcfd832d5a21390347002cc69999adfb5c7e0a832345209c56e9f504c/alibabacloud_vpc20160428-6.13.0.tar.gz", hash = "sha256:daf00679a83d422799f9fcf263739fe1f360641675843cbfbe623833fc8b1681", size = 465701, upload-time = "2025-11-03T17:59:28.327Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b9/ad80faf7ac76c925e1b3fa766d1148effab3418cbf9a004eefa03e6e068e/alibabacloud_vpc20160428-6.13.0-py3-none-any.whl", hash = "sha256:933cf1e74322a20a2df27ca6323760d857744a4246eeadc9fb3eae01322fb1c6", size = 472137, upload-time = "2025-11-03T17:59:26.686Z" }, +] + +[[package]] +name = "alive-progress" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "about-time" }, + { name = "graphemeu" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/26/d43128764a6f8fe1668c4f87aba6b1fe52bea81d05a35c84a70d3c70b6f7/alive-progress-3.3.0.tar.gz", hash = "sha256:457dd2428b48dacd49854022a46448d236a48f1b7277874071c39395307e830c", size = 116281, upload-time = "2025-07-20T02:10:39.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/85/ec72f6c885703d18f3b09769645e950e14c7d0cc0a0e35d94127983f666f/alive_progress-3.3.0-py3-none-any.whl", hash = "sha256:63dd33bb94cde15ad9e5b666dbba8fedf71b72a4935d6fb9a92931e69402c9ff", size = 78403, upload-time = "2025-07-20T02:10:37.318Z" }, +] + +[[package]] +name = "aliyun-log-fastpb" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/ce/be2bc51121ab88ba9fd9d1f743a5bc2402036a0c4fe88d658d70bf03618e/aliyun_log_fastpb-0.2.0.tar.gz", hash = "sha256:91c714e76fb941c9a0db6b1aa1f4c56cb1626254ff5444c1179860f5e5b63d93", size = 16668, upload-time = "2025-11-18T02:22:13.115Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/68/8456bfeb892e08f9abf676218995a3efed24ed41612bca8538c84e2de556/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", size = 388912, upload-time = "2025-11-18T02:21:54.508Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e5/0a2aefe7e8ed364ae136896fd8f1a77b93b8b30a4eab7a642675a619f296/aliyun_log_fastpb-0.2.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d2984aafc61ccbbf1db2589ce90b6d5a26e72dba137fb1fdf7f61ce3faa967c0", size = 199594, upload-time = "2025-11-18T02:21:55.896Z" }, + { url = "https://files.pythonhosted.org/packages/01/62/d5e1beb5d5b964387e665fad710ce917b849e23e00cac643d931d26f8274/aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:181fc61ac9934f58b0880fa5617a4a4dc709dba09f8be95b5a71e828f2e48053", size = 208736, upload-time = "2025-11-18T02:21:57.19Z" }, + { url = "https://files.pythonhosted.org/packages/58/30/9b7585e4cb5bb7fb5e7ea257e98771b5846f55e1d79d401c92f7eee6232d/aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12b8bfddf0bc5450f16f1954c6387a73da124fae10d1205a17a0117e66bb56db", size = 215661, upload-time = "2025-11-18T02:21:58.528Z" }, + { url = "https://files.pythonhosted.org/packages/dd/24/9196b789addf90760b9eda18f080bad6d0b5c0855b680d35931cab1c5bea/aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8fbc83cbaa51d332e5e68871c1200014f1f3de54a8cba4fb55a634ee145cd4e4", size = 344569, upload-time = "2025-11-18T02:21:59.792Z" }, + { url = "https://files.pythonhosted.org/packages/17/b8/7266923774c406b78aff6bd5e5f3d1f86b0d1ff469e591bc3b5c113e8699/aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a86a6e11dd227d595fa23f69d30588446af19d045d1003bd1b66b5c9a55485", size = 339922, upload-time = "2025-11-18T02:22:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f6/8e8ff4a696ee0232c8bb5de90136cf70be5897f3f6dbdeae449134393d02/aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd92c0b84ba300c1d1c227204c5f2fff243cea80bc3f9399293385e87c82ee3e", size = 241647, upload-time = "2025-11-18T02:22:02.291Z" }, + { url = "https://files.pythonhosted.org/packages/56/2c/02696108e224741a708d1812bfd11ab36c7c5743175e227b574e2cb7aef2/aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c07a6d81a3eab6666949240da305236ed2350c305154d7e39fcc121fc52291", size = 221910, upload-time = "2025-11-18T02:22:03.606Z" }, + { url = "https://files.pythonhosted.org/packages/f2/de/19b70b6fca8892e2d82ca8d7231378cf16f3158b6d8486484a8ca3bd5dc9/aliyun_log_fastpb-0.2.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cff4fbdd0edff94adcee1dcabf16daacb5d336a12fc897887aa6e4f0ad25152", size = 238372, upload-time = "2025-11-18T02:22:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/62/f3/50200a879a4259efe6c562252ac6413036009efdfd83c6369a67d9f67fdb/aliyun_log_fastpb-0.2.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5a451809e2a062accbb8dae8750e507e58806e4a8da48d69215cdeef428e9d63", size = 391304, upload-time = "2025-11-18T02:22:06.042Z" }, + { url = "https://files.pythonhosted.org/packages/5f/24/17e26a16a9e147331dc161e5e3f2f327502e693eb15f229232c479ea990a/aliyun_log_fastpb-0.2.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:61f09df30232f1f5628d13310cf0e175171399ea1c75a8470e9f9d97b045bfb5", size = 484348, upload-time = "2025-11-18T02:22:07.034Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a5/0daafb81d37e69981651722406eacf2e302d57fe787002b3e3d816cf411d/aliyun_log_fastpb-0.2.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:a5fbf0d41d8c0c964a3dc8dd0ee2e732f876b803e0ed3432550ef3b84dde84f1", size = 422952, upload-time = "2025-11-18T02:22:08.47Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/5c715a1af59249485ea703ee4d34653e5403c8d9f50ae6fc80286fd7e9d8/aliyun_log_fastpb-0.2.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ae2f84ed0777e00045791044a56413f370afbd5b061505f5ded540c04b19c58e", size = 392587, upload-time = "2025-11-18T02:22:09.634Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/fdb216167c825878456e69937433db14519c0513a9c450c6e0c200abb170/aliyun_log_fastpb-0.2.0-cp37-abi3-win32.whl", hash = "sha256:967f9656c805602fd9be07d8c2756ad89204c852c99689c3c71aa035416ef42a", size = 112174, upload-time = "2025-11-18T02:22:10.643Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/013c773af65875ed74876197d016f6fb58dd4c14348462546de96ff8ff8c/aliyun_log_fastpb-0.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:bbdcf7b85f0f3437c2a8e8a1db0ef5584d21468b7c7a358269a4c651c84f4a54", size = 117948, upload-time = "2025-11-18T02:22:11.99Z" }, +] + +[[package]] +name = "amqp" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "applicationinsights" +version = "0.11.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/f2/46a75ac6096d60da0e71a068015b610206e697de01fa2fb5bba8564b0798/applicationinsights-0.11.10.tar.gz", hash = "sha256:0b761f3ef0680acf4731906dfc1807faa6f2a57168ae74592db0084a6099f7b3", size = 44722, upload-time = "2021-04-22T23:22:45.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/0d/cb6b23164eb55eebaa5f9f302dfe557cfa751bd7b2779863f1abd0343b6b/applicationinsights-0.11.10-py2.py3-none-any.whl", hash = "sha256:e89a890db1c6906b6a7d0bcfd617dac83974773c64573147c8d6654f9cf2a6ea", size = 55068, upload-time = "2021-04-22T23:22:44.451Z" }, +] + +[[package]] +name = "apscheduler" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, +] + +[[package]] +name = "argcomplete" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/be/6c23d80cb966fb8f83fb1ebfb988351ae6b0554d0c3a613ee4531c026597/argcomplete-3.5.3.tar.gz", hash = "sha256:c12bf50eded8aebb298c7b7da7a5ff3ee24dffd9f5281867dfe1424b58c55392", size = 72999, upload-time = "2024-12-31T19:22:57.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/08/2a4db06ec3d203124c967fc89295e85a202e5cbbcdc08fd6a64b65217d1e/argcomplete-3.5.3-py3-none-any.whl", hash = "sha256:2ab2c4a215c59fd6caaff41a869480a23e8f6a5f910b266c1808037f4e375b61", size = 43569, upload-time = "2024-12-31T19:22:54.305Z" }, +] + +[[package]] +name = "asgiref" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/b9/4db2509eabd14b4a8c71d1b24c8d5734c52b8560a7b1e1a8b56c8d25568b/asgiref-3.11.0.tar.gz", hash = "sha256:13acff32519542a1736223fb79a715acdebe24286d98e8b164a73085f40da2c4", size = 37969, upload-time = "2025-11-19T15:32:20.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, +] + +[[package]] +name = "astroid" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/53/1067e1113ecaf58312357f2cd93063674924119d80d173adc3f6f2387aa2/astroid-3.2.4.tar.gz", hash = "sha256:0e14202810b30da1b735827f78f5157be2bbd4a7a59b7707ca0bfc2fb4c0063a", size = 397576, upload-time = "2024-07-20T12:57:43.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/96/b32bbbb46170a1c8b8b1f28c794202e25cfe743565e9d3469b8eb1e0cc05/astroid-3.2.4-py3-none-any.whl", hash = "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25", size = 276348, upload-time = "2024-07-20T12:57:40.886Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "autopep8" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycodestyle" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/d8/30873d2b7b57dee9263e53d142da044c4600a46f2d28374b3e38b023df16/autopep8-2.3.2.tar.gz", hash = "sha256:89440a4f969197b69a995e4ce0661b031f455a9f776d2c5ba3dbd83466931758", size = 92210, upload-time = "2025-01-14T14:46:18.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/43/53afb8ba17218f19b77c7834128566c5bbb100a0ad9ba2e8e89d089d7079/autopep8-2.3.2-py2.py3-none-any.whl", hash = "sha256:ce8ad498672c845a0c3de2629c15b635ec2b05ef8177a6e7c91c74f3e9b51128", size = 45807, upload-time = "2025-01-14T14:46:15.466Z" }, +] + +[[package]] +name = "awsipranges" +version = "0.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/2e/6efa95f995369da828715f41705686cd214b9259ed758266942553d40441/awsipranges-0.3.3.tar.gz", hash = "sha256:4f0b3f22a9dc1163c85b513bed812b6c92bdacd674e6a7b68252a3c25b99e2c0", size = 16739, upload-time = "2022-02-10T21:08:32.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/ce/5c9a8bf91bdc9592a409c99e58fd99f2727ab8d634719c0ad796021b76d7/awsipranges-0.3.3-py3-none-any.whl", hash = "sha256:f3d7a54aeaf7fe310beb5d377a4034a63a51b72677ae6af3e0967bc4de7eedaf", size = 18106, upload-time = "2022-02-10T21:08:31.497Z" }, +] + +[[package]] +name = "azure-cli-core" +version = "2.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "azure-cli-telemetry" }, + { name = "azure-core" }, + { name = "azure-mgmt-core" }, + { name = "cryptography" }, + { name = "distro", marker = "sys_platform == 'linux'" }, + { name = "humanfriendly" }, + { name = "jmespath" }, + { name = "knack" }, + { name = "microsoft-security-utilities-secret-masker" }, + { name = "msal" }, + { name = "msal", extra = ["broker"], marker = "sys_platform == 'win32'" }, + { name = "msal-extensions" }, + { name = "packaging" }, + { name = "pkginfo" }, + { name = "psutil", marker = "sys_platform != 'cygwin'" }, + { name = "py-deviceid" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "pyopenssl" }, + { name = "requests", extra = ["socks"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/c2/2bde346a14db05ef5e5a6b1c0660ab507683b40682c79017f7bbcaef1efa/azure_cli_core-2.83.0.tar.gz", hash = "sha256:ac59ae4307a961891587d746984a3349b7afe9759ed8267e1cdd614aeeeabbf9", size = 231971, upload-time = "2026-02-03T03:43:28.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/e9/d74bfd65e7bb491c985ff9a769c9d6593e94599b32c82b716f6fddbfb40a/azure_cli_core-2.83.0-py3-none-any.whl", hash = "sha256:3136f1434cb6fbd2f5b1d7f82b15cff3d4ba4a638808a86584376a829fd26b8a", size = 262490, upload-time = "2026-02-03T03:43:33.385Z" }, +] + +[[package]] +name = "azure-cli-telemetry" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "applicationinsights" }, + { name = "portalocker" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/1d/c1cf3663391a271864d866e17f911b913ec257386aef7cac35121ed180a6/azure-cli-telemetry-1.1.0.tar.gz", hash = "sha256:d922379cda1b48952be75fb3bd2ac5e7ceecf569492a6088bab77894c624a278", size = 9233, upload-time = "2023-08-01T02:15:31.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/23/5940808a891282e7fdd240d689aedc91bc945b4eb653c15e694f45a4babc/azure_cli_telemetry-1.1.0-py3-none-any.whl", hash = "sha256:2fc12608c0cf0ea6e69b392af9cab92f1249340b8caff7e9674cf91b3becb337", size = 11538, upload-time = "2023-08-01T02:15:37.126Z" }, +] + +[[package]] +name = "azure-common" +version = "1.1.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/71/f6f71a276e2e69264a97ad39ef850dca0a04fce67b12570730cb38d0ccac/azure-common-1.1.28.zip", hash = "sha256:4ac0cd3214e36b6a1b6a442686722a5d8cc449603aa833f3f0f40bda836704a3", size = 20914, upload-time = "2022-02-03T19:39:44.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/55/7f118b9c1b23ec15ca05d15a578d8207aa1706bc6f7c87218efffbbf875d/azure_common-1.1.28-py2.py3-none-any.whl", hash = "sha256:5c12d3dcf4ec20599ca6b0d3e09e86e146353d443e7fcc050c9a19c1f9df20ad", size = 14462, upload-time = "2022-02-03T19:39:42.417Z" }, +] + +[[package]] +name = "azure-core" +version = "1.38.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/9b/23893febea484ad8183112c9419b5eb904773adb871492b5fa8ff7b21e09/azure_core-1.38.1.tar.gz", hash = "sha256:9317db1d838e39877eb94a2240ce92fa607db68adf821817b723f0d679facbf6", size = 363323, upload-time = "2026-02-11T02:03:06.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/88/aaea2ad269ce70b446660371286272c1f6ba66541a7f6f635baf8b0db726/azure_core-1.38.1-py3-none-any.whl", hash = "sha256:69f08ee3d55136071b7100de5b198994fc1c5f89d2b91f2f43156d20fcf200a4", size = 217930, upload-time = "2026-02-11T02:03:07.548Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/a1/f1a683672e7a88ea0e3119f57b6c7843ed52650fdcac8bfa66ed84e86e40/azure_identity-1.21.0.tar.gz", hash = "sha256:ea22ce6e6b0f429bc1b8d9212d5b9f9877bd4c82f1724bfa910760612c07a9a6", size = 266445, upload-time = "2025-03-11T20:53:07.463Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9f/1f9f3ef4f49729ee207a712a5971a9ca747f2ca47d9cbf13cf6953e3478a/azure_identity-1.21.0-py3-none-any.whl", hash = "sha256:258ea6325537352440f71b35c3dffe9d240eae4a5126c1b7ce5efd5766bd9fd9", size = 189190, upload-time = "2025-03-11T20:53:09.197Z" }, +] + +[[package]] +name = "azure-keyvault-certificates" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/bc/07497d7118178729f59043153083bcdfdfc9629a365cbc5bc5690b4ff21b/azure_keyvault_certificates-4.10.0.tar.gz", hash = "sha256:004ff47a73152f9f40f678e5a07719b753a3ca86f0460bfeaaf6a23304872e05", size = 179157, upload-time = "2025-06-16T22:52:24.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/03/71cfb1ee68a625ca1c211a3b18e1051a4dd9fe8774329e48bb3c67afcf86/azure_keyvault_certificates-4.10.0-py3-none-any.whl", hash = "sha256:fa76cbc329274cb5f4ab61b0ed7d209d44377df4b4d6be2fd01e741c2fbb83a9", size = 155683, upload-time = "2025-06-16T22:52:26.02Z" }, +] + +[[package]] +name = "azure-keyvault-keys" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/f9/85c95072c4f396126a8ae145ffb45fb3e7bea660b6cb8ff8b2f702944a57/azure_keyvault_keys-4.10.0.tar.gz", hash = "sha256:511206ae90aec1726a4d6ff5a92d754bd0c0f1e8751891368d30fb70b62955f1", size = 216961, upload-time = "2024-10-17T23:27:52.872Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/63/04fc6ab70fc30bae3448837411ded3c17bb1ace5bf3cfdf353961fe3f7fa/azure_keyvault_keys-4.10.0-py3-none-any.whl", hash = "sha256:210227e0061f641a79755f0e0bcbcf27bbfb4df630a933c43a99a29962283d0d", size = 153660, upload-time = "2024-10-17T23:27:55.213Z" }, +] + +[[package]] +name = "azure-keyvault-secrets" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/e5/3074e581b6e8923c4a1f2e42192ea6f390bb52de3600c68baaaed529ef05/azure_keyvault_secrets-4.10.0.tar.gz", hash = "sha256:666fa42892f9cee749563e551a90f060435ab878977c95265173a8246d546a36", size = 129695, upload-time = "2025-06-16T22:52:20.986Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/94/7c902e966b28e7cb5080a8e0dd6bffc22ba44bc907f09c4c633d2b7c4f6a/azure_keyvault_secrets-4.10.0-py3-none-any.whl", hash = "sha256:9dbde256077a4ee1a847646671580692e3f9bea36bcfc189c3cf2b9a94eb38b9", size = 125237, upload-time = "2025-06-16T22:52:22.489Z" }, +] + +[[package]] +name = "azure-mgmt-apimanagement" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/e4/3e536f0374bccb203886ef7aeb1148e063de6be31187034179ddcbf1adff/azure_mgmt_apimanagement-5.0.0.tar.gz", hash = "sha256:0ab7fe17e70fe3154cd840ff47d19d7a4610217003eaa7c21acf3511a6e57999", size = 625729, upload-time = "2025-04-22T06:24:05.364Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/d8/1406ff959601b1a93fb662b1a76067ecef7094680efcdd75c007457f9efd/azure_mgmt_apimanagement-5.0.0-py3-none-any.whl", hash = "sha256:b88c42a392333b60722fb86f15d092dfc19a8d67510dccd15c217381dff4e6ec", size = 1150262, upload-time = "2025-04-22T06:24:07.012Z" }, +] + +[[package]] +name = "azure-mgmt-applicationinsights" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/8b/f3c8886ecd90d440458c4cc2b1db4ff47215f189cc3c0ba3cf11b7d4d64e/azure_mgmt_applicationinsights-4.1.0.tar.gz", hash = "sha256:15531390f12ce3d767cd3f1949af36aa39077c145c952fec4d80303c86ec7b6c", size = 323696, upload-time = "2025-03-06T08:50:52.14Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/45/f9f050523282d642598396ce9880b7b8be0d41545985935d2134199393b2/azure_mgmt_applicationinsights-4.1.0-py3-none-any.whl", hash = "sha256:9e71f29b01e505a773501451d12fd6a10482cf4b13e9ac2bff72f5380496d979", size = 616137, upload-time = "2025-03-06T08:50:54.044Z" }, +] + +[[package]] +name = "azure-mgmt-authorization" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ab/e79874f166eed24f4456ce4d532b29a926fb4c798c2c609eefd916a3f73d/azure-mgmt-authorization-4.0.0.zip", hash = "sha256:69b85abc09ae64fc72975bd43431170d8c7eb5d166754b98aac5f3845de57dc4", size = 1134795, upload-time = "2023-07-25T04:47:46.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/b3/8ec1268082f4d20cc8bf723a1a8e6b9e330bcc338a4dbcee9c7737e9dc1c/azure_mgmt_authorization-4.0.0-py3-none-any.whl", hash = "sha256:d8feeb3842e6ddf1a370963ca4f61fb6edc124e8997b807dd025bc9b2379cd1a", size = 1072620, upload-time = "2023-07-25T04:47:49.26Z" }, +] + +[[package]] +name = "azure-mgmt-compute" +version = "34.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/3f/72e09a6f9a12d8afed8f56c929e8e142de21be9c19c27e4b2b94d60eb73a/azure_mgmt_compute-34.0.0.tar.gz", hash = "sha256:58cd01d025efa02870b84dbfb69834a3b23501a135658c03854d2434e8dfee1e", size = 1785981, upload-time = "2025-01-20T06:47:24.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/72/93e378927b59921c863a97c1586266857ccc6a4ee2c9904e43942c90b1ee/azure_mgmt_compute-34.0.0-py3-none-any.whl", hash = "sha256:f8f7b1c5c187a26fae4d1f099adf93561244242f28899484d9a42747bf0d5af4", size = 2010334, upload-time = "2025-01-20T06:47:26.861Z" }, +] + +[[package]] +name = "azure-mgmt-containerinstance" +version = "10.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/19/cdb22d87560238893f5c014176b4e6868c3befbd6585bb5c44bdb1ddc997/azure-mgmt-containerinstance-10.1.0.zip", hash = "sha256:78d437adb28574f448c838ed5f01f9ced378196098061deb59d9f7031704c17e", size = 106507, upload-time = "2023-04-21T03:30:50.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/11/3bfa586ac0514e1fe224879c065dbec39c0d9d2653b74549ac8080408f07/azure_mgmt_containerinstance-10.1.0-py3-none-any.whl", hash = "sha256:ee7977b7b70f2233e44ec6ce8c99027f3f7892bb3452b4bad46df340d9f98959", size = 87314, upload-time = "2023-04-21T03:30:52.379Z" }, +] + +[[package]] +name = "azure-mgmt-containerregistry" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/be/5806085a314e314ca5a53f5fa7ac87de55aad9bcde4c417b12d515edec8e/azure_mgmt_containerregistry-12.0.0.tar.gz", hash = "sha256:f19f8faa7881deaf2b5015c0eb050a92e2380cd9d18dee33cdb5f27d44a06c03", size = 720736, upload-time = "2025-02-24T06:14:57.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/d4/98a626a676b230d3bcbbf5aca38ef2c95063186093335a13ab399d6c5fac/azure_mgmt_containerregistry-12.0.0-py3-none-any.whl", hash = "sha256:464abd4d3d9ecc0456ed8f63a6b9b93afc2e3e194f2d34f26a758afb67ad3b5c", size = 1130225, upload-time = "2025-02-24T06:14:58.849Z" }, +] + +[[package]] +name = "azure-mgmt-containerservice" +version = "34.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/b0/a5a2d6c2b2f9f7404f86732d400786d7cd996a155467f47ecaf786ce56d9/azure_mgmt_containerservice-34.1.0.tar.gz", hash = "sha256:637a6cf8f06636c016ad151d76f9c7ba75bd05d4334b3dd7837eb8b517f30dbe", size = 319609, upload-time = "2025-02-19T04:08:41.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/c7/a03d8ea9827442be3f2310e857266e659c5c8aa516367654a4bf90dfe9ab/azure_mgmt_containerservice-34.1.0-py3-none-any.whl", hash = "sha256:1faa1714e0100c6ee4cfb8d2eadb1c270b548a84b0070c74e9fe646056a5cb12", size = 415971, upload-time = "2025-02-19T04:08:44.203Z" }, +] + +[[package]] +name = "azure-mgmt-core" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/99/fa9e7551313d8c7099c89ebf3b03cd31beb12e1b498d575aa19bb59a5d04/azure_mgmt_core-1.6.0.tar.gz", hash = "sha256:b26232af857b021e61d813d9f4ae530465255cb10b3dde945ad3743f7a58e79c", size = 30818, upload-time = "2025-07-03T02:02:24.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/26/c79f962fd3172b577b6f38685724de58b6b4337a51d3aad316a43a4558c6/azure_mgmt_core-1.6.0-py3-none-any.whl", hash = "sha256:0460d11e85c408b71c727ee1981f74432bc641bb25dfcf1bb4e90a49e776dbc4", size = 29310, upload-time = "2025-07-03T02:02:25.203Z" }, +] + +[[package]] +name = "azure-mgmt-cosmosdb" +version = "9.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/2a/3240e83aff38443d334a17467d32a46bab269164ab9477bb17d2277b32f8/azure_mgmt_cosmosdb-9.7.0.tar.gz", hash = "sha256:b5072d319f11953d8f12e22459aded1912d5f27e442e1d8b49596a85005410a1", size = 265676, upload-time = "2024-11-19T03:19:26.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/71/506877fd7a94f535444eaf6d110254150a7d1db525f0a8f1ae13b034ef5f/azure_mgmt_cosmosdb-9.7.0-py3-none-any.whl", hash = "sha256:be735a554d16995c8cefe413e62119985f8fabae1cb45a6f6ad2c3958bed14da", size = 389922, upload-time = "2024-11-19T03:19:28.39Z" }, +] + +[[package]] +name = "azure-mgmt-databricks" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/6d/b5e549a1287a4a0a2a194dcc4578c81af66111b8b18a73f4dbd434539b07/azure-mgmt-databricks-2.0.0.zip", hash = "sha256:70d11362dc2d17f5fb1db0cfe65c1af55b8f136f1a0db9a5b51e7acf760cf5b9", size = 109722, upload-time = "2023-06-29T03:03:15.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/ed/2c0af6f1f384e18f9bd7dec7124fc20fba9b9ab814d3223f29039c75fbd6/azure_mgmt_databricks-2.0.0-py3-none-any.whl", hash = "sha256:0c29434a7339e74231bd171a6c08dcdf8153abaebd332658d7f66b8ea143fa17", size = 97759, upload-time = "2023-06-29T03:03:17.565Z" }, +] + +[[package]] +name = "azure-mgmt-datafactory" +version = "9.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/7a/253f8d0b4c3971ad0f11408b80c3429dc75408834e219ed2f0d928b4a586/azure_mgmt_datafactory-9.2.0.tar.gz", hash = "sha256:5132e9c24c441ac225f2a60225924baa55079ca81eff7db99a70d661d64bb0d7", size = 484275, upload-time = "2025-04-21T04:21:28.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/dd/2e7b6bac7420a05bc4ad17cb64099948117b12ca50ea68d6d4665867085e/azure_mgmt_datafactory-9.2.0-py3-none-any.whl", hash = "sha256:d870a7a6099227e91d1c258a956c2aa32c2ea4c0a4409913d8f215887349f128", size = 554231, upload-time = "2025-04-21T04:21:30.956Z" }, +] + +[[package]] +name = "azure-mgmt-eventgrid" +version = "10.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/68/24a2921b66827a571a3da75cc12289b7029a7c436087c0c5b2b4ca387aa3/azure_mgmt_eventgrid-10.4.0.tar.gz", hash = "sha256:303e5e27cf4bb5ec833ba4e5a9ef70b5bc410e190412ec47cde59d82e413fb7e", size = 258796, upload-time = "2025-03-24T03:32:20.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/c7/6dfac325b4b081038aa3495e048740b44d0a68b7da97d922e0aa47a67351/azure_mgmt_eventgrid-10.4.0-py3-none-any.whl", hash = "sha256:5e4637245bbff33298d5f427971b870dbb03d873a3ef68f328190a7b7a38c56f", size = 353207, upload-time = "2025-03-24T03:32:22.847Z" }, +] + +[[package]] +name = "azure-mgmt-eventhub" +version = "11.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/41/5080742de3a0c7a2c579fcf6995c531d48a83f9c588b52334b41bd84d666/azure_mgmt_eventhub-11.2.0.tar.gz", hash = "sha256:31c47f18f73d2d83345cde5909568e28858c2548a35b10e23194b4767a9ce7e3", size = 626966, upload-time = "2025-01-21T07:06:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/a0/1142295958af5531f394662adda09ec49f0583f04475b977b4b22373336a/azure_mgmt_eventhub-11.2.0-py3-none-any.whl", hash = "sha256:a7e2618eca58d8e52c7ff7d4a04a4fae12685351746e6d01b933b43e7ea3b906", size = 1054754, upload-time = "2025-01-21T07:06:49.056Z" }, +] + +[[package]] +name = "azure-mgmt-keyvault" +version = "10.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/c9/c9cd047729de3996656da854e361636dafa4f5e9b35af449abe23ec75582/azure-mgmt-keyvault-10.3.1.tar.gz", hash = "sha256:34b92956aefbdd571cae5a03f7078e037d8087b2c00cfa6748835dc73abb5a30", size = 512188, upload-time = "2024-07-17T03:33:27.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/e8/45dee15ba05ecd27891bea507ac39121463881620c7eb09240955bdbd03b/azure_mgmt_keyvault-10.3.1-py3-none-any.whl", hash = "sha256:a18a27a06551482d31f92bc43ac8b0846af02cd69511f80090865b4c5caa3c21", size = 901390, upload-time = "2024-07-17T03:33:29.878Z" }, +] + +[[package]] +name = "azure-mgmt-loganalytics" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "msrest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/66/99802a1711cadb1c41e322dd42aaa3acfb58c70aba13e0f0cdbead21e6c4/azure-mgmt-loganalytics-12.0.0.zip", hash = "sha256:da128a7e0291be7fa2063848df92a9180cf5c16d42adc09d2bc2efd711536bfb", size = 179483, upload-time = "2021-11-17T02:17:43.918Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8a/8fcbd6f7549f4789a00cbe523d9e4bcc9f686aaeea3a6c9fb47e6c90cac1/azure_mgmt_loganalytics-12.0.0-py2.py3-none-any.whl", hash = "sha256:75ac1d47dd81179905c40765be8834643d8994acff31056ddc1863017f3faa02", size = 146191, upload-time = "2021-11-17T02:17:42.023Z" }, +] + +[[package]] +name = "azure-mgmt-logic" +version = "10.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "msrest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/89/47e9b71d49ee05782cebcf3a9e4b04e974c76b19f4b2d68fd9ea9c4e358a/azure-mgmt-logic-10.0.0.zip", hash = "sha256:b3fa4864f14aaa7af41d778d925f051ed29b6016f46344765ecd0f49d0f04dd6", size = 250261, upload-time = "2022-06-13T01:38:29.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/66/0d8ae9ca4d75e57746026a1f9a10a7e25029511c128cf20166fce516bda9/azure_mgmt_logic-10.0.0-py3-none-any.whl", hash = "sha256:525c78afedf3edb35eb0a16152c8beba89769ee1bc6af01bcdc42842a551e443", size = 235433, upload-time = "2022-06-13T01:38:27.333Z" }, +] + +[[package]] +name = "azure-mgmt-monitor" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/31/ebabafe0be1a177428880a8ec0fc44d681ac9dc1ae66a70d859cb5c7fbc3/azure-mgmt-monitor-6.0.2.tar.gz", hash = "sha256:5ffbf500e499ab7912b1ba6d26cef26480d9ae411532019bb78d72562196e07b", size = 760481, upload-time = "2023-08-22T08:31:30.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/47/82ccbdb757da87c3c00d9ca0a7b7a28ab95f8dab040d1877bb15bf1c1e38/azure_mgmt_monitor-6.0.2-py3-none-any.whl", hash = "sha256:fe4cf41e6680b74a228f81451dc5522656d599c6f343ecf702fc790fda9a357b", size = 1305191, upload-time = "2023-08-22T08:31:35.989Z" }, +] + +[[package]] +name = "azure-mgmt-network" +version = "28.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/a3/8d2fa6e33107354c8cd2abcca4e0f02138bda4c6024984ae5fce5cf23b27/azure_mgmt_network-28.1.0.tar.gz", hash = "sha256:8c84bffb5ec75c6e0244e58ecf07c00d5fc421d616b0cb369c6fe585af33cf87", size = 651528, upload-time = "2024-12-20T05:56:54.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/68/d1604383635f1f1b16cd7f1e27004db40a1f0493c57b2da9fb36dc775a79/azure_mgmt_network-28.1.0-py3-none-any.whl", hash = "sha256:8ddb0e9ec8f10c9c152d60fc945908d113e4591f397ea3e40b92290ec2b01658", size = 575260, upload-time = "2024-12-20T05:56:57.672Z" }, +] + +[[package]] +name = "azure-mgmt-postgresqlflexibleservers" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/03/c3dacaff6551fdd482a13f4e4b3628ab9fdada888159ab9547ebc1763d7c/azure_mgmt_postgresqlflexibleservers-1.1.0.tar.gz", hash = "sha256:9ede9d8ba63e9d2879cb74adc903c649af3bc5460a02787287b0cd18d754af14", size = 115321, upload-time = "2025-03-25T07:31:55.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/ae/7bed1540285c20dc09bf794c91f242e0f9c1c6a0508910b669d1c4eabcb5/azure_mgmt_postgresqlflexibleservers-1.1.0-py3-none-any.whl", hash = "sha256:87ddb5a5e6d12c45769485d234cfe0322140e3a0a7636d0e61fb00ac544b5d20", size = 220299, upload-time = "2025-03-25T07:31:56.959Z" }, +] + +[[package]] +name = "azure-mgmt-rdbms" +version = "10.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "msrest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/3c/c1e03a11cf3dc2567ba947cc196d695d125d0d0e86af6731a7c067c5404a/azure-mgmt-rdbms-10.1.0.zip", hash = "sha256:a87d401c876c84734cdd4888af551e4a1461b4b328d9816af60cb8ac5979f035", size = 671167, upload-time = "2022-03-07T05:33:37.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/13/d39466bbbb1da667354aebe5329e575b6b211f40985e426cfb730d87b30f/azure_mgmt_rdbms-10.1.0-py3-none-any.whl", hash = "sha256:8eac17d1341a91d7ed914435941ba917b5ef1568acabc3e65653603966a7cc88", size = 639668, upload-time = "2022-03-07T05:33:34.171Z" }, +] + +[[package]] +name = "azure-mgmt-recoveryservices" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/52/b1b2047ffa71cda33250e5f2286966ed805feeff58f763e771fbd855a1e6/azure_mgmt_recoveryservices-3.1.0.tar.gz", hash = "sha256:7f2db98401708cf145322f50bc491caf7967bec4af3bf7b0984b9f07d3092687", size = 73743, upload-time = "2025-06-05T02:57:29.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/db/6282d6bcffc1816752a784f819d2ac3b3df8ecc24c52dbfb45a4e864c798/azure_mgmt_recoveryservices-3.1.0-py3-none-any.whl", hash = "sha256:21c58afdf4ae66806783e95f8cd17e3bec31be7178c48784db21f0b05de7fa66", size = 109722, upload-time = "2025-06-05T02:57:30.522Z" }, +] + +[[package]] +name = "azure-mgmt-recoveryservicesbackup" +version = "9.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/28/99997bb991c8d1d53ec1164a4f07adc520e3c10c55b7e0b814f6e6c6043e/azure_mgmt_recoveryservicesbackup-9.2.0.tar.gz", hash = "sha256:c402b3e22a6c3879df56bc37e0063142c3352c5102599ff102d19824f1b32b29", size = 318311, upload-time = "2025-04-17T08:14:51.188Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/4e/87175be17d65e6f4c0419b35d36f6df0da8e8331982eaf8cd2f14cb6bfb3/azure_mgmt_recoveryservicesbackup-9.2.0-py3-none-any.whl", hash = "sha256:c0002858d0166b6a10189a1fd580a49c83dc31b111e98010a5b2ea0f767dfff1", size = 576737, upload-time = "2025-04-17T08:14:52.957Z" }, +] + +[[package]] +name = "azure-mgmt-resource" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/4c/b27a3dfbedebbcc8e346a956a803528bd94a19fdf14b1de4bd781b03a6cc/azure_mgmt_resource-24.0.0.tar.gz", hash = "sha256:cf6b8995fcdd407ac9ff1dd474087129429a1d90dbb1ac77f97c19b96237b265", size = 3030022, upload-time = "2025-06-17T08:04:01.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/18/f047cb553dad6fdb65c625c4fe48552e043c4e9a859416a70c5047d07475/azure_mgmt_resource-24.0.0-py3-none-any.whl", hash = "sha256:27b32cd223e2784269f5a0db3c282042886ee4072d79cedc638438ece7cd0df4", size = 3613790, upload-time = "2025-06-17T08:04:04.046Z" }, +] + +[[package]] +name = "azure-mgmt-search" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/b7/b9431f1ab621f83849f3ace5ba9d2820c731409fce8466b5f06d330d19f4/azure-mgmt-search-9.1.0.tar.gz", hash = "sha256:53bc6eeadb0974d21f120bb21bb5e6827df6d650e17347460fd83e2d68883599", size = 76258, upload-time = "2023-10-25T07:53:00.328Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/17/1a8fc321662acffb784914dfd916de842c4e2d6724bd87f7bd9202166f15/azure_mgmt_search-9.1.0-py3-none-any.whl", hash = "sha256:488ff81477e980e2b7abf0b857387c74ebbad419e6f6126044e3e6fad2da72b6", size = 110342, upload-time = "2023-10-25T07:53:02.318Z" }, +] + +[[package]] +name = "azure-mgmt-security" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/90/13186657355452bdce44f27db6b194b99f78f8c185301b47624fff6d9531/azure-mgmt-security-7.0.0.tar.gz", hash = "sha256:5912eed7e9d3758fdca8d26e1dc26b41943dc4703208a1184266e2c252e1ad66", size = 684730, upload-time = "2024-05-20T05:23:17.392Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/d3/9541d871294dbc7c1eba3ffc9d4eb54cb651005ddbb1513a17f2a795bdfe/azure_mgmt_security-7.0.0-py3-none-any.whl", hash = "sha256:85a6d8b7a5cd74884a548ed53fed034449f54a9989edd64e9020c5837db96933", size = 1397437, upload-time = "2024-05-20T05:23:19.991Z" }, +] + +[[package]] +name = "azure-mgmt-sql" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "msrest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/af/398c57d15064ea23475076cd087b1a143b66d33a029e6e47c4688ca32310/azure-mgmt-sql-3.0.1.zip", hash = "sha256:129042cc011225e27aee6ef2697d585fa5722e5d1aeb0038af6ad2451a285457", size = 996625, upload-time = "2021-07-16T02:07:56.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/46/db6dd21a237c32eb747c4a1790a6b0aafe1f52c55e07b90f732231027d47/azure_mgmt_sql-3.0.1-py2.py3-none-any.whl", hash = "sha256:1d1dd940d4d41be4ee319aad626341251572a5bf4a2addec71779432d9a1381f", size = 912881, upload-time = "2021-07-16T02:07:53.079Z" }, +] + +[[package]] +name = "azure-mgmt-storage" +version = "22.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/e7/1f6a1384a77513c0d636ae411e169d22433a07fd146c8b8b7d6027f90dbb/azure_mgmt_storage-22.1.1.tar.gz", hash = "sha256:25aaa5ae8c40c30e2f91f8aae6f52906b0557e947d5c1b9817d4ff9decc11340", size = 370272, upload-time = "2025-03-01T01:32:45.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/e2/2b33808eb5c3d61e19ef44bb60b306cfda3c17d77629ba2648978b3202b6/azure_mgmt_storage-22.1.1-py3-none-any.whl", hash = "sha256:a4a4064918dcfa4f1cbebada5bf064935d66f2a3647a2f46a1f1c9348736f5d9", size = 569480, upload-time = "2025-03-01T01:32:47.576Z" }, +] + +[[package]] +name = "azure-mgmt-subscription" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "msrest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/67/14b19a006e13d86f05ee59faf78c39dc443d4fd6967344e9c94f688949c1/azure-mgmt-subscription-3.1.1.zip", hash = "sha256:4e255b4ce9b924357bb8c5009b3c88a2014d3203b2495e2256fa027bf84e800e", size = 96018, upload-time = "2022-09-06T07:30:49.467Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/12/e6de2021c4689f857386670ba0b6d3c4025d4209e45df7dd7cdabe9a4ac1/azure_mgmt_subscription-3.1.1-py3-none-any.whl", hash = "sha256:38d4574a8d47fa17e3587d756e296cb63b82ad8fb21cd8543bcee443a502bf48", size = 79409, upload-time = "2022-09-06T07:30:47.247Z" }, +] + +[[package]] +name = "azure-mgmt-synapse" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "msrest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/fa/5a7c375d305ec0ec06978db07ac34c4894d2b8a00087ff3dd9e1435e397f/azure-mgmt-synapse-2.0.0.zip", hash = "sha256:bec6bdfaeb55b4fdd159f2055e8875bf50a720bb0fce80a816e92a2359b898c8", size = 454745, upload-time = "2021-04-08T02:02:29.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/97/4ede2b4a0f53b9b692bf2853c320d8b94cb89253fd3eac6b6d0dc1f40047/azure_mgmt_synapse-2.0.0-py2.py3-none-any.whl", hash = "sha256:e901274009be843a7bf2eedeab32c0941fabb2addea9a1ad1560395073965f0f", size = 442173, upload-time = "2021-04-08T02:02:26.849Z" }, +] + +[[package]] +name = "azure-mgmt-web" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/55/5a24bc2d98830f0dc224e2baaf28b0091b7b646b390dc35c8234ae2f4830/azure_mgmt_web-8.0.0.tar.gz", hash = "sha256:c8d9c042c09db7aacb20270a9effed4d4e651e365af32d80897b84dc7bf35098", size = 2056394, upload-time = "2025-01-23T10:23:03.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/6f/aececaf10c925195424fe1d2a6734a2e92218875e1c4899c7a0ef3b79733/azure_mgmt_web-8.0.0-py3-none-any.whl", hash = "sha256:0536aac05bfc673b56ed930f2966b77856e84df675d376e782a7af6bb92449af", size = 2503582, upload-time = "2025-01-23T10:23:06.321Z" }, +] + +[[package]] +name = "azure-monitor-query" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/c0/e5c760f38224575f1eba35c319842f2be30fab599854ba9bd0b19d39c261/azure_monitor_query-2.0.0.tar.gz", hash = "sha256:7b05f2fcac4fb67fc9f77a7d4c5d98a0f3099fb73b57c69ec1b080773994671b", size = 86658, upload-time = "2025-07-30T22:23:41.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/0c/6b08a5a1e5f0bd97cefa13c53bf47f281a9a11732d19a94a86709acbc6bd/azure_monitor_query-2.0.0-py3-none-any.whl", hash = "sha256:8f52d581271d785e12f49cd5aaa144b8910fb843db2373855a7ef94c7fc462ea", size = 71102, upload-time = "2025-07-30T22:23:43.056Z" }, +] + +[[package]] +name = "azure-storage-blob" +version = "12.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/ff/f6e81d15687510d83a06cafba9ac38d17df71a2bb18f35a0fb169aee3af3/azure_storage_blob-12.24.1.tar.gz", hash = "sha256:052b2a1ea41725ba12e2f4f17be85a54df1129e13ea0321f5a2fcc851cbf47d4", size = 570523, upload-time = "2025-01-22T21:27:20.822Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/3c/3814aba90a63e84c7de0eb6fdf67bd1a9115ac5f99ec5b7a817a5d5278ec/azure_storage_blob-12.24.1-py3-none-any.whl", hash = "sha256:77fb823fdbac7f3c11f7d86a5892e2f85e161e8440a7489babe2195bf248f09e", size = 408432, upload-time = "2025-01-22T21:27:23.082Z" }, +] + +[[package]] +name = "azure-synapse-artifacts" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-common" }, + { name = "azure-mgmt-core" }, + { name = "isodate" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/b6/08aa179e85836089f541b8805e18e9eaca507dc2d8e608f5e9c2e893d4b3/azure_synapse_artifacts-0.21.0.tar.gz", hash = "sha256:d7e37516cf8569e03c604d921e3407d7140cf7523b67b67f757caf999e3c8ee7", size = 460511, upload-time = "2025-09-05T05:42:35.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d4/152923189ee93fc4d810b9fd84a8f97b4b80528d8e3beef0d12fb9e8d16e/azure_synapse_artifacts-0.21.0-py3-none-any.whl", hash = "sha256:3311919df13a2b42f1fb9debf5d512080c35d64d02b9f84ff944848835289a8d", size = 543285, upload-time = "2025-09-05T05:42:37.659Z" }, +] + +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + +[[package]] +name = "bandit" +version = "1.7.9" +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/1c/a4/ee391b0f046a6d8919eef246aed7c39849e299cc2e50d918b54add397de6/bandit-1.7.9.tar.gz", hash = "sha256:7c395a436743018f7be0a4cbb0a4ea9b902b6d87264ddecf8cfdc73b4f78ff61", size = 4225771, upload-time = "2024-06-12T22:25:04.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a3/05820b7ce584a1fa01d887ec5e3274bee9f9e02a53aa63de3cb1a5ad7d24/bandit-1.7.9-py3-none-any.whl", hash = "sha256:52077cb339000f337fb25f7e045995c4ad01511e716e5daac37014b9752de8ec", size = 127957, upload-time = "2024-06-12T22:25:01.214Z" }, +] + +[[package]] +name = "billiard" +version = "4.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/23/b12ac0bcdfb7360d664f40a00b1bda139cbbbced012c34e375506dbd0143/billiard-4.2.4.tar.gz", hash = "sha256:55f542c371209e03cd5862299b74e52e4fbcba8250ba611ad94276b369b6a85f", size = 156537, upload-time = "2025-11-30T13:28:48.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "boto3" +version = "1.40.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/f9/6ef8feb52c3cce5ec3967a535a6114b57ac7949fd166b0f3090c2b06e4e5/boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12", size = 111535, upload-time = "2025-10-28T19:26:57.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/24/3bf865b07d15fea85b63504856e137029b6acbc73762496064219cdb265d/boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c", size = 139321, upload-time = "2025-10-28T19:26:55.007Z" }, +] + +[[package]] +name = "botocore" +version = "1.40.61" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a3/81d3a47c2dbfd76f185d3b894f2ad01a75096c006a2dd91f237dca182188/botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd", size = 14393956, upload-time = "2025-10-28T19:26:46.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/c5/f6ce561004db45f0b847c2cd9b19c67c6bf348a82018a48cb718be6b58b0/botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7", size = 14055973, upload-time = "2025-10-28T19:26:42.15Z" }, +] + +[[package]] +name = "cartography" +version = "0.135.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "adal" }, + { name = "aioboto3" }, + { name = "azure-cli-core" }, + { name = "azure-identity" }, + { name = "azure-keyvault-certificates" }, + { name = "azure-keyvault-keys" }, + { name = "azure-keyvault-secrets" }, + { name = "azure-mgmt-authorization" }, + { name = "azure-mgmt-compute" }, + { name = "azure-mgmt-containerinstance" }, + { name = "azure-mgmt-containerservice" }, + { name = "azure-mgmt-cosmosdb" }, + { name = "azure-mgmt-datafactory" }, + { name = "azure-mgmt-eventgrid" }, + { name = "azure-mgmt-eventhub" }, + { name = "azure-mgmt-keyvault" }, + { name = "azure-mgmt-logic" }, + { name = "azure-mgmt-monitor" }, + { name = "azure-mgmt-network" }, + { name = "azure-mgmt-resource" }, + { name = "azure-mgmt-security" }, + { name = "azure-mgmt-sql" }, + { name = "azure-mgmt-storage" }, + { name = "azure-mgmt-synapse" }, + { name = "azure-mgmt-web" }, + { name = "azure-synapse-artifacts" }, + { name = "backoff" }, + { name = "boto3" }, + { name = "botocore" }, + { name = "cloudflare" }, + { name = "crowdstrike-falconpy" }, + { name = "cryptography" }, + { name = "dnspython" }, + { name = "duo-client" }, + { name = "google-api-python-client" }, + { name = "google-auth" }, + { name = "google-cloud-asset" }, + { name = "google-cloud-resource-manager" }, + { name = "httpx" }, + { name = "kubernetes" }, + { name = "marshmallow" }, + { name = "msgraph-sdk" }, + { name = "msrestazure" }, + { name = "neo4j" }, + { name = "oci" }, + { name = "okta" }, + { name = "packageurl-python" }, + { name = "packaging" }, + { name = "pagerduty" }, + { name = "policyuniverse" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-dateutil" }, + { name = "python-digitalocean" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "scaleway" }, + { name = "slack-sdk" }, + { name = "statsd" }, + { name = "typer" }, + { name = "types-aiobotocore-ecr" }, + { name = "workos" }, + { name = "xmltodict" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/47/606851d2403a983b63813b9e95427a5dd896e49bc5a501868c041262e9a5/cartography-0.135.0.tar.gz", hash = "sha256:3f500cd22c3b392d00e8b49f62acc95fd4dcd559ce514aafe2eb8101133c7a49", size = 9106458, upload-time = "2026-04-10T16:25:34.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/e1/99a26b3e662202be77961aba73338e1448623490710b81783e53a4bbef15/cartography-0.135.0-py3-none-any.whl", hash = "sha256:c62c32a6917b8f23a8b98fe2b6c7c4a918b50f55918482966c4dae1cf5f538e1", size = 1590545, upload-time = "2026-04-10T16:25:37.669Z" }, +] + +[[package]] +name = "celery" +version = "5.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "billiard" }, + { name = "click" }, + { name = "click-didyoumean" }, + { name = "click-plugins" }, + { name = "click-repl" }, + { name = "kombu" }, + { name = "python-dateutil" }, + { name = "tzlocal" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/9d/3d13596519cfa7207a6f9834f4b082554845eb3cd2684b5f8535d50c7c44/celery-5.6.2.tar.gz", hash = "sha256:4a8921c3fcf2ad76317d3b29020772103581ed2454c4c042cc55dcc43585009b", size = 1718802, upload-time = "2026-01-04T12:35:58.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/bd/9ecd619e456ae4ba73b6583cc313f26152afae13e9a82ac4fe7f8856bfd1/celery-5.6.2-py3-none-any.whl", hash = "sha256:3ffafacbe056951b629c7abcf9064c4a2366de0bdfc9fdba421b97ebb68619a5", size = 445502, upload-time = "2026-01-04T12:35:55.894Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "circuitbreaker" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/ac/de7a92c4ed39cba31fe5ad9203b76a25ca67c530797f6bb420fff5f65ccb/circuitbreaker-2.1.3.tar.gz", hash = "sha256:1a4baee510f7bea3c91b194dcce7c07805fe96c4423ed5594b75af438531d084", size = 10787, upload-time = "2025-03-31T08:12:08.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/34/15f08edd4628f65217de1fc3c1a27c82e46fe357d60c217fc9881e12ebcc/circuitbreaker-2.1.3-py3-none-any.whl", hash = "sha256:87ba6a3ed03fdc7032bc175561c2b04d52ade9d5faf94ca2b035fbdc5e6b1dd1", size = 7737, upload-time = "2025-03-31T08:12:07.802Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "click-didyoumean" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, +] + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "click-repl" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, +] + +[[package]] +name = "cloudflare" +version = "4.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/48/e481c0a9b9010a5c41b5ca78ff9fbe00dc8a9a4d39da5af610a4ec49c7f7/cloudflare-4.3.1.tar.gz", hash = "sha256:b1e1c6beeb8d98f63bfe0a1cba874fc4e22e000bcc490544f956c689b3b5b258", size = 1933187, upload-time = "2025-06-16T21:43:18.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/8f/c6c543565efd3144da4304efa5917aac06b6416a8663a6defe0e9b2b7569/cloudflare-4.3.1-py3-none-any.whl", hash = "sha256:6927135a5ee5633d6e2e1952ca0484745e933727aeeb189996d2ad9d292071c6", size = 4406465, upload-time = "2025-06-16T21:43:17.3Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "contextlib2" +version = "21.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/13/37ea7805ae3057992e96ecb1cffa2fa35c2ef4498543b846f90dd2348d8f/contextlib2-21.6.0.tar.gz", hash = "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869", size = 43795, upload-time = "2021-06-27T06:54:40.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/56/6d6872f79d14c0cb02f1646cbb4592eef935857c0951a105874b7b62a0c3/contextlib2-21.6.0-py2.py3-none-any.whl", hash = "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f", size = 13277, upload-time = "2021-06-27T06:54:20.972Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "coverage" +version = "7.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/05/31553dc038667012853d0a248b57987d8d70b2d67ea885605f87bcb1baba/coverage-7.5.4.tar.gz", hash = "sha256:a44963520b069e12789d0faea4e9fdb1e410cdc4aab89d94f7f55cbb7fef0353", size = 793238, upload-time = "2024-06-22T21:51:05.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/92/f56bf17b10efdb21311b7aa6853afc39eb962af0f9595a24408f7df3f694/coverage-7.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:db14f552ac38f10758ad14dd7b983dbab424e731588d300c7db25b6f89e335b5", size = 205211, upload-time = "2024-06-22T21:49:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/b8/69/a3bdace4d667f592b7730c0d636ac9ff9195f678fb4e61b5469b91e49919/coverage-7.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3257fdd8e574805f27bb5342b77bc65578e98cbc004a92232106344053f319ba", size = 205664, upload-time = "2024-06-22T21:49:39.702Z" }, + { url = "https://files.pythonhosted.org/packages/cd/bd/8515e955724baab11e8220a3872dc3d1c895b841b281ac8865834257ae2e/coverage-7.5.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a6612c99081d8d6134005b1354191e103ec9705d7ba2754e848211ac8cacc6b", size = 237695, upload-time = "2024-06-22T21:49:41.485Z" }, + { url = "https://files.pythonhosted.org/packages/41/d5/f4f9d2d86e3bd0c3ae761e2511c4033abcdce1de8f1926f8e7c98952540d/coverage-7.5.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d45d3cbd94159c468b9b8c5a556e3f6b81a8d1af2a92b77320e887c3e7a5d080", size = 235270, upload-time = "2024-06-22T21:49:43.578Z" }, + { url = "https://files.pythonhosted.org/packages/1e/62/e33595d35c9fa7cbcca5df2c3745b595532ec94b68c49ca2877629c4aca1/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", size = 236966, upload-time = "2024-06-22T21:49:46.006Z" }, + { url = "https://files.pythonhosted.org/packages/62/ea/e5ae9c845bef94369a3b9b66eb1e0857289c0a769b20078fcf5a5e6021be/coverage-7.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a892be37ca35eb5019ec85402c3371b0f7cda5ab5056023a7f13da0961e60da", size = 235891, upload-time = "2024-06-22T21:49:48.379Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/068a5d05ca6c89295bc8b7ae7ad5ed9d7b0286305a2444eb4d1eb42cb902/coverage-7.5.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8192794d120167e2a64721d88dbd688584675e86e15d0569599257566dec9bf0", size = 234548, upload-time = "2024-06-22T21:49:50.726Z" }, + { url = "https://files.pythonhosted.org/packages/15/a6/bbeeb4c0447a0ae8993e7d9b7ac8c8538ffb1a4210d106573238233f58c8/coverage-7.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:820bc841faa502e727a48311948e0461132a9c8baa42f6b2b84a29ced24cc078", size = 235329, upload-time = "2024-06-22T21:49:52.534Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/e009827b234225815743303d002a146183ea25e011c088dfa7a87f895fdf/coverage-7.5.4-cp311-cp311-win32.whl", hash = "sha256:6aae5cce399a0f065da65c7bb1e8abd5c7a3043da9dceb429ebe1b289bc07806", size = 207728, upload-time = "2024-06-22T21:49:54.482Z" }, + { url = "https://files.pythonhosted.org/packages/cd/48/8b929edd540634d8e7ed50d78e86790613e8733edf7eb21c2c217bf25176/coverage-7.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:d2e344d6adc8ef81c5a233d3a57b3c7d5181f40e79e05e1c143da143ccb6377d", size = 208614, upload-time = "2024-06-22T21:49:56.374Z" }, + { url = "https://files.pythonhosted.org/packages/6d/96/58bcb3417c2fd38fae862704599f7088451bb6c8786f5cec6887366e78d9/coverage-7.5.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:54317c2b806354cbb2dc7ac27e2b93f97096912cc16b18289c5d4e44fc663233", size = 205392, upload-time = "2024-06-22T21:49:58.102Z" }, + { url = "https://files.pythonhosted.org/packages/2c/63/4f781db529b585a6ef3860ea01390951b006dbea9ada4ea3a3d830e325f4/coverage-7.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:042183de01f8b6d531e10c197f7f0315a61e8d805ab29c5f7b51a01d62782747", size = 205634, upload-time = "2024-06-22T21:49:59.841Z" }, + { url = "https://files.pythonhosted.org/packages/57/50/c5aadf036078072f31d8f1ae1a6000cc70f3f6cf652939c2d77551174d77/coverage-7.5.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bb74ed465d5fb204b2ec41d79bcd28afccf817de721e8a807d5141c3426638", size = 238754, upload-time = "2024-06-22T21:50:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a6/57c42994b1686461c7b0b29de3b6d3d60c5f23a656f96460f9c755a31506/coverage-7.5.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3d45ff86efb129c599a3b287ae2e44c1e281ae0f9a9bad0edc202179bcc3a2e", size = 235783, upload-time = "2024-06-22T21:50:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/88/52/7054710a881b09d295e93b9889ac204c241a6847a8c05555fc6e1d8799d5/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", size = 237865, upload-time = "2024-06-22T21:50:07.507Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/57ef08c70483b83feb4e0d22345010aaf0afbe442dba015da3b173076c36/coverage-7.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1014fbf665fef86cdfd6cb5b7371496ce35e4d2a00cda501cf9f5b9e6fced69f", size = 237340, upload-time = "2024-06-22T21:50:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/44/465fa8f8edc11a18cbb83673f29b1af20ccf5139a66fbe2768ff67527ff0/coverage-7.5.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3684bc2ff328f935981847082ba4fdc950d58906a40eafa93510d1b54c08a66c", size = 235663, upload-time = "2024-06-22T21:50:11.852Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e5/829ddcfb29ad41661ba8e9cac7dc52100fd2c4853bb93d668a3ebde64862/coverage-7.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:581ea96f92bf71a5ec0974001f900db495488434a6928a2ca7f01eee20c23805", size = 237309, upload-time = "2024-06-22T21:50:14.195Z" }, + { url = "https://files.pythonhosted.org/packages/98/f6/f9c96fbf9b36be3f4d8c252ab2b4944420d99425f235f492784498804182/coverage-7.5.4-cp312-cp312-win32.whl", hash = "sha256:73ca8fbc5bc622e54627314c1a6f1dfdd8db69788f3443e752c215f29fa87a0b", size = 207988, upload-time = "2024-06-22T21:50:16.21Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c1/2b7c7dcf4c273aac7676f12fb2b5524b133671d731ab91bd9a41c21675b9/coverage-7.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:cef4649ec906ea7ea5e9e796e68b987f83fa9a718514fe147f538cfeda76d7a7", size = 208756, upload-time = "2024-06-22T21:50:18.147Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cron-descriptor" +version = "1.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/83/70bd410dc6965e33a5460b7da84cf0c5a7330a68d6d5d4c3dfdb72ca117e/cron_descriptor-1.4.5.tar.gz", hash = "sha256:f51ce4ffc1d1f2816939add8524f206c376a42c87a5fca3091ce26725b3b1bca", size = 30666, upload-time = "2024-08-24T18:16:48.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/20/2cfe598ead23a715a00beb716477cfddd3e5948cf203c372d02221e5b0c6/cron_descriptor-1.4.5-py3-none-any.whl", hash = "sha256:736b3ae9d1a99bc3dbfc5b55b5e6e7c12031e7ba5de716625772f8b02dcd6013", size = 50370, upload-time = "2024-08-24T18:16:46.783Z" }, +] + +[[package]] +name = "crowdstrike-falconpy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c6/81d4e3ac3b2f3119f3dbc5641454699dc88560eea4a640f8dd2b59c9d686/crowdstrike_falconpy-1.6.0.tar.gz", hash = "sha256:663402ac9bc56625478460b4865446371de2d74f5e96cb5d16672119c113346f", size = 9335668, upload-time = "2026-01-01T09:42:33.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/35/f957cc90c0cdfb19a26b6b458aee64010e422d48d0f294564e2aad09d28e/crowdstrike_falconpy-1.6.0-py3-none-any.whl", hash = "sha256:2cae39e42510f473e77f3a647d56aa8f55d9de0a1963bad353b195cb5ae61ea4", size = 1008463, upload-time = "2026-01-01T09:42:31.142Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "darabonba-core" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "alibabacloud-tea" }, + { name = "requests" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/d3/a7daaee544c904548e665829b51a9fa2572acb82c73ad787a8ff90273002/darabonba_core-1.0.5-py3-none-any.whl", hash = "sha256:671ab8dbc4edc2a8f88013da71646839bb8914f1259efc069353243ef52ea27c", size = 24580, upload-time = "2025-12-12T07:53:59.494Z" }, +] + +[[package]] +name = "dash" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "importlib-metadata" }, + { name = "nest-asyncio" }, + { name = "plotly" }, + { name = "requests" }, + { name = "retrying" }, + { name = "setuptools" }, + { name = "typing-extensions" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/7c/8569c50a4c07fabee6b2428a1515c0c92247e2127367b438a9f99d69723c/dash-3.1.1.tar.gz", hash = "sha256:916b31cec46da0a3339da0e9df9f446126aa7f293c0544e07adf9fe4ba060b18", size = 7544441, upload-time = "2025-06-30T15:31:30.518Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/df/267614cbc1003f6982d7078fd5c7591778f75e07bf36d4771fcb2eab8ff1/dash-3.1.1-py3-none-any.whl", hash = "sha256:66fff37e79c6aa114cd55aea13683d1e9afe0e3f96b35388baca95ff6cfdad23", size = 7885616, upload-time = "2025-06-30T15:31:22.768Z" }, +] + +[[package]] +name = "dash-bootstrap-components" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/8d/0f641e7c7878ac65b4bb78a2c7cb707db036f82da13fd61948adec44d5aa/dash_bootstrap_components-2.0.3.tar.gz", hash = "sha256:5c161b04a6e7ed19a7d54e42f070c29fd6c385d5a7797e7a82999aa2fc15b1de", size = 115466, upload-time = "2025-05-22T22:30:18.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/f6/b4652aacfbc8d684c9ca8efc5178860a50b54abf82cd1960013c59f8258f/dash_bootstrap_components-2.0.3-py3-none-any.whl", hash = "sha256:82754d3d001ad5482b8a82b496c7bf98a1c68d2669d607a89dda7ec627304af5", size = 203706, upload-time = "2025-05-22T22:30:16.304Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/56/c3baf5cbe4dd77427fd9aef99fcdade259ad128feeb8a786c246adb838e5/debugpy-1.8.20-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:eada6042ad88fa1571b74bd5402ee8b86eded7a8f7b827849761700aff171f1b", size = 2208318, upload-time = "2026-01-29T23:03:36.481Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7d/4fa79a57a8e69fe0d9763e98d1110320f9ecd7f1f362572e3aafd7417c9d/debugpy-1.8.20-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:7de0b7dfeedc504421032afba845ae2a7bcc32ddfb07dae2c3ca5442f821c344", size = 3171493, upload-time = "2026-01-29T23:03:37.775Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f2/1e8f8affe51e12a26f3a8a8a4277d6e60aa89d0a66512f63b1e799d424a4/debugpy-1.8.20-cp311-cp311-win32.whl", hash = "sha256:773e839380cf459caf73cc533ea45ec2737a5cc184cf1b3b796cd4fd98504fec", size = 5209240, upload-time = "2026-01-29T23:03:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/d5/92/1cb532e88560cbee973396254b21bece8c5d7c2ece958a67afa08c9f10dc/debugpy-1.8.20-cp311-cp311-win_amd64.whl", hash = "sha256:1f7650546e0eded1902d0f6af28f787fa1f1dbdbc97ddabaf1cd963a405930cb", size = 5233481, upload-time = "2026-01-29T23:03:40.659Z" }, + { url = "https://files.pythonhosted.org/packages/14/57/7f34f4736bfb6e00f2e4c96351b07805d83c9a7b33d28580ae01374430f7/debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d", size = 2550686, upload-time = "2026-01-29T23:03:42.023Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/b193a3975ca34458f6f0e24aaf5c3e3da72f5401f6054c0dfd004b41726f/debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b", size = 4310588, upload-time = "2026-01-29T23:03:43.314Z" }, + { url = "https://files.pythonhosted.org/packages/c1/55/f14deb95eaf4f30f07ef4b90a8590fc05d9e04df85ee379712f6fb6736d7/debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390", size = 5331372, upload-time = "2026-01-29T23:03:45.526Z" }, + { url = "https://files.pythonhosted.org/packages/a1/39/2bef246368bd42f9bd7cba99844542b74b84dacbdbea0833e610f384fee8/debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3", size = 5372835, upload-time = "2026-01-29T23:03:47.245Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "detect-secrets" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/67/382a863fff94eae5a0cf05542179169a1c49a4c8784a9480621e2066ca7d/detect_secrets-1.5.0.tar.gz", hash = "sha256:6bb46dcc553c10df51475641bb30fd69d25645cc12339e46c824c1e0c388898a", size = 97351, upload-time = "2024-05-06T17:46:19.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/5e/4f5fe4b89fde1dc3ed0eb51bd4ce4c0bca406246673d370ea2ad0c58d747/detect_secrets-1.5.0-py3-none-any.whl", hash = "sha256:e24e7b9b5a35048c313e983f76c4bd09dad89f045ff059e354f9943bf45aa060", size = 120341, upload-time = "2024-05-06T17:46:16.628Z" }, +] + +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "dj-rest-auth" +version = "7.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/19/00150c8bedf7b6d4c44ecf7c2be9e58ae2203b42741ca734152d34f549f1/dj-rest-auth-7.0.1.tar.gz", hash = "sha256:3f8c744cbcf05355ff4bcbef0c8a63645da38e29a0fdef3c3332d4aced52fb90", size = 220541, upload-time = "2025-01-04T23:37:38.688Z" } + +[package.optional-dependencies] +with-social = [ + { name = "django-allauth", extra = ["socialaccount"] }, +] + +[[package]] +name = "django" +version = "5.1.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/45/1ac68964193cfcc0b0912a0f68025d5bdb54f71ba7b8716e85b959874bd0/django-5.1.15.tar.gz", hash = "sha256:46a356b5ff867bece73fc6365e081f21c569973403ee7e9b9a0316f27d0eb947", size = 10719662, upload-time = "2025-12-02T14:01:31.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/79/372e091f0eba4ddb8228245ccd1baaa140e9658711f5e3a0056e540b4c1e/django-5.1.15-py3-none-any.whl", hash = "sha256:117871e58d6eda37f09870b7d73a3d66567b03aecd515b386b1751177c413432", size = 8260901, upload-time = "2025-12-02T14:01:27.352Z" }, +] + +[[package]] +name = "django-allauth" +version = "65.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/c1/d3385f4c3169c1d6eea3c63aed0f36af51478c1d72e46db12bb1a08f8034/django_allauth-65.15.0.tar.gz", hash = "sha256:b404d48cf0c3ee14dacc834c541f30adedba2ff1c433980ecc494d6cb0b395a8", size = 2215709, upload-time = "2026-03-09T13:51:28.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/b8/c8411339171bd8bc075c09ef190fb42195e9a2149e5c5026e094fe62fce0/django_allauth-65.15.0-py3-none-any.whl", hash = "sha256:ad9fc49c49a9368eaa5bb95456b76e2a4f377b3c6862ee8443507816578c098d", size = 2022994, upload-time = "2026-03-09T13:51:19.711Z" }, +] + +[package.optional-dependencies] +saml = [ + { name = "python3-saml" }, +] +socialaccount = [ + { name = "oauthlib" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] + +[[package]] +name = "django-celery-beat" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "celery" }, + { name = "cron-descriptor" }, + { name = "django" }, + { name = "django-timezone-field" }, + { name = "python-crontab" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/45/fc97bc1d9af8e7dc07f1e37044d9551a30e6793249864cef802341e2e3a8/django_celery_beat-2.9.0.tar.gz", hash = "sha256:92404650f52fcb44cf08e2b09635cb1558327c54b1a5d570f0e2d3a22130934c", size = 177667, upload-time = "2026-02-28T16:45:34.749Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/ae/9befa7ae37f5e5c41be636a254fcf47ff30dd5c88bd115070e252f6b9162/django_celery_beat-2.9.0-py3-none-any.whl", hash = "sha256:4a9e5ebe26d6f8d7215e1fc5c46e466016279dc102435a28141108649bdf2157", size = 105013, upload-time = "2026-02-28T16:45:32.822Z" }, +] + +[[package]] +name = "django-celery-results" +version = "2.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "celery" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/b5/9966c28e31014c228305e09d48b19b35522a8f941fe5af5f81f40dc8fa80/django_celery_results-2.6.0.tar.gz", hash = "sha256:9abcd836ae6b61063779244d8887a88fe80bbfaba143df36d3cb07034671277c", size = 83985, upload-time = "2025-04-10T08:23:52.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/da/70f0f3c5364735344c4bc89e53413bcaae95b4fc1de4e98a7a3b9fb70c88/django_celery_results-2.6.0-py3-none-any.whl", hash = "sha256:b9ccdca2695b98c7cbbb8dea742311ba9a92773d71d7b4944a676e69a7df1c73", size = 38351, upload-time = "2025-04-10T08:23:49.965Z" }, +] + +[[package]] +name = "django-cors-headers" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/34/f0c7a7241f885cbfc99b1edef0acc7915dd7a3fb749fe27de5e8a9fb2ccb/django_cors_headers-4.4.0.tar.gz", hash = "sha256:92cf4633e22af67a230a1456cb1b7a02bb213d6536d2dcb2a4a24092ea9cebc2", size = 21151, upload-time = "2024-06-19T16:16:45.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/0c/4201d5650199b3a36ef3f2ab91f44c4527a70685f3003ce9f3ed8c30780c/django_cors_headers-4.4.0-py3-none-any.whl", hash = "sha256:5c6e3b7fe870876a1efdfeb4f433782c3524078fa0dc9e0195f6706ce7a242f6", size = 12789, upload-time = "2024-06-19T16:16:42.189Z" }, +] + +[[package]] +name = "django-environ" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/0b/f2c024529ee4bbf8b95176eebeb86c6e695192a9ce0e91059cb83a33c1d3/django-environ-0.11.2.tar.gz", hash = "sha256:f32a87aa0899894c27d4e1776fa6b477e8164ed7f6b3e410a62a6d72caaf64be", size = 54326, upload-time = "2023-09-01T21:03:02.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/f1/468b49cccba3b42dda571063a14c668bb0b53a1d5712426d18e36663bd53/django_environ-0.11.2-py2.py3-none-any.whl", hash = "sha256:0ff95ab4344bfeff693836aa978e6840abef2e2f1145adff7735892711590c05", size = 19141, upload-time = "2023-09-01T21:02:59.88Z" }, +] + +[[package]] +name = "django-filter" +version = "24.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/bc/dc19ae39c235332926dd0efe0951f663fa1a9fc6be8430737ff7fd566b20/django_filter-24.3.tar.gz", hash = "sha256:d8ccaf6732afd21ca0542f6733b11591030fa98669f8d15599b358e24a2cd9c3", size = 144444, upload-time = "2024-08-02T13:27:58.132Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/b1/92f1c30b47c1ebf510c35a2ccad9448f73437e5891bbd2b4febe357cc3de/django_filter-24.3-py3-none-any.whl", hash = "sha256:c4852822928ce17fb699bcfccd644b3574f1a2d80aeb2b4ff4f16b02dd49dc64", size = 95011, upload-time = "2024-08-02T13:27:55.616Z" }, +] + +[[package]] +name = "django-guid" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/12/629715b6aefc76b3626934614e5139e7cf4be76e3a5cc07dedb57416fdd2/django_guid-3.5.0.tar.gz", hash = "sha256:5f32f70287e4f36addc79f29f2a7b2f56fc5f4e4cfb2023141525be8baa35d9e", size = 14111, upload-time = "2024-04-25T15:48:53.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/34/2a8c3635e096ddc311c264a8aa34afb7df7118b872dfc06b56b22ea476e5/django_guid-3.5.0-py3-none-any.whl", hash = "sha256:28f52cfeac47e8e22ea889a3845bc2b1c604dd842e495dadd44ad5184db72c76", size = 19686, upload-time = "2024-04-25T15:48:51.619Z" }, +] + +[[package]] +name = "django-postgres-extra" +version = "2.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/b0/0275caa2ced7a3eae3ccbe7fefcc062374e76edfcd2bf882840069f08ea3/django_postgres_extra-2.0.9.tar.gz", hash = "sha256:9e47c436d033712d0e2611b8c1583566dd4f97700e5360001bf3623913a92046", size = 92055, upload-time = "2025-07-11T07:19:40.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/0d/2e611d8977b6dad077296b6880e8ee2d7d320e55f834601c6ad5eb87b982/django_postgres_extra-2.0.9-py3-none-any.whl", hash = "sha256:0a4f9fd7f843e2ef5cfe7a291fcb883bd72d2ca8e4b369d0070866205e00b404", size = 90512, upload-time = "2025-07-11T07:19:39.035Z" }, +] + +[[package]] +name = "django-silk" +version = "5.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "autopep8" }, + { name = "django" }, + { name = "gprof2dot" }, + { name = "sqlparse" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/ac0bf2f1bba661a9f8937bccd0b8dd93ba87b6b1ebbf3884981f5e27c2b0/django_silk-5.3.2.tar.gz", hash = "sha256:b0db54eebedb8d16f572321bd6daccac0bd3f547ae2618bb45d96fe8fc02229d", size = 4493706, upload-time = "2024-12-14T08:06:03.225Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/4d/92872928197099aefad0a3e4a055e6c22295e7770dd7b3388a6f2909c95c/django_silk-5.3.2-py3-none-any.whl", hash = "sha256:49f1caebfda28b1707f0cfef524e0476beb82b8c5e40f5ccff7f73a6b4f6d3ac", size = 1943114, upload-time = "2024-12-14T08:06:21.512Z" }, +] + +[[package]] +name = "django-timezone-field" +version = "7.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/05/9b93a66452cdb8a08ab26f08d5766d2332673e659a8b2aeb73f2a904d421/django_timezone_field-7.2.1.tar.gz", hash = "sha256:def846f9e7200b7b8f2a28fcce2b78fb2d470f6a9f272b07c4e014f6ba4c6d2e", size = 13096, upload-time = "2025-12-06T23:50:44.591Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/7f/d885667401515b467f84569c56075bc9add72c9fd425fca51a25f4c997e1/django_timezone_field-7.2.1-py3-none-any.whl", hash = "sha256:276915b72c5816f57c3baf9e43f816c695ef940d1b21f91ebf6203c09bf4ad44", size = 13284, upload-time = "2025-12-06T23:50:43.302Z" }, +] + +[[package]] +name = "djangorestframework" +version = "3.15.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/ce/31482eb688bdb4e271027076199e1aa8d02507e530b6d272ab8b4481557c/djangorestframework-3.15.2.tar.gz", hash = "sha256:36fe88cd2d6c6bec23dca9804bab2ba5517a8bb9d8f47ebc68981b56840107ad", size = 1067420, upload-time = "2024-06-19T07:59:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/b6/fa99d8f05eff3a9310286ae84c4059b08c301ae4ab33ae32e46e8ef76491/djangorestframework-3.15.2-py3-none-any.whl", hash = "sha256:2b8871b062ba1aefc2de01f773875441a961fefbf79f5eed1e32b2f096944b20", size = 1071235, upload-time = "2024-06-19T07:59:26.106Z" }, +] + +[[package]] +name = "djangorestframework-jsonapi" +version = "7.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "inflection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/d5/5d1ce5edd01abdd4636189aea4f0aad0318ba0485bfa47880276b92e9e0c/djangorestframework-jsonapi-7.0.2.tar.gz", hash = "sha256:d6c72a2bee539f1093dd86620e862af2d1a0e60408e38a710146286dbde71d75", size = 130641, upload-time = "2024-06-28T15:17:43.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/6a/234c0aebb841345db887c2c94a325789cce72de9eb3742a0a85ea0481bc0/djangorestframework_jsonapi-7.0.2-py2.py3-none-any.whl", hash = "sha256:be457adb50aac77eec8893048bf46ad6926dcd26204aa10965a1430610828d50", size = 354734, upload-time = "2024-06-28T15:17:40.575Z" }, +] + +[[package]] +name = "djangorestframework-simplejwt" +version = "5.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "pyjwt", extra = ["crypto"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "dogpile-cache" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/301ff89746e76745b937606df4753c032787c59ecb37dd4d4250bddc8929/dogpile_cache-1.5.0.tar.gz", hash = "sha256:849c5573c9a38f155cd4173103c702b637ede0361c12e864876877d0cd125eec", size = 947962, upload-time = "2025-10-11T17:35:36.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/80/12235e5b75bb2c586733280854f131b86051e0bbdfb55349ff70d0f72cf9/dogpile_cache-1.5.0-py3-none-any.whl", hash = "sha256:dc7b47d37844db15e8fdc0243c1b58857a2ddc52a5118237a97127bac200e18d", size = 64447, upload-time = "2025-10-11T17:35:38.573Z" }, +] + +[[package]] +name = "drf-extensions" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/5a/57e3cc6f2bb4e1e84ad85eed0a12ddf22d5217b6b87c39e611d10e677a63/drf_extensions-0.8.0.tar.gz", hash = "sha256:c3f27bca74a2def53e8454a5c7b327595195df51e121743120b2f51ef5a52aaa", size = 173718, upload-time = "2025-04-10T07:50:05.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/c1/b9721c9239b465c0ceac32b33e732c08f6ea0afd8156700e417416a47733/drf_extensions-0.8.0-py2.py3-none-any.whl", hash = "sha256:ab7bd854c9061c27ab55233b66d758001e5c2d81aaa9d117cbbe1c9ea49c77ab", size = 21589, upload-time = "2025-04-10T07:50:02.573Z" }, +] + +[[package]] +name = "drf-nested-routers" +version = "0.95.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/b2/070908ccd41bd6f025f5507d7f6e50009abe8be48393cc697d159b539f0b/drf_nested_routers-0.95.0.tar.gz", hash = "sha256:815978f802e578fd7035c74040c104909cbe97615de89a275d77e928f4029891", size = 23318, upload-time = "2025-09-09T02:01:58.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/61/69d951a6e8031389f5feade38531432325019e1047b23d5c6036a86c5e8f/drf_nested_routers-0.95.0-py2.py3-none-any.whl", hash = "sha256:dd489c33d667aaa81383ffaa8c74781d2b353d8f0795716ae37fc59ee297b7c4", size = 36407, upload-time = "2025-09-09T02:01:56.999Z" }, +] + +[[package]] +name = "drf-simple-apikey" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "django" }, + { name = "djangorestframework" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/fb/2829a2053fc4f545f20a41630f77475beaf22359f25a84fc0528cb924cc1/drf_simple_apikey-2.2.1.tar.gz", hash = "sha256:e5a52804bbac12c8db80c10a3d51a8514fc59fc8385b5e751099a2bc944ad25d", size = 24832, upload-time = "2025-05-10T18:35:55.132Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/d8/51c883e850f0565cb21d54686c695f4601a2b60f4885b5604141008fd530/drf_simple_apikey-2.2.1-py2.py3-none-any.whl", hash = "sha256:2a60b35676d14f907c47dee179dd0fa7425a84c34d6ff5b48d08d3b87ff32809", size = 26877, upload-time = "2025-05-10T18:35:53.889Z" }, +] + +[[package]] +name = "drf-spectacular" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "inflection" }, + { name = "jsonschema" }, + { name = "pyyaml" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/c9/55311dcbdae9a437eeb8f898f8421a6a3eabf08725c23ddf458cf2479b78/drf-spectacular-0.27.2.tar.gz", hash = "sha256:a199492f2163c4101055075ebdbb037d59c6e0030692fc83a1a8c0fc65929981", size = 235131, upload-time = "2024-04-01T18:00:21.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/cd/84c44a5d435f6544e58a9b138305f59bca232157ae4ecb658f9787f87d1c/drf_spectacular-0.27.2-py3-none-any.whl", hash = "sha256:b1c04bf8b2fbbeaf6f59414b4ea448c8787aba4d32f76055c3b13335cf7ec37b", size = 102930, upload-time = "2024-04-01T18:00:17.937Z" }, +] + +[[package]] +name = "drf-spectacular-jsonapi" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "djangorestframework-jsonapi" }, + { name = "drf-extensions" }, + { name = "drf-spectacular" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/a7/198704b65b5a0682ce5d4cc027113aa64faee33ba7f05e54a6e81013cfdf/drf-spectacular-jsonapi-0.5.1.tar.gz", hash = "sha256:e45f87f3cce2692f4f546e0785d8fcc32c6b49770fff858065c267ae8f9cfde5", size = 15718, upload-time = "2024-09-09T13:32:37.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/90/a2968757dac52d172ca48b0814c72513fc2e9653980b1a3df16b56ee3b8a/drf_spectacular_jsonapi-0.5.1-py3-none-any.whl", hash = "sha256:abac728abd83e2544408cc900d682d532ca2088f2f9321d1c9101bcdfdabca78", size = 13526, upload-time = "2024-09-09T13:32:36.227Z" }, +] + +[[package]] +name = "dulwich" +version = "1.2.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/85/ceb8ecff5cdeee4ceeebb86b599476dee559041dacc6c2c50cc0d4711549/dulwich-1.2.5.tar.gz", hash = "sha256:0395b2c8924c3424bafe2d9c1edd5348cc4b21ce9c1d6655bf01f9a5c47164c8", size = 1253230, upload-time = "2026-05-28T22:27:55.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/4a/654ae1671610fdf6b65a64586ad67ddd8550d4d08a632b2a4b9614754b6d/dulwich-1.2.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:556593fd11637f80f6018bee1916b1a84f5b420423b470ebb3f1a782ad6ef081", size = 1399277, upload-time = "2026-05-28T22:27:00.801Z" }, + { url = "https://files.pythonhosted.org/packages/85/d8/06ee3bc8eded4bd7adf8adf0c9ea5f19bf96f7e5e626bfaf7311cde4208a/dulwich-1.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a70477c991e96cfe8fdd7c866e7251faf71b38bfeb51d6f27554c9cce1caabf3", size = 1382310, upload-time = "2026-05-28T22:27:02.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/17/a03adf50b9095f9f5d863393f21d585dea39bdc4fdf60788ff3a9407a512/dulwich-1.2.5-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9008ef25cabd379cda4fa86000fc38ca14b72afe17db798a8c85c0b2b7ce4d1e", size = 1470993, upload-time = "2026-05-28T22:27:04.075Z" }, + { url = "https://files.pythonhosted.org/packages/60/58/1dc352d2a5e80befe4338af7208febb44bcfd7496b0dde5ac6dacb07b031/dulwich-1.2.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a5549f4afc973e0a15ea6b0244d57f848d3f3ee13dac557eb311024aebebf128", size = 1497820, upload-time = "2026-05-28T22:27:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a8/e058959a87e7df7753b112ef66a43ccbc57338c1bbdc23a0edf3833396df/dulwich-1.2.5-cp311-cp311-win32.whl", hash = "sha256:5108acead814d1de8b6262d6d8fb90af7e82f5a4d83788b6b48e39d01800a92f", size = 1066549, upload-time = "2026-05-28T22:27:06.832Z" }, + { url = "https://files.pythonhosted.org/packages/33/91/ff0b444f686718635348986bd73dfce42e947912417893de35de399b878b/dulwich-1.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:5e067b7feceb7034bc99e7c7143a704f1d97d4be7027d9a0aa5a83c0657ff091", size = 1079481, upload-time = "2026-05-28T22:27:08.33Z" }, + { url = "https://files.pythonhosted.org/packages/19/22/4f75770bbe5521cac61c4820ef46d4fbf8c2175d3519ba3d0378d4ba798e/dulwich-1.2.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:701a9ecf7a8a44f5e2459e46befa93530cf36a8b1ae3140aefc007db1d7d0207", size = 1396522, upload-time = "2026-05-28T22:27:09.997Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b1/c07c347681c0cf6acd4b189bf6e8d6207c71a1347b7a1e865eb40faa46b9/dulwich-1.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f90d68bfa97c4ca71de7507984365aefe27b6d248cb28dc99644d0f3ae8c60b", size = 1334826, upload-time = "2026-05-28T22:27:11.582Z" }, + { url = "https://files.pythonhosted.org/packages/13/80/6818eb7ce492e18ab2efa92ab901d173b4b0b159e5681c1424f329600c40/dulwich-1.2.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:00b54a1d56ddbacdd8eadd6d4787a51b3a05fefa30eadbf9165fd283a00b90ed", size = 1416616, upload-time = "2026-05-28T22:27:13.195Z" }, + { url = "https://files.pythonhosted.org/packages/14/a7/9790e60d19870f6554f7583722bb324c1355784316f20aeda1c0b5b1491a/dulwich-1.2.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d8f7ea8f47e38e5b0de3fab97e07e9c9161ffddc90b3964512cab2b7749df4e6", size = 1441354, upload-time = "2026-05-28T22:27:14.683Z" }, + { url = "https://files.pythonhosted.org/packages/91/44/0ea8a69c24aa1254ff5996d682eae2eab287d471b937dcdb26d9ea9720b4/dulwich-1.2.5-cp312-cp312-win32.whl", hash = "sha256:8929134acf4ff967203df7600b38535f9b5b590462067a7e30dbce01acb97af9", size = 1017058, upload-time = "2026-05-28T22:27:16.121Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/2fcddda7faec3bae52db7c64bfcb5dc756f597f33fae90e8d4e4b4d3b39b/dulwich-1.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:9693d2c9e226b2ea855c1dc3a87e2f4d972f7523fc0f7924e5997e9f4c23d97f", size = 1031731, upload-time = "2026-05-28T22:27:17.633Z" }, + { url = "https://files.pythonhosted.org/packages/07/4b/4a18a59ad230581cd0ef460e96001f90762e566dc2dfdba22aa358eb5a0e/dulwich-1.2.5-py3-none-any.whl", hash = "sha256:1679b376433a0fc7f36586afda1d4ed7427afa7a79d4bf17e5014474eea69fa4", size = 686745, upload-time = "2026-05-28T22:27:53.695Z" }, +] + +[[package]] +name = "duo-client" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/c8/4e8416eec72bc77ad947b736f760ceedf818df0c48153816b1bfb0fab854/duo_client-5.5.0.tar.gz", hash = "sha256:303109e047fe7525ba4fc4a294c1f3deb4125066e89c10d33f7430378867b1d6", size = 93475, upload-time = "2025-03-24T15:35:19.175Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/4b/dd927d3111e1f4c78c1f5944d3b64b4fb6ab655487c23100a3feff5cdaa4/duo_client-5.5.0-py3-none-any.whl", hash = "sha256:4fbf1e97a2b25ef64e9f88171ab817162cf45bafc1c63026af4883baf8892a12", size = 51313, upload-time = "2025-03-24T15:35:18.243Z" }, +] + +[[package]] +name = "durationpy" +version = "0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, +] + +[[package]] +name = "email-validator" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload-time = "2024-06-20T11:30:30.034Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload-time = "2024-06-20T11:30:28.248Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + +[[package]] +name = "fonttools" +version = "4.62.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/23ff32561ec8d45a4d48578b4d241369d9270dc50926c017570e60893701/fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7", size = 2871039, upload-time = "2026-03-13T13:52:33.127Z" }, + { url = "https://files.pythonhosted.org/packages/24/7f/66d3f8a9338a9b67fe6e1739f47e1cd5cee78bd3bc1206ef9b0b982289a5/fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14", size = 2416346, upload-time = "2026-03-13T13:52:35.676Z" }, + { url = "https://files.pythonhosted.org/packages/aa/53/5276ceba7bff95da7793a07c5284e1da901cf00341ce5e2f3273056c0cca/fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7", size = 5100897, upload-time = "2026-03-13T13:52:38.102Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a1/40a5c4d8e28b0851d53a8eeeb46fbd73c325a2a9a165f290a5ed90e6c597/fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b", size = 5071078, upload-time = "2026-03-13T13:52:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/d378fca4c65ea1956fee6d90ace6e861776809cbbc5af22388a090c3c092/fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1", size = 5076908, upload-time = "2026-03-13T13:52:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d9/ae6a1d0693a4185a84605679c8a1f719a55df87b9c6e8e817bfdd9ef5936/fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416", size = 5202275, upload-time = "2026-03-13T13:52:46.591Z" }, + { url = "https://files.pythonhosted.org/packages/54/6c/af95d9c4efb15cabff22642b608342f2bd67137eea6107202d91b5b03184/fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53", size = 2293075, upload-time = "2026-03-13T13:52:48.711Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/bf54c5b3f2be34e1f143e6db838dfdc54f2ffa3e68c738934c82f3b2a08d/fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2", size = 2344593, upload-time = "2026-03-13T13:52:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, + { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, + { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, + { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, + { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, +] + +[[package]] +name = "freezegun" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/ef/722b8d71ddf4d48f25f6d78aa2533d505bf3eec000a7cacb8ccc8de61f2f/freezegun-1.5.1.tar.gz", hash = "sha256:b29dedfcda6d5e8e083ce71b2b542753ad48cfec44037b3fc79702e2980a89e9", size = 33697, upload-time = "2024-05-11T17:32:53.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/0b/0d7fee5919bccc1fdc1c2a7528b98f65c6f69b223a3fd8f809918c142c36/freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1", size = 17569, upload-time = "2024-05-11T17:32:51.715Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "gevent" +version = "25.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/48/b3ef2673ffb940f980966694e40d6d32560f3ffa284ecaeb5ea3a90a6d3f/gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd", size = 5059025, upload-time = "2025-09-17T16:15:34.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/86/03f8db0704fed41b0fa830425845f1eb4e20c92efa3f18751ee17809e9c6/gevent-25.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5aff9e8342dc954adb9c9c524db56c2f3557999463445ba3d9cbe3dada7b7", size = 1792418, upload-time = "2025-09-17T15:41:24.384Z" }, + { url = "https://files.pythonhosted.org/packages/5f/35/f6b3a31f0849a62cfa2c64574bcc68a781d5499c3195e296e892a121a3cf/gevent-25.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1cdf6db28f050ee103441caa8b0448ace545364f775059d5e2de089da975c457", size = 1875700, upload-time = "2025-09-17T15:48:59.652Z" }, + { url = "https://files.pythonhosted.org/packages/66/1e/75055950aa9b48f553e061afa9e3728061b5ccecca358cef19166e4ab74a/gevent-25.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:812debe235a8295be3b2a63b136c2474241fa5c58af55e6a0f8cfc29d4936235", size = 1831365, upload-time = "2025-09-17T15:49:19.426Z" }, + { url = "https://files.pythonhosted.org/packages/31/e8/5c1f6968e5547e501cfa03dcb0239dff55e44c3660a37ec534e32a0c008f/gevent-25.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b28b61ff9216a3d73fe8f35669eefcafa957f143ac534faf77e8a19eb9e6883a", size = 2122087, upload-time = "2025-09-17T15:15:12.329Z" }, + { url = "https://files.pythonhosted.org/packages/c0/2c/ebc5d38a7542af9fb7657bfe10932a558bb98c8a94e4748e827d3823fced/gevent-25.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5e4b6278b37373306fc6b1e5f0f1cf56339a1377f67c35972775143d8d7776ff", size = 1808776, upload-time = "2025-09-17T15:52:40.16Z" }, + { url = "https://files.pythonhosted.org/packages/e6/26/e1d7d6c8ffbf76fe1fbb4e77bdb7f47d419206adc391ec40a8ace6ebbbf0/gevent-25.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d99f0cb2ce43c2e8305bf75bee61a8bde06619d21b9d0316ea190fc7a0620a56", size = 2179141, upload-time = "2025-09-17T15:24:09.895Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6c/bb21fd9c095506aeeaa616579a356aa50935165cc0f1e250e1e0575620a7/gevent-25.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:72152517ecf548e2f838c61b4be76637d99279dbaa7e01b3924df040aa996586", size = 1677941, upload-time = "2025-09-17T19:59:50.185Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/e55930ba5259629eb28ac7ee1abbca971996a9165f902f0249b561602f24/gevent-25.9.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:46b188248c84ffdec18a686fcac5dbb32365d76912e14fda350db5dc0bfd4f86", size = 2955991, upload-time = "2025-09-17T14:52:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/aa/88/63dc9e903980e1da1e16541ec5c70f2b224ec0a8e34088cb42794f1c7f52/gevent-25.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f2b54ea3ca6f0c763281cd3f96010ac7e98c2e267feb1221b5a26e2ca0b9a692", size = 1808503, upload-time = "2025-09-17T15:41:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/7a/8d/7236c3a8f6ef7e94c22e658397009596fa90f24c7d19da11ad7ab3a9248e/gevent-25.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7a834804ac00ed8a92a69d3826342c677be651b1c3cd66cc35df8bc711057aa2", size = 1890001, upload-time = "2025-09-17T15:49:01.227Z" }, + { url = "https://files.pythonhosted.org/packages/4f/63/0d7f38c4a2085ecce26b50492fc6161aa67250d381e26d6a7322c309b00f/gevent-25.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:323a27192ec4da6b22a9e51c3d9d896ff20bc53fdc9e45e56eaab76d1c39dd74", size = 1855335, upload-time = "2025-09-17T15:49:20.582Z" }, + { url = "https://files.pythonhosted.org/packages/95/18/da5211dfc54c7a57e7432fd9a6ffeae1ce36fe5a313fa782b1c96529ea3d/gevent-25.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6ea78b39a2c51d47ff0f130f4c755a9a4bbb2dd9721149420ad4712743911a51", size = 2109046, upload-time = "2025-09-17T15:15:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/a6/5a/7bb5ec8e43a2c6444853c4a9f955f3e72f479d7c24ea86c95fb264a2de65/gevent-25.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:dc45cd3e1cc07514a419960af932a62eb8515552ed004e56755e4bf20bad30c5", size = 1827099, upload-time = "2025-09-17T15:52:41.384Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d4/b63a0a60635470d7d986ef19897e893c15326dd69e8fb342c76a4f07fe9e/gevent-25.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34e01e50c71eaf67e92c186ee0196a039d6e4f4b35670396baed4a2d8f1b347f", size = 2172623, upload-time = "2025-09-17T15:24:12.03Z" }, + { url = "https://files.pythonhosted.org/packages/d5/98/caf06d5d22a7c129c1fb2fc1477306902a2c8ddfd399cd26bbbd4caf2141/gevent-25.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4acd6bcd5feabf22c7c5174bd3b9535ee9f088d2bbce789f740ad8d6554b18f3", size = 1682837, upload-time = "2025-09-17T19:48:47.318Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/10/05572d33273292bac49c2d1785925f7bc3ff2fe50e3044cf1062c1dde32e/google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7", size = 177828, upload-time = "2026-01-08T22:21:39.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/b6/85c4d21067220b9a78cfb81f516f9725ea6befc1544ec9bd2c1acd97c324/google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9", size = 173906, upload-time = "2026-01-08T22:21:36.093Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-api-python-client" +version = "2.163.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "httplib2" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/9f/535346bb1469ec91139c38f0438ad70bd229a6b11452367065fe49303860/google_api_python_client-2.163.0.tar.gz", hash = "sha256:88dee87553a2d82176e2224648bf89272d536c8f04dcdda37ef0a71473886dd7", size = 12588615, upload-time = "2025-03-05T16:56:22.715Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/3d/203d1c18cb239313ac125721f284e94c01c7eee947adb2ef2d9a85ac8d66/google_api_python_client-2.163.0-py2.py3-none-any.whl", hash = "sha256:080e8bc0669cb4c1fb8efb8da2f5b91a2625d8f0e7796cfad978f33f7016c6c4", size = 13097882, upload-time = "2025-03-05T16:56:19.197Z" }, +] + +[[package]] +name = "google-auth" +version = "2.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, +] + +[[package]] +name = "google-auth-httplib2" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "httplib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/be/217a598a818567b28e859ff087f347475c807a5649296fb5a817c58dacef/google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05", size = 10842, upload-time = "2023-12-12T17:40:30.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/8a/fe34d2f3f9470a27b01c9e76226965863f153d5fbe276f83608562e49c04/google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d", size = 9253, upload-time = "2023-12-12T17:40:13.055Z" }, +] + +[[package]] +name = "google-cloud-access-context-manager" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/eb/f8dcd4e3c596733f872732b43836df6486fa09966ce5586443d4e9e288fb/google_cloud_access_context_manager-0.3.0.tar.gz", hash = "sha256:f3aa35c9225b7aaef85ecdacedcc1577789be8d458b7a41b6ad23b504786e5f9", size = 46643, upload-time = "2025-10-17T02:33:32.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/94/24b010493660dd55e2d9769ae7ef44164aebd7e1f4a9266cf9459affd687/google_cloud_access_context_manager-0.3.0-py3-none-any.whl", hash = "sha256:5d15ad51547f06c281e35f16b4ffcb3e98bb2d898b01470f88b94edfb2eeb0a3", size = 58852, upload-time = "2025-10-17T02:30:33.768Z" }, +] + +[[package]] +name = "google-cloud-asset" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "google-cloud-access-context-manager" }, + { name = "google-cloud-org-policy" }, + { name = "google-cloud-os-config" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/7e/53cbc8352da27168f7ddefd8faa4e2cdde9309a6543e32c6abfd95e15f2e/google_cloud_asset-4.2.0.tar.gz", hash = "sha256:1734906cfd9b6ea6922861c8f1b4fcabe90d53ca267ee88499e8532b7593b35f", size = 305130, upload-time = "2026-01-15T13:14:16.184Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/88/9a43fae1d2fed94d7f5f46b6f4c44bd15e5ea0e8657632108b5ec5f53d9d/google_cloud_asset-4.2.0-py3-none-any.whl", hash = "sha256:fd7ea04c64948a4779790343204cd5b41d4772d6ab1d05a9125e28a637ac0862", size = 282707, upload-time = "2026-01-09T14:53:03.081Z" }, +] + +[[package]] +name = "google-cloud-org-policy" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/6d/4330ac61bf1b441d04bc0c3eb61b1cc1c00327c53c187ca7ed79d4d373eb/google_cloud_org_policy-1.16.0.tar.gz", hash = "sha256:c72147127d88d9809af8738b2abe34806eac529c3cdc57aa915cc08a1b842a13", size = 100280, upload-time = "2026-01-15T13:15:17.295Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/f6/65bb8770fe5f0a5e53886901731a9453df5ef9d44c86941a67e2ddb0e350/google_cloud_org_policy-1.16.0-py3-none-any.whl", hash = "sha256:96d1ed38f795182600a58f8eb2879e1577ce663b6b27df0b8a3050960cff87a5", size = 85598, upload-time = "2026-01-15T13:12:50.044Z" }, +] + +[[package]] +name = "google-cloud-os-config" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/99/b23efdf6a316d92675ef2cec45c470b543f5f3078ba7a82b7a8f68883940/google_cloud_os_config-1.23.0.tar.gz", hash = "sha256:a629cf55b3ede36b2df89814c6ccf3c1d43c7f1b43db6c7c02eb4860851baf3a", size = 304811, upload-time = "2026-01-15T13:04:50.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/81/cf65aeda47c34ac5bf8cf9f8684d6ff9bd015025332f297e856163a97870/google_cloud_os_config-1.23.0-py3-none-any.whl", hash = "sha256:fea865018391abca42a9a74d270ddab516ae0865d6e9ad3bcb503286ca01c069", size = 264712, upload-time = "2026-01-15T13:02:48.689Z" }, +] + +[[package]] +name = "google-cloud-resource-manager" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/7f/db00b2820475793a52958dc55fe9ec2eb8e863546e05fcece9b921f86ebe/google_cloud_resource_manager-1.16.0.tar.gz", hash = "sha256:cc938f87cc36c2672f062b1e541650629e0d954c405a4dac35ceedee70c267c3", size = 459840, upload-time = "2026-01-15T13:04:07.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/ff/4b28bcc791d9d7e4ac8fea00fbd90ccb236afda56746a3b4564d2ae45df3/google_cloud_resource_manager-1.16.0-py3-none-any.whl", hash = "sha256:fb9a2ad2b5053c508e1c407ac31abfd1a22e91c32876c1892830724195819a28", size = 400218, upload-time = "2026-01-15T13:02:47.378Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, +] + +[[package]] +name = "gprof2dot" +version = "2025.4.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/fd/cad13fa1f7a463a607176432c4affa33ea162f02f58cc36de1d40d3e6b48/gprof2dot-2025.4.14.tar.gz", hash = "sha256:35743e2d2ca027bf48fa7cba37021aaf4a27beeae1ae8e05a50b55f1f921a6ce", size = 39536, upload-time = "2025-04-14T07:21:45.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/ed/89d760cb25279109b89eb52975a7b5479700d3114a2421ce735bfb2e7513/gprof2dot-2025.4.14-py3-none-any.whl", hash = "sha256:0742e4c0b4409a5e8777e739388a11e1ed3750be86895655312ea7c20bd0090e", size = 37555, upload-time = "2025-04-14T07:21:43.319Z" }, +] + +[[package]] +name = "graphemeu" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/20/d012f71e7d00e0d5bb25176b9a96c5313d0e30cf947153bfdfa78089f4bb/graphemeu-0.7.2.tar.gz", hash = "sha256:42bbe373d7c146160f286cd5f76b1a8ad29172d7333ce10705c5cc282462a4f8", size = 307626, upload-time = "2025-01-15T09:48:59.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/18/36503ea63e1ecd0a95590d7b6b8b7d227a1e4541a154e1612a231def1bdc/graphemeu-0.7.2-py3-none-any.whl", hash = "sha256:1444520f6899fd30114fc2a39f297d86d10fa0f23bf7579f772f8bc7efaa2542", size = 22670, upload-time = "2025-01-15T09:48:57.241Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" }, + { url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" }, + { url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161, upload-time = "2026-01-23T16:15:53.456Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" }, + { url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" }, + { url = "https://files.pythonhosted.org/packages/1f/54/dcf9f737b96606f82f8dd05becfb8d238db0633dd7397d542a296fe9cad3/greenlet-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b", size = 226462, upload-time = "2026-01-23T15:36:50.422Z" }, + { url = "https://files.pythonhosted.org/packages/91/37/61e1015cf944ddd2337447d8e97fb423ac9bc21f9963fb5f206b53d65649/greenlet-3.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4", size = 225715, upload-time = "2026-01-23T15:33:17.298Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" }, + { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, + { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156, upload-time = "2026-01-23T15:34:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" }, +] + +[[package]] +name = "grpc-google-iam-v1" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos", extra = ["grpc"] }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/1e/1011451679a983f2f5c6771a1682542ecb027776762ad031fd0d7129164b/grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389", size = 23745, upload-time = "2025-10-15T21:14:53.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6", size = 32690, upload-time = "2025-10-15T21:14:51.72Z" }, +] + +[[package]] +name = "grpcio" +version = "1.76.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" }, + { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" }, + { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" }, + { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" }, + { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" }, + { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" }, + { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, + { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, + { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, + { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, + { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.76.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/46/e9f19d5be65e8423f886813a2a9d0056ba94757b0c5007aa59aed1a961fa/grpcio_status-1.76.0.tar.gz", hash = "sha256:25fcbfec74c15d1a1cb5da3fab8ee9672852dc16a5a9eeb5baf7d7a9952943cd", size = 13679, upload-time = "2025-10-21T16:28:52.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/cc/27ba60ad5a5f2067963e6a858743500df408eb5855e98be778eaef8c9b02/grpcio_status-1.76.0-py3-none-any.whl", hash = "sha256:380568794055a8efbbd8871162df92012e0228a5f6dffaf57f2a00c534103b18", size = 14425, upload-time = "2025-10-21T16:28:40.853Z" }, +] + +[[package]] +name = "gunicorn" +version = "23.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httplib2" +version = "0.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "humanfriendly" +version = "10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "iamdata" +version = "0.1.202602021" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/5e/8179963b7a528c548824a8e4088150509d9fa8571dd622b7399f6d2d5680/iamdata-0.1.202602021.tar.gz", hash = "sha256:c24265fc3694076f65da91a8aa9361b60da25f7b8cfd8ba4ddd6aa1b9bb5153e", size = 771233, upload-time = "2026-02-02T05:49:56.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/9e/ae7a3019aa5a27d70412b74da4f0304695efa5d9a88f0689f37ea2602ea2/iamdata-0.1.202602021-py3-none-any.whl", hash = "sha256:48419662d75dd0e1ea22b9cc98fd70201d4c72760c6897acc46ad9ab90633d18", size = 1226614, upload-time = "2026-02-02T05:49:54.735Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +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 = "inflection" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, +] + +[[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 = "iso8601" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522, upload-time = "2023-10-03T00:25:39.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" }, +] + +[[package]] +name = "isodate" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" }, +] + +[[package]] +name = "isort" +version = "5.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/f9/c1eb8635a24e87ade2efce21e3ce8cd6b8630bb685ddc9cdaca1349b2eb5/isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", size = 175303, upload-time = "2023-12-13T20:37:26.124Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6", size = 92310, upload-time = "2023-12-13T20:37:23.244Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, + { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, + { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpickle" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/a6/d07afcfdef402900229bcca795f80506b207af13a838d4d99ad45abf530c/jsonpickle-4.1.1.tar.gz", hash = "sha256:f86e18f13e2b96c1c1eede0b7b90095bbb61d99fedc14813c44dc2f361dbbae1", size = 316885, upload-time = "2025-06-02T20:36:11.57Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/73/04df8a6fa66d43a9fd45c30f283cc4afff17da671886e451d52af60bdc7e/jsonpickle-4.1.1-py3-none-any.whl", hash = "sha256:bb141da6057898aa2438ff268362b126826c812a1721e31cf08a6e142910dc91", size = 47125, upload-time = "2025-06-02T20:36:08.647Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jwcrypto" +version = "1.5.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/90/f065668004d22715c1940d6e88e4c3afc8ee16d5664e4478d2c8fd23a250/jwcrypto-1.5.7.tar.gz", hash = "sha256:70204d7cca406eda8c82352e3c41ba2d946610dafd19e54403f0a1f4f18633c6", size = 89535, upload-time = "2026-04-07T00:35:36.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/24/fb7da4d6613de7001feaf540d4b5969c6b5a1c42839043b0196cb13aa057/jwcrypto-1.5.7-py3-none-any.whl", hash = "sha256:729463fefe28b6de5cf1ebfda3e94f1a1b41d2799148ef98a01cb9678ebe2bb0", size = 94799, upload-time = "2026-04-07T00:35:35.085Z" }, +] + +[[package]] +name = "keystoneauth1" +version = "5.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "iso8601" }, + { name = "os-service-types" }, + { name = "pbr" }, + { name = "requests" }, + { name = "stevedore" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/2b/5a08163c8e878811f11dc8c78c041c2384cd424c71f75fb7a6b19969047f/keystoneauth1-5.13.0.tar.gz", hash = "sha256:57c9ca407207899b50d8ff1ca8abb4a4e7427461bfc1877eb8519c3989ce63ec", size = 288721, upload-time = "2026-01-19T10:47:02.467Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/99/76476a1057b349c860bae72e45d6ef438feb877c84ee7d565faf464e54c3/keystoneauth1-5.13.0-py3-none-any.whl", hash = "sha256:5ab81412eb0923ceb9c602cc3decce514b399523cb83d16b409ed3b0f9b03d41", size = 343585, upload-time = "2026-01-19T10:47:00.762Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" }, + { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596, upload-time = "2025-08-10T21:25:56.861Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548, upload-time = "2025-08-10T21:25:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618, upload-time = "2025-08-10T21:25:59.857Z" }, + { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437, upload-time = "2025-08-10T21:26:01.105Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742, upload-time = "2025-08-10T21:26:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810, upload-time = "2025-08-10T21:26:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579, upload-time = "2025-08-10T21:26:05.317Z" }, + { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071, upload-time = "2025-08-10T21:26:06.686Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840, upload-time = "2025-08-10T21:26:07.94Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159, upload-time = "2025-08-10T21:26:09.048Z" }, + { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, + { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, + { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, + { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, + { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" }, + { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009, upload-time = "2025-08-10T21:27:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, +] + +[[package]] +name = "knack" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "jmespath" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "pyyaml" }, + { name = "tabulate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/5b/7cc69b2941a11bdace4faffef8f023543feefd14ab0222b6e62a318c53b9/knack-0.11.0.tar.gz", hash = "sha256:eb6568001e9110b1b320941431c51033d104cc98cda2254a5c2b09ba569fd494", size = 72308, upload-time = "2023-07-26T06:23:32.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/6b/caf27d5a40618c7e945a1c68e1961c2d3637edfce9ebb0edc27c9ff53c1c/knack-0.11.0-py3-none-any.whl", hash = "sha256:6704c867840978a119a193914a90e2e98c7be7dff764c8fcd8a2286c5a978d00", size = 60848, upload-time = "2023-07-26T06:23:30.221Z" }, +] + +[[package]] +name = "kombu" +version = "5.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "packaging" }, + { name = "tzdata" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/a5/607e533ed6c83ae1a696969b8e1c137dfebd5759a2e9682e26ff1b97740b/kombu-5.6.2.tar.gz", hash = "sha256:8060497058066c6f5aed7c26d7cd0d3b574990b09de842a8c5aaed0b92cc5a55", size = 472594, upload-time = "2025-12-29T20:30:07.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/0f/834427d8c03ff1d7e867d3db3d176470c64871753252b21b4f4897d1fa45/kombu-5.6.2-py3-none-any.whl", hash = "sha256:efcfc559da324d41d61ca311b0c64965ea35b4c55cc04ee36e55386145dace93", size = 214219, upload-time = "2025-12-29T20:30:05.74Z" }, +] + +[[package]] +name = "kubernetes" +version = "32.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "durationpy" }, + { name = "google-auth" }, + { name = "oauthlib" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "six" }, + { name = "urllib3" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/e8/0598f0e8b4af37cd9b10d8b87386cf3173cb8045d834ab5f6ec347a758b3/kubernetes-32.0.1.tar.gz", hash = "sha256:42f43d49abd437ada79a79a16bd48a604d3471a117a8347e87db693f2ba0ba28", size = 946691, upload-time = "2025-02-18T21:06:34.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/10/9f8af3e6f569685ce3af7faab51c8dd9d93b9c38eba339ca31c746119447/kubernetes-32.0.1-py2.py3-none-any.whl", hash = "sha256:35282ab8493b938b08ab5526c7ce66588232df00ef5e1dbe88a419107dc10998", size = 1988070, upload-time = "2025-02-18T21:06:31.391Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5d/3bccad330292946f97962df9d5f2d3ae129cce6e212732a781e856b91e07/lxml-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cec05be8c876f92a5aa07b01d60bbb4d11cfbdd654cad0561c0d7b5c043a61b9", size = 8526232, upload-time = "2026-04-18T04:27:40.389Z" }, + { url = "https://files.pythonhosted.org/packages/a7/51/adc8826570a112f83bb4ddb3a2ab510bbc2ccd62c1b9fe1f34fae2d90b57/lxml-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9c03e048b6ce8e77b09c734e931584894ecd58d08296804ca2d0b184c933ce50", size = 4595448, upload-time = "2026-04-18T04:27:44.208Z" }, + { url = "https://files.pythonhosted.org/packages/54/84/5a9ec07cbe1d2334a6465f863b949a520d2699a755738986dcd3b6b89e3f/lxml-6.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:942454ff253da14218f972b23dc72fa4edf6c943f37edd19cd697618b626fac5", size = 4923771, upload-time = "2026-04-18T04:32:17.402Z" }, + { url = "https://files.pythonhosted.org/packages/a7/23/851cfa33b6b38adb628e45ad51fb27105fa34b2b3ba9d1d4aa7a9428dfe0/lxml-6.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d036ee7b99d5148072ac7c9b847193decdfeac633db350363f7bce4fff108f0e", size = 5068101, upload-time = "2026-04-18T04:32:21.437Z" }, + { url = "https://files.pythonhosted.org/packages/b0/38/41bf99c2023c6b79916ba057d83e9db21d642f473cac210201222882d38b/lxml-6.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ae5d8d5427f3cc317e7950f2da7ad276df0cfa37b8de2f5658959e618ea8512", size = 5002573, upload-time = "2026-04-18T04:32:25.373Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/053aa10bdc39747e1e923ce2d45413075e84f70a136045bb09e5eaca41d3/lxml-6.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:363e47283bde87051b821826e71dde47f107e08614e1aa312ba0c5711e77738c", size = 5202816, upload-time = "2026-04-18T04:32:29.393Z" }, + { url = "https://files.pythonhosted.org/packages/9a/da/bc710fad8bf04b93baee752c192eaa2210cd3a84f969d0be7830fea55802/lxml-6.1.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:f504d861d9f2a8f94020130adac88d66de93841707a23a86244263d1e54682f5", size = 5329999, upload-time = "2026-04-18T04:32:34.019Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/bf035dedbdf7fab49411aa52e4236f3445e98d38647d85419e6c0d2806b9/lxml-6.1.0-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:23a5dc68e08ed13331d61815c08f260f46b4a60fdd1640bbeb82cf89a9d90289", size = 4659643, upload-time = "2026-04-18T04:32:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/22be31f33727a5e4c7b01b0a874503026e50329b259d3587e0b923cf964b/lxml-6.1.0-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f15401d8d3dbf239e23c818afc10c7207f7b95f9a307e092122b6f86dd43209a", size = 5265963, upload-time = "2026-04-18T04:32:41.881Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2b/d44d0e5c79226017f4ab8c87a802ebe4f89f97e6585a8e4166dffcdd7b6e/lxml-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fcf3da95e93349e0647d48d4b36a12783105bcc74cb0c416952f9988410846a3", size = 5045444, upload-time = "2026-04-18T04:32:44.512Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c3/3f034fec1594c331a6dbf9491238fdcc9d66f68cc529e109ec75b97197e1/lxml-6.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0d082495c5fcf426e425a6e28daaba1fcb6d8f854a4ff01effb1f1f381203eb9", size = 4712703, upload-time = "2026-04-18T04:32:47.16Z" }, + { url = "https://files.pythonhosted.org/packages/12/16/0b83fccc158218aca75a7aa33e97441df737950734246b9fffa39301603d/lxml-6.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e3c4f84b24a1fcba435157d111c4b755099c6ff00a3daee1ad281817de75ed11", size = 5252745, upload-time = "2026-04-18T04:32:50.427Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/12e6c1b39a77666c02eaa77f94a870aaf63c4ac3a497b2d52319448b01c6/lxml-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:976a6b39b1b13e8c354ad8d3f261f3a4ac6609518af91bdb5094760a08f132c4", size = 5226822, upload-time = "2026-04-18T04:32:53.437Z" }, + { url = "https://files.pythonhosted.org/packages/34/20/c7852904858b4723af01d2fc14b5d38ff57cb92f01934a127ebd9a9e51aa/lxml-6.1.0-cp311-cp311-win32.whl", hash = "sha256:857efde87d365706590847b916baff69c0bc9252dc5af030e378c9800c0b10e3", size = 3594026, upload-time = "2026-04-18T04:27:31.903Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/d60c732b56da5085175c07c74b2df4e6d181b0c9a61e1691474f06ef4b39/lxml-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:183bfb45a493081943be7ea2b5adfc2b611e1cf377cefa8b8a8be404f45ef9a7", size = 4025114, upload-time = "2026-04-18T04:27:34.077Z" }, + { url = "https://files.pythonhosted.org/packages/c2/df/c84dcc175fd690823436d15b41cb920cd5ba5e14cd8bfb00949d5903b320/lxml-6.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:19f4164243fc206d12ed3d866e80e74f5bc3627966520da1a5f97e42c32a3f39", size = 3667742, upload-time = "2026-04-18T04:27:38.45Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d4/9326838b59dc36dfae42eec9656b97520f9997eee1de47b8316aaeed169c/lxml-6.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d2f17a16cd8751e8eb233a7e41aecdf8e511712e00088bf9be455f604cd0d28d", size = 8570663, upload-time = "2026-04-18T04:27:48.253Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a4/053745ce1f8303ccbb788b86c0db3a91b973675cefc42566a188637b7c40/lxml-6.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0cea5b1d3e6e77d71bd2b9972eb2446221a69dc52bb0b9c3c6f6e5700592d93", size = 4624024, upload-time = "2026-04-18T04:27:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/a517944b20f8fd0932ad2109482bee4e29fe721416387a363306667941f6/lxml-6.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc46da94826188ed45cb53bd8e3fc076ae22675aea2087843d4735627f867c6d", size = 4930895, upload-time = "2026-04-18T04:32:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/e08a970727d556caa040a44773c7b7e3ad0f0d73dedc863543e9a8b931f2/lxml-6.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9147d8e386ec3b82c3b15d88927f734f565b0aaadef7def562b853adca45784a", size = 5093820, upload-time = "2026-04-18T04:32:58.94Z" }, + { url = "https://files.pythonhosted.org/packages/88/ee/2a5c2aa2c32016a226ca25d3e1056a8102ea6e1fe308bf50213586635400/lxml-6.1.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5715e0e28736a070f3f34a7ccc09e2fdcba0e3060abbcf61a1a5718ff6d6b105", size = 5005790, upload-time = "2026-04-18T04:33:01.272Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/a0db9be8f38ad6043ab9429487c128dd1d30f07956ef43040402f8da49e8/lxml-6.1.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4937460dc5df0cdd2f06a86c285c28afda06aefa3af949f9477d3e8df430c485", size = 5630827, upload-time = "2026-04-18T04:33:04.036Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/3c13d3fc24b7cacf675f808a3a1baabf43a30d0cd24c98f94548e9aa58eb/lxml-6.1.0-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc783ee3147e60a25aa0445ea82b3e8aabb83b240f2b95d32cb75587ff781814", size = 5240445, upload-time = "2026-04-18T04:33:06.87Z" }, + { url = "https://files.pythonhosted.org/packages/55/ba/eeef4ccba09b2212fe239f46c1692a98db1878e0872ae320756488878a94/lxml-6.1.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:40d9189f80075f2e1f88db21ef815a2b17b28adf8e50aaf5c789bfe737027f32", size = 5350121, upload-time = "2026-04-18T04:33:09.365Z" }, + { url = "https://files.pythonhosted.org/packages/7e/01/1da87c7b587c38d0cbe77a01aae3b9c1c49ed47d76918ef3db8fc151b1ca/lxml-6.1.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:05b9b8787e35bec69e68daf4952b2e6dfcfb0db7ecf1a06f8cdfbbac4eb71aad", size = 4694949, upload-time = "2026-04-18T04:33:11.628Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/7db0fe66d5aaf128443ee1623dec3db1576f3e4c17751ec0ef5866468590/lxml-6.1.0-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0f08beb0182e3e9a86fae124b3c47a7b41b7b69b225e1377db983802404e54", size = 5243901, upload-time = "2026-04-18T04:33:13.95Z" }, + { url = "https://files.pythonhosted.org/packages/00/a8/1346726af7d1f6fca1f11223ba34001462b0a3660416986d37641708d57c/lxml-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73becf6d8c81d4c76b1014dbd3584cb26d904492dcf73ca85dc8bff08dcd6d2d", size = 5048054, upload-time = "2026-04-18T04:33:16.965Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b7/85057012f035d1a0c87e02f8c723ca3c3e6e0728bcf4cb62080b21b1c1e3/lxml-6.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1ae225f66e5938f4fa29d37e009a3bb3b13032ac57eb4eb42afa44f6e4054e69", size = 4777324, upload-time = "2026-04-18T04:33:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/75/6c/ad2f94a91073ef570f33718040e8e160d5fb93331cf1ab3ca1323f939e2d/lxml-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:690022c7fae793b0489aa68a658822cea83e0d5933781811cabbf5ea3bcfe73d", size = 5645702, upload-time = "2026-04-18T04:33:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/3b/89/0bb6c0bd549c19004c60eea9dc554dd78fd647b72314ef25d460e0d208c6/lxml-6.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63aeafc26aac0be8aff14af7871249e87ea1319be92090bfd632ec68e03b16a5", size = 5232901, upload-time = "2026-04-18T04:33:26.21Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d9/d609a11fb567da9399f525193e2b49847b5a409cdebe737f06a8b7126bdc/lxml-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:264c605ab9c0e4aa1a679636f4582c4d3313700009fac3ec9c3412ed0d8f3e1d", size = 5261333, upload-time = "2026-04-18T04:33:28.984Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3a/ac3f99ec8ac93089e7dd556f279e0d14c24de0a74a507e143a2e4b496e7c/lxml-6.1.0-cp312-cp312-win32.whl", hash = "sha256:56971379bc5ee8037c5a0f09fa88f66cdb7d37c3e38af3e45cf539f41131ac1f", size = 3596289, upload-time = "2026-04-18T04:27:42.819Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a7/0a915557538593cb1bbeedcd40e13c7a261822c26fecbbdb71dad0c2f540/lxml-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:bba078de0031c219e5dd06cf3e6bf8fb8e6e64a77819b358f53bb132e3e03366", size = 3997059, upload-time = "2026-04-18T04:27:46.764Z" }, + { url = "https://files.pythonhosted.org/packages/92/96/a5dc078cf0126fbfbc35611d77ecd5da80054b5893e28fb213a5613b9e1d/lxml-6.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3592631e652afa34999a088f98ba7dfc7d6aff0d535c410bea77a71743f3819", size = 3659552, upload-time = "2026-04-18T04:27:51.133Z" }, + { url = "https://files.pythonhosted.org/packages/f2/88/55143966481409b1740a3ac669e611055f49efd68087a5ce41582325db3e/lxml-6.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:546b66c0dd1bb8d9fa89d7123e5fa19a8aff3a1f2141eb22df96112afb17b842", size = 3930134, upload-time = "2026-04-18T04:32:35.008Z" }, + { url = "https://files.pythonhosted.org/packages/b5/97/28b985c2983938d3cb696dd5501423afb90a8c3e869ef5d3c62569282c0f/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfa1a34df366d9dc0d5eaf420f4cf2bb1e1bebe1066d1c2fc28c179f8a4004c", size = 4210749, upload-time = "2026-04-18T04:36:03.626Z" }, + { url = "https://files.pythonhosted.org/packages/29/67/dfab2b7d58214921935ccea7ce9b3df9b7d46f305d12f0f532ac7cf6b804/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db88156fcf544cdbf0d95588051515cfdfd4c876fc66444eb98bceb5d6db76de", size = 4318463, upload-time = "2026-04-18T04:36:06.309Z" }, + { url = "https://files.pythonhosted.org/packages/32/a2/4ac7eb32a4d997dd352c32c32399aae27b3f268d440e6f9cfa405b575d2f/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:07f98f5496f96bf724b1e3c933c107f0cbf2745db18c03d2e13a291c3afd2635", size = 4251124, upload-time = "2026-04-18T04:36:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/33/ef/d6abd850bb4822f9b720cfe36b547a558e694881010ff7d012191e8769c6/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4642e04449a1e164b5ff71ffd901ddb772dfabf5c9adf1b7be5dffe1212bc037", size = 4401758, upload-time = "2026-04-18T04:36:11.803Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/3ee09a5b60cb44c4f2fbc1c9015cfd6ff5afc08f991cab295d3024dcbf2d/lxml-6.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7da13bb6fbadfafb474e0226a30570a3445cfd47c86296f2446dafbd77079ace", size = 3508860, upload-time = "2026-04-18T04:32:48.619Z" }, +] + +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/5b/6edcd23319d9e28b1bedf32768c3d1fd56eed8223960a2c47dacd2cec2af/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4", size = 207391, upload-time = "2025-11-03T13:01:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/5f9b772e85b3d5769367a79973b8030afad0d6b724444083bad09becd66f/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43", size = 207146, upload-time = "2025-11-03T13:01:37.928Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/f66da5647c0d72592081a37c8775feacc3d14d2625bbdaabd6307c274565/lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7", size = 1292623, upload-time = "2025-11-03T13:01:39.341Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/5df0f17467cdda0cad464a9197a447027879197761b55faad7ca29c29a04/lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb", size = 1279982, upload-time = "2025-11-03T13:01:40.816Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/b55cb577aa148ed4e383e9700c36f70b651cd434e1c07568f0a86c9d5fbb/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989", size = 1368674, upload-time = "2025-11-03T13:01:42.118Z" }, + { url = "https://files.pythonhosted.org/packages/fb/31/e97e8c74c59ea479598e5c55cbe0b1334f03ee74ca97726e872944ed42df/lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d", size = 88168, upload-time = "2025-11-03T13:01:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/47/715865a6c7071f417bef9b57c8644f29cb7a55b77742bd5d93a609274e7e/lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004", size = 99491, upload-time = "2025-11-03T13:01:44.167Z" }, + { url = "https://files.pythonhosted.org/packages/14/e7/ac120c2ca8caec5c945e6356ada2aa5cfabd83a01e3170f264a5c42c8231/lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b", size = 91271, upload-time = "2025-11-03T13:01:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.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" } +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" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "marshmallow" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/7e/1dbd4096eb7c148cd2841841916f78820bb85a4d80a0c25c02d30815a7fb/marshmallow-4.3.0.tar.gz", hash = "sha256:fb43c53b3fe240b8f6af37223d6ef1636f927ad9bea8ab323afad95dff090880", size = 224485, upload-time = "2026-04-03T21:46:32.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/e0/ff24e25218bb59eb6290a530cea40651b14068b6e3659b20f9c175179632/marshmallow-4.3.0-py3-none-any.whl", hash = "sha256:46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46", size = 49148, upload-time = "2026-04-03T21:46:31.241Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, + { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" }, + { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" }, + { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, + { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, + { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, + { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, + { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "microsoft-kiota-abstractions" +version = "1.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "std-uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/94/37315b82a1bcc08145e5bc2af7396a4be8160ac138ec269611c3b9589b7a/microsoft_kiota_abstractions-1.9.9.tar.gz", hash = "sha256:5df9a8e0517a4568726c2cac6d9789284cc6ffa66043b68eba42ae55749fb861", size = 24468, upload-time = "2026-03-02T21:03:50.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/6a/7d5a1a8131f0eccc6b45839c091aa00ba29661854e7defaa7936cf342fa7/microsoft_kiota_abstractions-1.9.9-py3-none-any.whl", hash = "sha256:8d0a14eda42f3f0ccac2e9512227a338f69998dc9b782fd21cb8ca7c48302caa", size = 44453, upload-time = "2026-03-02T21:03:51.11Z" }, +] + +[[package]] +name = "microsoft-kiota-authentication-azure" +version = "1.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "azure-core" }, + { name = "microsoft-kiota-abstractions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/ce/5ae8b37ee4a50f0ed5e092c2d0105d60b592e6102a190959f76658a0994c/microsoft_kiota_authentication_azure-1.9.9.tar.gz", hash = "sha256:aca5e7dc8a0a28224f9025a479349ac2f9aaf166bfd6bc707f232658b45eec28", size = 5000, upload-time = "2026-03-02T21:04:02.355Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/de/dc504324b776d00a420886cc6f39e04be2cf48cab0e9b18f8450a5efcc29/microsoft_kiota_authentication_azure-1.9.9-py3-none-any.whl", hash = "sha256:73dc21a1a2861ea78a135327291db3322e2255542a18b311dd03fd908342e902", size = 6951, upload-time = "2026-03-02T21:04:03.18Z" }, +] + +[[package]] +name = "microsoft-kiota-http" +version = "1.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "microsoft-kiota-abstractions" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/3f/fc18eb0d1d845daf6355fd54fd990af7f7e10043ef6a6da39b9e5981cbaf/microsoft_kiota_http-1.9.9.tar.gz", hash = "sha256:ae672b145df71b644f8da0951767a12a4ce47a40576d86eba19b7c22d9e160f9", size = 21493, upload-time = "2026-03-02T21:04:11.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/6a/cc1b1055b4b6d4dfc1be7a71917c2f0ef19c070c6a18b16d3c1032d20925/microsoft_kiota_http-1.9.9-py3-none-any.whl", hash = "sha256:a5b1b217ac9afeb4054f12515417e3b1d2be12a9385a70a41d18d64379ea2e7e", size = 31945, upload-time = "2026-03-02T21:04:12.328Z" }, +] + +[[package]] +name = "microsoft-kiota-serialization-form" +version = "1.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "microsoft-kiota-abstractions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/b4/18e9fce60a30c8b6ea0a6278fb81cf352127340d48df2d7c52ff1b579488/microsoft_kiota_serialization_form-1.9.9.tar.gz", hash = "sha256:3cdc8b172baec5b5282af72f2ce02715edcd23252ce0b5af96075256edd75114", size = 9015, upload-time = "2026-03-02T21:04:20.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/24/eb8436b882f1473bd0a868848d214df3df2d9b3db8e5422d111032f1114f/microsoft_kiota_serialization_form-1.9.9-py3-none-any.whl", hash = "sha256:1c426d4f0d463fc9215c41d7fa0f3dc5fe8d3c80573d555cf63ea67000148d84", size = 10718, upload-time = "2026-03-02T21:04:21.25Z" }, +] + +[[package]] +name = "microsoft-kiota-serialization-json" +version = "1.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "microsoft-kiota-abstractions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/2f/d36eba916c00136da122d1701acb862c5b1f2e22b6dc6fa4e0f4abda2786/microsoft_kiota_serialization_json-1.9.9.tar.gz", hash = "sha256:9b27479427f49bbac15ead8e8ff0176e47fcdf81153611acc408f5f399342079", size = 9545, upload-time = "2026-03-02T21:04:29.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/7b/b3f606ef2dcbdebe12ae27004ed6e7542370cb2494265f11a8877a1de2d1/microsoft_kiota_serialization_json-1.9.9-py3-none-any.whl", hash = "sha256:bb80b93e81bab41dc142e9b254f79bf0b7b9fe49a796ca0c8e8691925bd3967f", size = 11210, upload-time = "2026-03-02T21:04:29.844Z" }, +] + +[[package]] +name = "microsoft-kiota-serialization-multipart" +version = "1.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "microsoft-kiota-abstractions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/44/24087f0fac7c5682c13c7fb61468a0c5a5185b9f243de3a99309aa6fcaa7/microsoft_kiota_serialization_multipart-1.9.9.tar.gz", hash = "sha256:f8730be6da5f6c63a6bf4ea310a9723b9998a47a04745887dc156d08f119a829", size = 5162, upload-time = "2026-03-02T21:04:48.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/db/6b988fdf771c3d07dff4a116176d575832daf2a43823444d145d71da5b61/microsoft_kiota_serialization_multipart-1.9.9-py3-none-any.whl", hash = "sha256:572e9cbafa2eb946452cdadfb019a4e9245768c0d61c3089d3436d4f5106c550", size = 6696, upload-time = "2026-03-02T21:04:48.98Z" }, +] + +[[package]] +name = "microsoft-kiota-serialization-text" +version = "1.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "microsoft-kiota-abstractions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/3c/d244ad08e03003134871698aa54de8243bcc61c0faf3ab114293bb76d6ad/microsoft_kiota_serialization_text-1.9.9.tar.gz", hash = "sha256:18bc0764dda4078a4c953300253344e05d0cdb9c17136f1a2f695d438cedb402", size = 7325, upload-time = "2026-03-02T21:04:37.567Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/f8/43f8d00fed6e090810d3ce0c05e06c23eaa5dee6e87ab1fb89d96ca9559f/microsoft_kiota_serialization_text-1.9.9-py3-none-any.whl", hash = "sha256:84418119d4929a76fde7f31e957e240e003bf145757838b9aa3a0f36dec1b789", size = 8885, upload-time = "2026-03-02T21:04:38.76Z" }, +] + +[[package]] +name = "microsoft-security-utilities-secret-masker" +version = "1.0.0b4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/1a/6fa5c0ba55ed62e17df010af8a3a71ffea701c3d414b4688834c527d5aeb/microsoft_security_utilities_secret_masker-1.0.0b4.tar.gz", hash = "sha256:a30bd361ac18c8b52f6844076bc26465335949ea9c7a004d95f5196ec6fdef3e", size = 10919, upload-time = "2025-03-10T05:46:54.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/4b/69befea52f4623da5356e46cb3fc020525d35ceb766a428abb4919628cdc/microsoft_security_utilities_secret_masker-1.0.0b4-py3-none-any.whl", hash = "sha256:0429fcaad10fc8ae3f940ab84fd2926e4f50ede134162144123b35937be831a8", size = 16678, upload-time = "2025-03-10T05:46:55.43Z" }, +] + +[[package]] +name = "msal" +version = "1.35.0b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/7a/6880016fab1720981b54db844c32af6f2e5e90aac21575ad6e54e1840313/msal-1.35.0b1.tar.gz", hash = "sha256:fe8143079183a5c952cd9f3ba66a148fe7bae9fb9952bd0e834272bfbeb34508", size = 157573, upload-time = "2026-01-06T23:51:56.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/8e/7090fafcf58e9081767a8fa960431c708211ce273bc4f6e519e9046acacc/msal-1.35.0b1-py3-none-any.whl", hash = "sha256:bf656775c64bbc2103d8255980f5c3c966c7432106795e1fe70ca338a7e43150", size = 117733, upload-time = "2026-01-06T23:51:55.903Z" }, +] + +[package.optional-dependencies] +broker = [ + { name = "pymsalruntime", marker = "sys_platform == 'win32'" }, +] + +[[package]] +name = "msal-extensions" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, + { name = "portalocker" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/38/ad49272d0a5af95f7a0cb64a79bbd75c9c187f3b789385a143d8d537a5eb/msal_extensions-1.2.0.tar.gz", hash = "sha256:6f41b320bfd2933d631a215c91ca0dd3e67d84bd1a2f50ce917d5874ec646bef", size = 22391, upload-time = "2024-06-23T02:15:37.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/69/314d887a01599669fb330da14e5c6ff5f138609e322812a942a74ef9b765/msal_extensions-1.2.0-py3-none-any.whl", hash = "sha256:cf5ba83a2113fa6dc011a254a72f1c223c88d7dfad74cc30617c4679a417704d", size = 19254, upload-time = "2024-06-23T02:15:36.584Z" }, +] + +[[package]] +name = "msgraph-core" +version = "1.3.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "microsoft-kiota-abstractions" }, + { name = "microsoft-kiota-authentication-azure" }, + { name = "microsoft-kiota-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/4e/123f9530ec43b306c597bb830c62bedab830ffa76e0edf33ea88a26f756e/msgraph_core-1.3.8.tar.gz", hash = "sha256:6e883f9d4c4ad57501234749e07b010478c1a5f19550ef4cf005bbcac4a63ae7", size = 25506, upload-time = "2025-09-11T22:46:57.267Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/4d/01432f60727ae452787014cad0d5bc9e035c6e11a670f12c23f7fc926d90/msgraph_core-1.3.8-py3-none-any.whl", hash = "sha256:86d83edcf62119946f201d13b7e857c947ef67addb088883940197081de85bea", size = 34473, upload-time = "2025-09-11T22:46:56.026Z" }, +] + +[[package]] +name = "msgraph-sdk" +version = "1.55.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-identity" }, + { name = "microsoft-kiota-serialization-form" }, + { name = "microsoft-kiota-serialization-json" }, + { name = "microsoft-kiota-serialization-multipart" }, + { name = "microsoft-kiota-serialization-text" }, + { name = "msgraph-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/44/0b5a188addf6341b3da10dd207e444417de255f7c1651902ba72016a2843/msgraph_sdk-1.55.0.tar.gz", hash = "sha256:6df691a31954a050d26b8a678968017e157d940fb377f2a8a4e17a9741b98756", size = 6295669, upload-time = "2026-02-20T00:32:29.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/a8/de807e62f8ff93003b573aa243cdcee2da2c0618b42efbc9a8e61aa7300d/msgraph_sdk-1.55.0-py3-none-any.whl", hash = "sha256:c8e68ebc4b88af5111de312e7fa910a4e76ddf48a4534feadb1fb8a411c48cfc", size = 25758742, upload-time = "2026-02-20T00:30:40.039Z" }, +] + +[[package]] +name = "msrest" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "certifi" }, + { name = "isodate" }, + { name = "requests" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/77/8397c8fb8fc257d8ea0fa66f8068e073278c65f05acb17dcb22a02bfdc42/msrest-0.7.1.zip", hash = "sha256:6e7661f46f3afd88b75667b7187a92829924446c7ea1d169be8c4bb7eeb788b9", size = 175332, upload-time = "2022-06-13T22:41:25.111Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/cf/f2966a2638144491f8696c27320d5219f48a072715075d168b31d3237720/msrest-0.7.1-py3-none-any.whl", hash = "sha256:21120a810e1233e5e6cc7fe40b474eeb4ec6f757a15d7cf86702c369f9567c32", size = 85384, upload-time = "2022-06-13T22:41:22.42Z" }, +] + +[[package]] +name = "msrestazure" +version = "0.6.4.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "adal" }, + { name = "msrest" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/86/06a086e4ed3523765a1917665257b1828f1bf882130768445f082a4c3484/msrestazure-0.6.4.post1.tar.gz", hash = "sha256:39842007569e8c77885ace5c46e4bf2a9108fcb09b1e6efdf85b6e2c642b55d4", size = 47728, upload-time = "2024-04-10T21:00:51.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/7e/620e883def84ae56b8a9da382d960f7f801e37518fe930085cf72c148dae/msrestazure-0.6.4.post1-py2.py3-none-any.whl", hash = "sha256:2264493b086c2a0a82ddf5fd87b35b3fffc443819127fed992ac5028354c151e", size = 40789, upload-time = "2024-04-10T21:00:49.359Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "mypy" +version = "1.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/b9/81e4c6dbb1ec1e72503de3ff2c5fe4b7f224e04613b670f8b9004cd8a4dd/mypy-1.10.1.tar.gz", hash = "sha256:1f8f492d7db9e3593ef42d4f115f04e556130f2819ad33ab84551403e97dd4c0", size = 3022304, upload-time = "2024-06-25T00:12:23.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/cf/0645128c6edf70eb9b9687ad42fcb61ea344a7927ed2b78ce2275282fe87/mypy-1.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bd6f629b67bb43dc0d9211ee98b96d8dabc97b1ad38b9b25f5e4c4d7569a0c6a", size = 10740526, upload-time = "2024-06-25T00:10:58.841Z" }, + { url = "https://files.pythonhosted.org/packages/19/c9/10842953066265e6063c41a85bbee3b877501947c970ea84a1db5f11d32e/mypy-1.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1bbb3a6f5ff319d2b9d40b4080d46cd639abe3516d5a62c070cf0114a457d84", size = 9898375, upload-time = "2024-06-25T00:11:54.767Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/551e897f67c5d67aa1976bc3b4951f297d1daf07250c421bb045b2613350/mypy-1.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8edd4e9bbbc9d7b79502eb9592cab808585516ae1bcc1446eb9122656c6066f", size = 12602338, upload-time = "2024-06-25T00:10:55.898Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a4/55e3635253e5fa7051674dd5a67582f08b0ba8823e1fdbf7241ed5b32d4e/mypy-1.10.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:6166a88b15f1759f94a46fa474c7b1b05d134b1b61fca627dd7335454cc9aa6b", size = 12680741, upload-time = "2024-06-25T00:11:39.882Z" }, + { url = "https://files.pythonhosted.org/packages/7a/cc/aa881ad051f99915887db0b5de8facc0e224295be22f92178c8f77fd8359/mypy-1.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:5bb9cd11c01c8606a9d0b83ffa91d0b236a0e91bc4126d9ba9ce62906ada868e", size = 9393661, upload-time = "2024-06-25T00:11:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/5d/86/3c3bdaccc3cbd1372acb15667a2c2cb773523a710a22e2748cbda9a7c1e2/mypy-1.10.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d8681909f7b44d0b7b86e653ca152d6dff0eb5eb41694e163c6092124f8246d7", size = 10864022, upload-time = "2024-06-25T00:11:01.838Z" }, + { url = "https://files.pythonhosted.org/packages/ec/05/7c87b26b6a769b70f6c0b8a6daef01fc6f3ae566df89a2fa9d04f690b0d3/mypy-1.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:378c03f53f10bbdd55ca94e46ec3ba255279706a6aacaecac52ad248f98205d3", size = 9857795, upload-time = "2024-06-25T00:12:17.799Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b5/cbccba4dca9703c4c467171e7f61ea6a1a75eae991208aa5bc7d49807f91/mypy-1.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bacf8f3a3d7d849f40ca6caea5c055122efe70e81480c8328ad29c55c69e93e", size = 12647633, upload-time = "2024-06-25T00:10:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/1f5e57c8cfab4299f7189821ae8bb4896e8e623a04d293fd32e32eb0e617/mypy-1.10.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:701b5f71413f1e9855566a34d6e9d12624e9e0a8818a5704d74d6b0402e66c04", size = 12730251, upload-time = "2024-06-25T00:12:14.738Z" }, + { url = "https://files.pythonhosted.org/packages/f9/20/d33608e8dc3bc0f5966fc1f6c2d16671f0725dcca279beec47c3e19afd9d/mypy-1.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c4c2992f6ea46ff7fce0072642cfb62af7a2484efe69017ed8b095f7b39ef31", size = 9491734, upload-time = "2024-06-25T00:11:05.118Z" }, + { url = "https://files.pythonhosted.org/packages/2b/ee/d53a3d4792a09b6cd757978951d6dcf8b10825a8b8522b68e9b5eb53b9a1/mypy-1.10.1-py3-none-any.whl", hash = "sha256:71d8ac0b906354ebda8ef1673e5fde785936ac1f29ff6987c7483cfbd5a4235a", size = 2580108, upload-time = "2024-06-25T00:12:08.18Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "narwhals" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/6f/713be67779028d482c6e0f2dde5bc430021b2578a4808c1c9f6d7ad48257/narwhals-2.16.0.tar.gz", hash = "sha256:155bb45132b370941ba0396d123cf9ed192bf25f39c4cea726f2da422ca4e145", size = 618268, upload-time = "2026-02-02T10:31:00.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/cc/7cb74758e6df95e0c4e1253f203b6dd7f348bf2f29cf89e9210a2416d535/narwhals-2.16.0-py3-none-any.whl", hash = "sha256:846f1fd7093ac69d63526e50732033e86c30ea0026a44d9b23991010c7d1485d", size = 443951, upload-time = "2026-02-02T10:30:58.635Z" }, +] + +[[package]] +name = "neo4j" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/01/d6ce65e4647f6cb2b9cca3b813978f7329b54b4e36660aaec1ddf0ccce7a/neo4j-6.1.0.tar.gz", hash = "sha256:b5dde8c0d8481e7b6ae3733569d990dd3e5befdc5d452f531ad1884ed3500b84", size = 239629, upload-time = "2026-01-12T11:27:34.777Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/5c/ee71e2dd955045425ef44283f40ba1da67673cf06404916ca2950ac0cd39/neo4j-6.1.0-py3-none-any.whl", hash = "sha256:3bd93941f3a3559af197031157220af9fd71f4f93a311db687bd69ffa417b67d", size = 325326, upload-time = "2026-01-12T11:27:33.196Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "numpy" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" }, + { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" }, + { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" }, + { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" }, + { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" }, + { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "oci" +version = "2.169.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "circuitbreaker" }, + { name = "cryptography" }, + { name = "pyopenssl" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/f4/3c2eddccc75dd06a692dbb3290f20f4bc733d99dc60de21f22d65efdeae4/oci-2.169.0.tar.gz", hash = "sha256:f3c5fff00b01783b5325ea7b13bf140053ec1e9f41da20bfb9c8a349ee7662fa", size = 16885837, upload-time = "2026-03-31T06:14:58.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/bf/19643bd939ab595193779ee25c2c12aef8e9a54e0a68de5ed79f209702e3/oci-2.169.0-py3-none-any.whl", hash = "sha256:c71bb5143f307791082b3e33cc1545c2490a518cfed85ab1948ef5107c36d30b", size = 34460447, upload-time = "2026-03-31T06:14:51.373Z" }, +] + +[[package]] +name = "okta" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aenum" }, + { name = "aiohttp" }, + { name = "blinker" }, + { name = "jwcrypto" }, + { name = "pycryptodomex" }, + { name = "pydantic" }, + { name = "pydash" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "xmltodict" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/58/f38bc707e51c7ca6b745c87ff7eacf1c2cf4591582062eea5d5700b00837/okta-3.4.2.tar.gz", hash = "sha256:b05201056f3f028c5d2d16394f9b47024a689080f5a993c11d4d80f0e1b5ba1e", size = 2399661, upload-time = "2026-04-15T19:22:46.691Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/81/eeb7ac44656bea4ab462bc5318a1fac5fd5d122dffbf7bba62f444f21ee5/okta-3.4.2-py3-none-any.whl", hash = "sha256:b67bcff31de65223c5848894a202153236d0c99e3a8541a54bf7065f81676637", size = 3907638, upload-time = "2026-04-15T19:22:42.187Z" }, +] + +[[package]] +name = "openai" +version = "1.109.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/a1/a303104dc55fc546a3f6914c842d3da471c64eec92043aef8f652eb6c524/openai-1.109.1.tar.gz", hash = "sha256:d173ed8dbca665892a6db099b4a2dfac624f94d20a93f46eb0b56aae940ed869", size = 564133, upload-time = "2025-09-24T13:00:53.075Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/2a/7dd3d207ec669cacc1f186fd856a0f61dbc255d24f6fdc1a6715d6051b0f/openai-1.109.1-py3-none-any.whl", hash = "sha256:6bcaf57086cf59159b8e27447e4e7dd019db5d29a438072fbd49c290c7e65315", size = 948627, upload-time = "2025-09-24T13:00:50.754Z" }, +] + +[[package]] +name = "openstacksdk" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "decorator" }, + { name = "dogpile-cache" }, + { name = "iso8601" }, + { name = "jmespath" }, + { name = "jsonpatch" }, + { name = "keystoneauth1" }, + { name = "os-service-types" }, + { name = "pbr" }, + { name = "platformdirs" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "requestsexceptions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/64/e1a90090a5e892f272b73bec5069b676a422bac9ff24193bdac4b868daa6/openstacksdk-4.2.0.tar.gz", hash = "sha256:5cb9450dcce8054a2caf89d8be9e55057ddfa219a954e781032241eb29280445", size = 1236489, upload-time = "2024-12-16T11:31:21.207Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/2a/0569ac23eb82a376ce1d4695d0defead3080a3ca5050b27392370d2423dd/openstacksdk-4.2.0-py3-none-any.whl", hash = "sha256:238be0fa5d9899872b00787ab38e84f92fd6dc87525fde0965dadcdc12196dc6", size = 1748374, upload-time = "2024-12-16T11:31:16.888Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.39.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" } +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" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, +] + +[[package]] +name = "os-service-types" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pbr" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/62/31e39aa8f2ac5bff0b061ce053f0610c9fe659e12aeca20bfb26d1665024/os_service_types-1.8.2.tar.gz", hash = "sha256:ab7648d7232849943196e1bb00a30e2e25e600fa3b57bb241d15b7f521b5b575", size = 27476, upload-time = "2025-11-21T13:55:47.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/26/0937af7b4383f1eba5bca789b8d191c0e09e59bb64962b18f4a14534ce41/os_service_types-1.8.2-py3-none-any.whl", hash = "sha256:f78890d71814deffabf0ed4358288ec2ced579bc4d0bb87a79ae806cbb4deb6e", size = 24876, upload-time = "2025-11-21T13:55:46.093Z" }, +] + +[[package]] +name = "packageurl-python" +version = "0.17.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pagerduty" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/ad/f8029b94b0f3ae0453eb5125b7aa4a411ea7f5260900d07a1bef9231e939/pagerduty-6.1.0.tar.gz", hash = "sha256:84dfba74f68142c4a71c88af4858f1eb8671e7bc564bc133ac41c59daa7b54f8", size = 43553, upload-time = "2025-11-19T17:30:33.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/70/a2c9869b6848e57c990cd35652a6ab6023e27021df7775eacf32d9d5e75e/pagerduty-6.1.0-py3-none-any.whl", hash = "sha256:ca4954b917cb8e92f83e6b4e18d0f81fdaa73768edb7ad6e859edcc8f950f4eb", size = 53858, upload-time = "2025-11-19T17:30:32.165Z" }, +] + +[[package]] +name = "pandas" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213, upload-time = "2024-09-20T13:10:04.827Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/44/d9502bf0ed197ba9bf1103c9867d5904ddcaf869e52329787fc54ed70cc8/pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039", size = 12602222, upload-time = "2024-09-20T13:08:56.254Z" }, + { url = "https://files.pythonhosted.org/packages/52/11/9eac327a38834f162b8250aab32a6781339c69afe7574368fffe46387edf/pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd", size = 11321274, upload-time = "2024-09-20T13:08:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/45/fb/c4beeb084718598ba19aa9f5abbc8aed8b42f90930da861fcb1acdb54c3a/pandas-2.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698", size = 15579836, upload-time = "2024-09-20T19:01:57.571Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5f/4dba1d39bb9c38d574a9a22548c540177f78ea47b32f99c0ff2ec499fac5/pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc", size = 13058505, upload-time = "2024-09-20T13:09:01.501Z" }, + { url = "https://files.pythonhosted.org/packages/b9/57/708135b90391995361636634df1f1130d03ba456e95bcf576fada459115a/pandas-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3", size = 16744420, upload-time = "2024-09-20T19:02:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/86/4a/03ed6b7ee323cf30404265c284cee9c65c56a212e0a08d9ee06984ba2240/pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32", size = 14440457, upload-time = "2024-09-20T13:09:04.105Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8c/87ddf1fcb55d11f9f847e3c69bb1c6f8e46e2f40ab1a2d2abadb2401b007/pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5", size = 11617166, upload-time = "2024-09-20T13:09:06.917Z" }, + { url = "https://files.pythonhosted.org/packages/17/a3/fb2734118db0af37ea7433f57f722c0a56687e14b14690edff0cdb4b7e58/pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9", size = 12529893, upload-time = "2024-09-20T13:09:09.655Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0c/ad295fd74bfac85358fd579e271cded3ac969de81f62dd0142c426b9da91/pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4", size = 11363475, upload-time = "2024-09-20T13:09:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2a/4bba3f03f7d07207481fed47f5b35f556c7441acddc368ec43d6643c5777/pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3", size = 15188645, upload-time = "2024-09-20T19:02:03.88Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/d8fddee9ed0d0c0f4a2132c1dfcf0e3e53265055da8df952a53e7eaf178c/pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319", size = 12739445, upload-time = "2024-09-20T13:09:17.621Z" }, + { url = "https://files.pythonhosted.org/packages/20/e8/45a05d9c39d2cea61ab175dbe6a2de1d05b679e8de2011da4ee190d7e748/pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8", size = 16359235, upload-time = "2024-09-20T19:02:07.094Z" }, + { url = "https://files.pythonhosted.org/packages/1d/99/617d07a6a5e429ff90c90da64d428516605a1ec7d7bea494235e1c3882de/pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a", size = 14056756, upload-time = "2024-09-20T13:09:20.474Z" }, + { url = "https://files.pythonhosted.org/packages/29/d4/1244ab8edf173a10fd601f7e13b9566c1b525c4f365d6bee918e68381889/pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13", size = 11504248, upload-time = "2024-09-20T13:09:23.137Z" }, +] + +[[package]] +name = "pbr" +version = "7.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/ab/1de9a4f730edde1bdbbc2b8d19f8fa326f036b4f18b2f72cfbea7dc53c26/pbr-7.0.3.tar.gz", hash = "sha256:b46004ec30a5324672683ec848aed9e8fc500b0d261d40a3229c2d2bbfcedc29", size = 135625, upload-time = "2025-11-03T17:04:56.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/db/61efa0d08a99f897ef98256b03e563092d36cc38dc4ebe4a85020fe40b31/pbr-7.0.3-py2.py3-none-any.whl", hash = "sha256:ff223894eb1cd271a98076b13d3badff3bb36c424074d26334cd25aebeecea6b", size = 131898, upload-time = "2025-11-03T17:04:54.875Z" }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +] + +[[package]] +name = "pkginfo" +version = "1.12.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/03/e26bf3d6453b7fda5bd2b84029a426553bb373d6277ef6b5ac8863421f87/pkginfo-1.12.1.2.tar.gz", hash = "sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b", size = 451828, upload-time = "2025-02-19T15:27:37.188Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/3d/f4f2ba829efb54b6cd2d91349c7463316a9cc55a43fc980447416c88540f/pkginfo-1.12.1.2-py3-none-any.whl", hash = "sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343", size = 32717, upload-time = "2025-02-19T15:27:33.071Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "plotly" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/4f/8a10a9b9f5192cb6fdef62f1d77fa7d834190b2c50c0cd256bd62879212b/plotly-6.5.2.tar.gz", hash = "sha256:7478555be0198562d1435dee4c308268187553cc15516a2f4dd034453699e393", size = 7015695, upload-time = "2026-01-14T21:26:51.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/67/f95b5460f127840310d2187f916cf0023b5875c0717fdf893f71e1325e87/plotly-6.5.2-py3-none-any.whl", hash = "sha256:91757653bd9c550eeea2fa2404dba6b85d1e366d54804c340b2c874e5a7eb4a4", size = 9895973, upload-time = "2026-01-14T21:26:47.135Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +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/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]] +name = "policyuniverse" +version = "1.5.1.20231109" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/a2/6cf14186b746fbcab73e507968e0b1927ad2e91dcb67af967f65d6cbe6c1/policyuniverse-1.5.1.20231109.tar.gz", hash = "sha256:74e56d410560915c2c5132e361b0130e4bffe312a2f45230eac50d7c094bc40a", size = 469645, upload-time = "2023-11-30T19:12:46.297Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/f5/65b66420c275e9b26513fdd6d84687403d11ac8be4650b67d1e5572b8f48/policyuniverse-1.5.1.20231109-py2.py3-none-any.whl", hash = "sha256:0b0ece0ee8285af31fc39ce09c82a551ca62e62bc2842e23952503bccb973321", size = 484251, upload-time = "2023-11-30T19:12:43.463Z" }, +] + +[[package]] +name = "portalocker" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/d3/c6c64067759e87af98cc668c1cc75171347d0f1577fab7ca3749134e3cd4/portalocker-2.10.1.tar.gz", hash = "sha256:ef1bf844e878ab08aee7e40184156e1151f228f103aa5c6bd0724cc330960f8f", size = 40891, upload-time = "2024-07-13T23:15:34.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/fb/a70a4214956182e0d7a9099ab17d50bfcba1056188e9b14f35b9e2b62a0d/portalocker-2.10.1-py3-none-any.whl", hash = "sha256:53a5984ebc86a025552264b459b46a2086e269b21823cb572f8f28ee759e45bf", size = 18423, upload-time = "2024-07-13T23:15:32.602Z" }, +] + +[[package]] +name = "prek" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/ff/5b7a2a9c4fa3dd2ffc8b13a9ec22aa550deda5b39ab273f8e02863b12642/prek-0.3.9.tar.gz", hash = "sha256:f82b92d81f42f1f90a47f5fbbf492373e25ef1f790080215b2722dd6da66510e", size = 423801, upload-time = "2026-04-13T12:30:38.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/08/c11a6b7834b461223763b6b1552f32c9199393685d52d555de621e900ee7/prek-0.3.9-py3-none-linux_armv6l.whl", hash = "sha256:3ed793d51bfaa27bddb64d525d7acb77a7c8644f549412d82252e3eb0b88aad8", size = 5337784, upload-time = "2026-04-13T12:30:46.044Z" }, + { url = "https://files.pythonhosted.org/packages/15/d9/974b02832a645c6411069c713e3191ce807f9962006da108e4727efd2fa1/prek-0.3.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:399c58400c0bd0b82a93a3c09dc1bfd88d8d0cfb242d414d2ed247187b06ead1", size = 5713864, upload-time = "2026-04-13T12:30:27.007Z" }, + { url = "https://files.pythonhosted.org/packages/40/e1/4ed14bef15eb30039a75177b0807ac007095a5a110284706ccf900a8d512/prek-0.3.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ea1ffb124e92f081b8e2ca5b5a623a733efb3be0c5b1f4b7ffe2ee17d1f20c", size = 5290437, upload-time = "2026-04-13T12:30:30.658Z" }, + { url = "https://files.pythonhosted.org/packages/67/80/d5c3015e9da161dede566bfeef41f098f92470613157daa4f7377ab08d58/prek-0.3.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:aaf639f95b7301639298311d8d44aad0d0b4864e9736083ad3c71ce9765d37ab", size = 5536208, upload-time = "2026-04-13T12:30:47.964Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/8cdc5eb1018437d7828740defd322e7a96459c02fc8961160c4120325313/prek-0.3.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff104863b187fa443ea8451ca55d51e2c6e94f99f00d88784b5c3c4c623f1ebe", size = 5251785, upload-time = "2026-04-13T12:30:39.78Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e2/a5fc35a0fd3167224a000ca1b6235ecbdea0ac77e24af5979a75b0e6b5a4/prek-0.3.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:039ecaf87c63a3e67cca645ebd5bc5eb6aafa6c9d929e9a27b2921e7849d7ef9", size = 5668548, upload-time = "2026-04-13T12:30:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/09/e8/a189ee79f401c259f66f8af587f899d4d5bfb04e0ca371bfd01e49871007/prek-0.3.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3bde2a3d045705095983c7f78ba04f72a7565fe1c2b4e85f5628502a254754ff", size = 6660927, upload-time = "2026-04-13T12:30:44.495Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5a/54117316e98ff62a14911ad1488a3a0945530242a2ce3e92f7a40b6ccc02/prek-0.3.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28a0960a21543563e2c8e19aaad176cc8423a87aac3c914d0f313030d7a9244a", size = 5932244, upload-time = "2026-04-13T12:30:49.532Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/e88d4361f59be7adeeb3a8a3819d69d286d86fe6f7606840af6734362675/prek-0.3.9-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0dfb5d5171d7523271909246ee306b4dc3d5b63752e7dd7c7e8a8908fc9490d1", size = 5542139, upload-time = "2026-04-13T12:30:41.266Z" }, + { url = "https://files.pythonhosted.org/packages/11/1f/204837115087bb8d063bda754a7fe975428c5d5b6548c30dd749f8ab85d4/prek-0.3.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82b791bd36c1430c84d3ae7220a85152babc7eaf00f70adcb961bd594e756ba3", size = 5392519, upload-time = "2026-04-13T12:30:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/bd/00/de57b5795e670b6d38e7eda6d9ac6fd6d757ca22f725e5054b042104cd53/prek-0.3.9-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:6eac6d2f736b041118f053a1487abed468a70dd85a8688eaf87bb42d3dcecf20", size = 5222780, upload-time = "2026-04-13T12:30:36.576Z" }, + { url = "https://files.pythonhosted.org/packages/f5/14/0bc055c305d92980b151f2ec00c14d28fe94c6d51180ca07fded28771cbf/prek-0.3.9-py3-none-musllinux_1_1_i686.whl", hash = "sha256:5517e46e761367a3759b3168eabc120840ffbca9dfbc53187167298a98f87dc4", size = 5524310, upload-time = "2026-04-13T12:30:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d1/eebc2b69be0de36cd84adbe0a0710f4deb468a90e30525be027d6db02d54/prek-0.3.9-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:92024778cf78683ca32687bb249ab6a7d5c33887b5ee1d1a9f6d0c14228f4cf3", size = 6043751, upload-time = "2026-04-13T12:30:29.101Z" }, + { url = "https://files.pythonhosted.org/packages/46/cb/be98c04e702cbc0b0328cd745ff4634ace69ad5a84461bde36f88a7be873/prek-0.3.9-py3-none-win32.whl", hash = "sha256:7f89c55e5f480f5d073769e319924ad69d4bf9f98c5cb46a83082e26e634c958", size = 5045940, upload-time = "2026-04-13T12:30:42.882Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b6/b51771d69f6282e34edeb73f23d956da34f2cabbb5ba16ba175cc0a056f9/prek-0.3.9-py3-none-win_amd64.whl", hash = "sha256:7722f3372eaa83b147e70a43cb7b9fe2128c13d0c78d8a1cdbf2a8ec2ee071eb", size = 5435204, upload-time = "2026-04-13T12:30:51.482Z" }, + { url = "https://files.pythonhosted.org/packages/30/8a/f8a87c15b095460eccd67c8d89a086b7a37aac8d363f89544b8ce6ec653d/prek-0.3.9-py3-none-win_arm64.whl", hash = "sha256:0bced6278d6cc8a4b46048979e36bc9da034611dc8facd77ab123177b833a929", size = 5279552, upload-time = "2026-04-13T12:30:53.011Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/89/9cbe2f4bba860e149108b683bc2efec21f14d5f7ed6e25562ad86acbc373/proto_plus-1.27.0.tar.gz", hash = "sha256:873af56dd0d7e91836aee871e5799e1c6f1bda86ac9a983e0bb9f0c266a568c4", size = 56158, upload-time = "2025-12-16T13:46:25.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/24/3b7a0818484df9c28172857af32c2397b6d8fcd99d9468bd4684f98ebf0a/proto_plus-1.27.0-py3-none-any.whl", hash = "sha256:1baa7f81cf0f8acb8bc1f6d085008ba4171eaf669629d1b6d1673b21ed1c0a82", size = 50205, upload-time = "2025-12-16T13:46:24.76Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, +] + +[[package]] +name = "prowler" +version = "5.27.0" +source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" } +dependencies = [ + { name = "alibabacloud-actiontrail20200706" }, + { name = "alibabacloud-credentials" }, + { name = "alibabacloud-cs20151215" }, + { name = "alibabacloud-ecs20140526" }, + { name = "alibabacloud-gateway-oss-util" }, + { name = "alibabacloud-oss20190517" }, + { name = "alibabacloud-ram20150501" }, + { name = "alibabacloud-rds20140815" }, + { name = "alibabacloud-sas20181203" }, + { name = "alibabacloud-sls20201230" }, + { name = "alibabacloud-sts20150401" }, + { name = "alibabacloud-tea-openapi" }, + { name = "alibabacloud-vpc20160428" }, + { name = "alive-progress" }, + { name = "awsipranges" }, + { name = "azure-identity" }, + { name = "azure-keyvault-keys" }, + { name = "azure-mgmt-apimanagement" }, + { name = "azure-mgmt-applicationinsights" }, + { name = "azure-mgmt-authorization" }, + { name = "azure-mgmt-compute" }, + { name = "azure-mgmt-containerregistry" }, + { name = "azure-mgmt-containerservice" }, + { name = "azure-mgmt-cosmosdb" }, + { name = "azure-mgmt-databricks" }, + { name = "azure-mgmt-keyvault" }, + { name = "azure-mgmt-loganalytics" }, + { name = "azure-mgmt-monitor" }, + { name = "azure-mgmt-network" }, + { name = "azure-mgmt-postgresqlflexibleservers" }, + { name = "azure-mgmt-rdbms" }, + { name = "azure-mgmt-recoveryservices" }, + { name = "azure-mgmt-recoveryservicesbackup" }, + { name = "azure-mgmt-resource" }, + { name = "azure-mgmt-search" }, + { name = "azure-mgmt-security" }, + { name = "azure-mgmt-sql" }, + { name = "azure-mgmt-storage" }, + { name = "azure-mgmt-subscription" }, + { name = "azure-mgmt-web" }, + { name = "azure-monitor-query" }, + { name = "azure-storage-blob" }, + { name = "boto3" }, + { name = "botocore" }, + { name = "cloudflare" }, + { name = "colorama" }, + { name = "cryptography" }, + { name = "dash" }, + { name = "dash-bootstrap-components" }, + { name = "defusedxml" }, + { name = "detect-secrets" }, + { name = "dulwich" }, + { name = "google-api-python-client" }, + { name = "google-auth-httplib2" }, + { name = "h2" }, + { name = "jsonschema" }, + { name = "kubernetes" }, + { name = "markdown" }, + { name = "microsoft-kiota-abstractions" }, + { name = "msgraph-sdk" }, + { name = "numpy" }, + { name = "oci" }, + { name = "okta" }, + { name = "openstacksdk" }, + { name = "pandas" }, + { name = "py-iam-expand" }, + { name = "py-ocsf-models" }, + { name = "pydantic" }, + { name = "pygithub" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "schema" }, + { name = "shodan" }, + { name = "slack-sdk" }, + { name = "tabulate" }, + { name = "tzlocal" }, + { name = "uuid6" }, +] + +[[package]] +name = "prowler-api" +version = "1.31.0" +source = { virtual = "." } +dependencies = [ + { name = "cartography" }, + { name = "celery" }, + { name = "defusedxml" }, + { name = "dj-rest-auth", extra = ["with-social"] }, + { name = "django" }, + { name = "django-allauth", extra = ["saml"] }, + { name = "django-celery-beat" }, + { name = "django-celery-results" }, + { name = "django-cors-headers" }, + { name = "django-environ" }, + { name = "django-filter" }, + { name = "django-guid" }, + { name = "django-postgres-extra" }, + { name = "djangorestframework" }, + { name = "djangorestframework-jsonapi" }, + { name = "djangorestframework-simplejwt" }, + { name = "drf-nested-routers" }, + { name = "drf-simple-apikey" }, + { name = "drf-spectacular" }, + { name = "drf-spectacular-jsonapi" }, + { name = "fonttools" }, + { name = "gevent" }, + { name = "gunicorn" }, + { name = "h2" }, + { name = "lxml" }, + { name = "markdown" }, + { name = "matplotlib" }, + { name = "neo4j" }, + { name = "openai" }, + { name = "prowler" }, + { name = "psycopg2-binary" }, + { name = "pytest-celery", extra = ["redis"] }, + { name = "reportlab" }, + { name = "sentry-sdk", extra = ["django"] }, + { name = "sqlparse" }, + { name = "uuid6" }, + { name = "werkzeug" }, + { name = "xmlsec" }, +] + +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "coverage" }, + { name = "django-silk" }, + { name = "docker" }, + { name = "filelock" }, + { name = "freezegun" }, + { name = "mypy" }, + { name = "prek" }, + { name = "pylint" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-django" }, + { name = "pytest-env" }, + { name = "pytest-randomly" }, + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "tqdm" }, + { name = "vulture" }, +] + +[package.metadata] +requires-dist = [ + { name = "cartography", specifier = "==0.135.0" }, + { name = "celery", specifier = "==5.6.2" }, + { name = "defusedxml", specifier = "==0.7.1" }, + { name = "dj-rest-auth", extras = ["with-social", "jwt"], specifier = "==7.0.1" }, + { name = "django", specifier = "==5.1.15" }, + { name = "django-allauth", extras = ["saml"], specifier = "==65.15.0" }, + { name = "django-celery-beat", specifier = "==2.9.0" }, + { name = "django-celery-results", specifier = "==2.6.0" }, + { name = "django-cors-headers", specifier = "==4.4.0" }, + { name = "django-environ", specifier = "==0.11.2" }, + { name = "django-filter", specifier = "==24.3" }, + { name = "django-guid", specifier = "==3.5.0" }, + { name = "django-postgres-extra", specifier = "==2.0.9" }, + { name = "djangorestframework", specifier = "==3.15.2" }, + { name = "djangorestframework-jsonapi", specifier = "==7.0.2" }, + { name = "djangorestframework-simplejwt", specifier = "==5.5.1" }, + { name = "drf-nested-routers", specifier = "==0.95.0" }, + { name = "drf-simple-apikey", specifier = "==2.2.1" }, + { name = "drf-spectacular", specifier = "==0.27.2" }, + { name = "drf-spectacular-jsonapi", specifier = "==0.5.1" }, + { name = "fonttools", specifier = "==4.62.1" }, + { name = "gevent", specifier = "==25.9.1" }, + { name = "gunicorn", specifier = "==23.0.0" }, + { name = "h2", specifier = "==4.3.0" }, + { name = "lxml", specifier = "==6.1.0" }, + { name = "markdown", specifier = "==3.10.2" }, + { name = "matplotlib", specifier = "==3.10.8" }, + { name = "neo4j", specifier = "==6.1.0" }, + { name = "openai", specifier = "==1.109.1" }, + { name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" }, + { name = "psycopg2-binary", specifier = "==2.9.9" }, + { name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" }, + { name = "reportlab", specifier = "==4.4.10" }, + { name = "sentry-sdk", extras = ["django"], specifier = "==2.56.0" }, + { name = "sqlparse", specifier = "==0.5.5" }, + { name = "uuid6", specifier = "==2024.7.10" }, + { name = "werkzeug", specifier = "==3.1.7" }, + { name = "xmlsec", specifier = "==1.3.17" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = "==1.7.9" }, + { name = "coverage", specifier = "==7.5.4" }, + { name = "django-silk", specifier = "==5.3.2" }, + { name = "docker", specifier = "==7.1.0" }, + { name = "filelock", specifier = "==3.20.3" }, + { name = "freezegun", specifier = "==1.5.1" }, + { name = "mypy", specifier = "==1.10.1" }, + { name = "prek", specifier = "==0.3.9" }, + { name = "pylint", specifier = "==3.2.5" }, + { name = "pytest", specifier = "==9.0.3" }, + { name = "pytest-cov", specifier = "==5.0.0" }, + { name = "pytest-django", specifier = "==4.8.0" }, + { name = "pytest-env", specifier = "==1.1.3" }, + { name = "pytest-randomly", specifier = "==3.15.0" }, + { name = "pytest-xdist", specifier = "==3.6.1" }, + { name = "ruff", specifier = "==0.5.0" }, + { name = "tqdm", specifier = "==4.67.1" }, + { name = "vulture", specifier = "==2.14" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/07/e720e53bfab016ebcc34241695ccc06a9e3d91ba19b40ca81317afbdc440/psycopg2-binary-2.9.9.tar.gz", hash = "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c", size = 384973, upload-time = "2023-10-03T12:48:55.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/ac/702d300f3df169b9d0cbef0340d9f34a78bc18dc2dbafbcb39ff0f165cf8/psycopg2_binary-2.9.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ee825e70b1a209475622f7f7b776785bd68f34af6e7a46e2e42f27b659b5bc26", size = 2822581, upload-time = "2023-10-03T12:46:30.64Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1f/a6cf0cdf944253f7c45d90fbc876cc8bed5cc9942349306245715c0d88d6/psycopg2_binary-2.9.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ea665f8ce695bcc37a90ee52de7a7980be5161375d42a0b6c6abedbf0d81f0f", size = 2552633, upload-time = "2023-10-03T12:46:32.808Z" }, + { url = "https://files.pythonhosted.org/packages/81/0b/3adf561107c865928455891156d1dde5325253f7f4316fe56cd2c3f73570/psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:143072318f793f53819048fdfe30c321890af0c3ec7cb1dfc9cc87aa88241de2", size = 2851075, upload-time = "2023-10-03T12:46:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/f7/98/c2fedcbf0a9607519a010dcf88571138b2251062dbde3610cdba5ba1eee1/psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c332c8d69fb64979ebf76613c66b985414927a40f8defa16cf1bc028b7b0a7b0", size = 3080509, upload-time = "2023-10-03T12:46:37.44Z" }, + { url = "https://files.pythonhosted.org/packages/c2/05/81e8bc7fca95574c9323e487d9ce1b58a4cfcc17f89b8fe843af46361211/psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7fc5a5acafb7d6ccca13bfa8c90f8c51f13d8fb87d95656d3950f0158d3ce53", size = 3264303, upload-time = "2023-10-03T12:46:40.73Z" }, + { url = "https://files.pythonhosted.org/packages/ce/85/62825cabc6aad53104b7b6d12eb2ad74737d268630032d07b74d4444cb72/psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:977646e05232579d2e7b9c59e21dbe5261f403a88417f6a6512e70d3f8a046be", size = 3019515, upload-time = "2023-10-03T12:46:43.038Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b0/9ca2b8e01a0912c9a14234fd5df7a241a1e44778c5797bf4b8eaa8dc3d3a/psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b6356793b84728d9d50ead16ab43c187673831e9d4019013f1402c41b1db9b27", size = 2355892, upload-time = "2023-10-03T12:46:45.632Z" }, + { url = "https://files.pythonhosted.org/packages/73/17/ba28bb0022db5e2015a82d2df1c4b0d419c37fa07a588b3aff3adc4939f6/psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bc7bb56d04601d443f24094e9e31ae6deec9ccb23581f75343feebaf30423359", size = 2534903, upload-time = "2023-10-03T12:46:47.934Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/b463556409cdc12791cd8b1dae0072bf8efe817ef68b7ea3d9cf7d0e5656/psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:77853062a2c45be16fd6b8d6de2a99278ee1d985a7bd8b103e97e41c034006d2", size = 2486597, upload-time = "2023-10-03T12:46:50.598Z" }, + { url = "https://files.pythonhosted.org/packages/92/57/96576e07132d7f7a1ac1df939575e6fdd8951aea337ee152b586bb51a971/psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:78151aa3ec21dccd5cdef6c74c3e73386dcdfaf19bced944169697d7ac7482fc", size = 2454908, upload-time = "2023-10-03T12:46:52.903Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ae/cedd56e1f4a2b0e37213283caf3733a875c4c76f3372241e19c0d2a87355/psycopg2_binary-2.9.9-cp311-cp311-win32.whl", hash = "sha256:dc4926288b2a3e9fd7b50dc6a1909a13bbdadfc67d93f3374d984e56f885579d", size = 1024240, upload-time = "2023-10-03T12:46:55.009Z" }, + { url = "https://files.pythonhosted.org/packages/25/1f/7ae31759142999a8d06b3e250c1346c4abcdcada8fa884376775dc1de686/psycopg2_binary-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:b76bedd166805480ab069612119ea636f5ab8f8771e640ae103e05a4aae3e417", size = 1163655, upload-time = "2023-10-03T12:46:57.038Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d0/5f2db14e7b53552276ab613399a83f83f85b173a862d3f20580bc7231139/psycopg2_binary-2.9.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf", size = 2823784, upload-time = "2023-10-03T12:47:00.404Z" }, + { url = "https://files.pythonhosted.org/packages/18/ca/da384fd47233e300e3e485c90e7aab5d7def896d1281239f75901faf87d4/psycopg2_binary-2.9.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d", size = 2553308, upload-time = "2023-11-01T10:40:33.984Z" }, + { url = "https://files.pythonhosted.org/packages/50/66/fa53d2d3d92f6e1ef469d92afc6a4fe3f6e8a9a04b687aa28fb1f1d954ee/psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212", size = 2851283, upload-time = "2023-10-03T12:47:02.736Z" }, + { url = "https://files.pythonhosted.org/packages/04/37/2429360ac5547378202db14eec0dde76edbe1f6627df5a43c7e164922859/psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493", size = 3081839, upload-time = "2023-10-03T12:47:05.027Z" }, + { url = "https://files.pythonhosted.org/packages/62/2a/c0530b59d7e0d09824bc2102ecdcec0456b8ca4d47c0caa82e86fce3ed4c/psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996", size = 3264488, upload-time = "2023-10-03T12:47:08.962Z" }, + { url = "https://files.pythonhosted.org/packages/19/57/9f172b900795ea37246c78b5f52e00f4779984370855b3e161600156906d/psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119", size = 3020700, upload-time = "2023-10-03T12:47:12.23Z" }, + { url = "https://files.pythonhosted.org/packages/94/68/1176fc14ea76861b7b8360be5176e87fb20d5091b137c76570eb4e237324/psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba", size = 2355968, upload-time = "2023-10-03T12:47:14.817Z" }, + { url = "https://files.pythonhosted.org/packages/70/bb/aec2646a705a09079d008ce88073401cd61fc9b04f92af3eb282caa3a2ec/psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07", size = 2536101, upload-time = "2023-10-03T12:47:17.454Z" }, + { url = "https://files.pythonhosted.org/packages/14/33/12818c157e333cb9d9e6753d1b2463b6f60dbc1fade115f8e4dc5c52cac4/psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb", size = 2487064, upload-time = "2023-10-03T12:47:20.717Z" }, + { url = "https://files.pythonhosted.org/packages/56/a2/7851c68fe8768f3c9c246198b6356ee3e4a8a7f6820cc798443faada3400/psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe", size = 2456257, upload-time = "2023-10-03T12:47:23.004Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ee/3ba07c6dc7c3294e717e94720da1597aedc82a10b1b180203ce183d4631a/psycopg2_binary-2.9.9-cp312-cp312-win32.whl", hash = "sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93", size = 1024709, upload-time = "2023-10-28T09:37:24.991Z" }, + { url = "https://files.pythonhosted.org/packages/7b/08/9c66c269b0d417a0af9fb969535f0371b8c538633535a7a6a5ca3f9231e2/psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab", size = 1163864, upload-time = "2023-10-28T09:37:28.155Z" }, +] + +[[package]] +name = "py-deviceid" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/fe/1beb99282853f4f6fd32af50dc1f77d15e8883627bf5014a14a7eb024963/py_deviceid-0.1.1.tar.gz", hash = "sha256:c3e7577ada23666e7f39e69370dfdaa76fe9de79c02635376d6aa0229bfa30e3", size = 5023, upload-time = "2025-03-07T17:51:25.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/a5/a598864cd753af149a4714482da1396d3f772b089d1a26579a3e4545b7a3/py_deviceid-0.1.1-py3-none-any.whl", hash = "sha256:c0e32815e87a08087a0811c18f4402ee88b28a321f997753d75ecdaab570321b", size = 5735, upload-time = "2025-03-07T17:51:25.94Z" }, +] + +[[package]] +name = "py-iam-expand" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "iamdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/99/8d31a30b37825577275bb3663885b55075fba80257fcd6813b85d3aaffa8/py_iam_expand-0.1.0.tar.gz", hash = "sha256:5a2884dc267ac59a02c3a80fefc0b34c309dac681baa0f87c436067c6cf53a96", size = 10228, upload-time = "2025-04-30T07:15:35.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/19/482c2e0768cda7afaed07918e4fbd951e2418255fb5d1d9b35b284871716/py_iam_expand-0.1.0-py3-none-any.whl", hash = "sha256:b845ce7b50ac895b02b4f338e09c62a68ea51849794f76e189b02009bd388510", size = 12522, upload-time = "2025-04-30T07:15:33.799Z" }, +] + +[[package]] +name = "py-ocsf-models" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "email-validator" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/70/61e2f9ce3d7e83aa5339ed6ae17e473c15c7a36f161c6dbea0e939e3af0c/py_ocsf_models-0.8.1.tar.gz", hash = "sha256:c9045237857f951e073c9f9d1f57954c90d86875b469260725292d47f7a7d73c", size = 36540, upload-time = "2026-02-12T16:50:15.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/18/63790884bf33f820e2c60f8d5038b5d6de967a03343ddf237c054e1d6d08/py_ocsf_models-0.8.1-py3-none-any.whl", hash = "sha256:061eb446c4171534c09a8b37f5a9d2a2fe9f87c5db32edbd1182446bc5fd097e", size = 64354, upload-time = "2026-02-12T16:50:12.983Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +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/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 = "pycryptodomex" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/85/e24bf90972a30b0fcd16c73009add1d7d7cd9140c2498a68252028899e41/pycryptodomex-3.23.0.tar.gz", hash = "sha256:71909758f010c82bc99b0abf4ea12012c98962fbf0583c2164f8b84533c2e4da", size = 4922157, upload-time = "2025-05-17T17:23:41.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/9c/1a8f35daa39784ed8adf93a694e7e5dc15c23c741bbda06e1d45f8979e9e/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:06698f957fe1ab229a99ba2defeeae1c09af185baa909a31a5d1f9d42b1aaed6", size = 2499240, upload-time = "2025-05-17T17:22:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/7a/62/f5221a191a97157d240cf6643747558759126c76ee92f29a3f4aee3197a5/pycryptodomex-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2c2537863eccef2d41061e82a881dcabb04944c5c06c5aa7110b577cc487545", size = 1644042, upload-time = "2025-05-17T17:22:49.098Z" }, + { url = "https://files.pythonhosted.org/packages/8c/fd/5a054543c8988d4ed7b612721d7e78a4b9bf36bc3c5ad45ef45c22d0060e/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43c446e2ba8df8889e0e16f02211c25b4934898384c1ec1ec04d7889c0333587", size = 2186227, upload-time = "2025-05-17T17:22:51.139Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/8862616a85cf450d2822dbd4fff1fcaba90877907a6ff5bc2672cafe42f8/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f489c4765093fb60e2edafdf223397bc716491b2b69fe74367b70d6999257a5c", size = 2272578, upload-time = "2025-05-17T17:22:53.676Z" }, + { url = "https://files.pythonhosted.org/packages/46/9f/bda9c49a7c1842820de674ab36c79f4fbeeee03f8ff0e4f3546c3889076b/pycryptodomex-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bdc69d0d3d989a1029df0eed67cc5e8e5d968f3724f4519bd03e0ec68df7543c", size = 2312166, upload-time = "2025-05-17T17:22:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/03/cc/870b9bf8ca92866ca0186534801cf8d20554ad2a76ca959538041b7a7cf4/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6bbcb1dd0f646484939e142462d9e532482bc74475cecf9c4903d4e1cd21f003", size = 2185467, upload-time = "2025-05-17T17:22:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/96/e3/ce9348236d8e669fea5dd82a90e86be48b9c341210f44e25443162aba187/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:8a4fcd42ccb04c31268d1efeecfccfd1249612b4de6374205376b8f280321744", size = 2346104, upload-time = "2025-05-17T17:23:02.112Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e869bcee87beb89040263c416a8a50204f7f7a83ac11897646c9e71e0daf/pycryptodomex-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55ccbe27f049743a4caf4f4221b166560d3438d0b1e5ab929e07ae1702a4d6fd", size = 2271038, upload-time = "2025-05-17T17:23:04.872Z" }, + { url = "https://files.pythonhosted.org/packages/8d/67/09ee8500dd22614af5fbaa51a4aee6e342b5fa8aecf0a6cb9cbf52fa6d45/pycryptodomex-3.23.0-cp37-abi3-win32.whl", hash = "sha256:189afbc87f0b9f158386bf051f720e20fa6145975f1e76369303d0f31d1a8d7c", size = 1771969, upload-time = "2025-05-17T17:23:07.115Z" }, + { url = "https://files.pythonhosted.org/packages/69/96/11f36f71a865dd6df03716d33bd07a67e9d20f6b8d39820470b766af323c/pycryptodomex-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:52e5ca58c3a0b0bd5e100a9fbc8015059b05cffc6c66ce9d98b4b45e023443b9", size = 1803124, upload-time = "2025-05-17T17:23:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/f9/93/45c1cdcbeb182ccd2e144c693eaa097763b08b38cded279f0053ed53c553/pycryptodomex-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:02d87b80778c171445d67e23d1caef279bf4b25c3597050ccd2e13970b57fd51", size = 1707161, upload-time = "2025-05-17T17:23:11.414Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydash" +version = "8.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/c1/1c55272f49d761cec38ddb80be9817935b9c91ebd6a8988e10f532868d56/pydash-8.0.6.tar.gz", hash = "sha256:b2821547e9723f69cf3a986be4db64de41730be149b2641947ecd12e1e11025a", size = 164338, upload-time = "2026-01-17T16:42:56.576Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/b7/cc5e7974699db40014d58c7dd7c4ad4ffc244d36930dc9ec7d06ee67d7a9/pydash-8.0.6-py3-none-any.whl", hash = "sha256:ee70a81a5b292c007f28f03a4ee8e75c1f5d7576df5457b836ec7ab2839cc5d0", size = 101561, upload-time = "2026-01-17T16:42:55.448Z" }, +] + +[[package]] +name = "pygithub" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "pynacl" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/a3/cd19cd08ddd34f35040d8ab9577262b296f014856089c821d677061e5bd1/pygithub-2.8.0.tar.gz", hash = "sha256:72f5f2677d86bc3a8843aa720c6ce4c1c42fb7500243b136e3d5e14ddb5c3386", size = 2246155, upload-time = "2025-09-02T09:24:19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/d0/136d47b7a523f93175c0608da7437dda57a98169070aa5d0b632c1fef5d8/pygithub-2.8.0-py3-none-any.whl", hash = "sha256:11a3473c1c2f1c39c525d0ee8c559f369c6d46c272cb7321c9b0cabc7aa1ce7d", size = 432572, upload-time = "2025-09-02T09:24:17.314Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +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/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.13.0" +source = { registry = "https://pypi.org/simple" } +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/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] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pylint" +version = "3.2.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/f3/e552877a02574b7919855a8d1f372591e67d276ea880c079968e7b3ba353/pylint-3.2.5.tar.gz", hash = "sha256:e9b7171e242dcc6ebd0aaa7540481d1a72860748a0a7816b8fe6cf6c80a6fe7e", size = 1508793, upload-time = "2024-06-28T13:10:26.915Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/ff/7f52c1461d8ceaefa989d2700a027f84427879bb7571145bbffdec5d5f4a/pylint-3.2.5-py3-none-any.whl", hash = "sha256:32cd6c042b5004b8e857d727708720c54a676d1e22917cf1a2df9b4d4868abd6", size = 519603, upload-time = "2024-06-28T13:10:23.526Z" }, +] + +[[package]] +name = "pymsalruntime" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/f9/9691cd100cf311f6e526d6e88bd20144dded21f5206dbb7d1b12dbe1bb67/pymsalruntime-0.18.1-cp311-cp311-win32.whl", hash = "sha256:74416947b1071054f3258cac3448a7adf708888727bf283267df2bb27f0998f1", size = 1094568, upload-time = "2025-03-13T18:12:28.926Z" }, + { url = "https://files.pythonhosted.org/packages/0f/d1/eacd266809a276c8d4da33268c751cb407ac90e6aee8185f1cccb05ec794/pymsalruntime-0.18.1-cp311-cp311-win_amd64.whl", hash = "sha256:beb926655aae3367b7e4bda2baad86f9271beefee1121f71642da0ed4de37fd2", size = 1246912, upload-time = "2025-03-13T18:12:14.264Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d3/0399d685a7c544dd6792736a8f0dbb66e66a452dd5c53134cdff2ea39477/pymsalruntime-0.18.1-cp312-cp312-win32.whl", hash = "sha256:ace12bf9b7fcbf1bf21a03c227717e09ba99acd9190623fe0821a08832ece4eb", size = 1093430, upload-time = "2025-03-13T18:12:31.525Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cc/22bb46a8c5a17c815a50e82b430808ebe970239d41a3b5851fb9b86b4912/pymsalruntime-0.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:f9fd8ea52395f52f7d62498e47754adf2bfe6530816ff57eff1ba6f524aee51b", size = 1245309, upload-time = "2025-03-13T18:12:16.771Z" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + +[[package]] +name = "pyopenssl" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyreadline3" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + +[[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 = "pytest-celery" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "celery" }, + { name = "debugpy" }, + { name = "docker" }, + { name = "kombu" }, + { name = "psutil" }, + { name = "pytest-docker-tools" }, + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/0b/775fdabc659b21b6d03162141d9f2c1485eb9f8b0de725017a5c85f2364c/pytest_celery-1.3.0.tar.gz", hash = "sha256:bd9e5b0f594ec5de9ab97cf27e3a11c644718a761bab6b997d01800fd7394f64", size = 28149, upload-time = "2026-03-02T19:58:44.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/52/929eac8a21b91850165c7340d06df20f9ee7cbea332b71c95a0e15e8b9ab/pytest_celery-1.3.0-py3-none-any.whl", hash = "sha256:f02201d7770584a0c412a1ded329a142170c24012467c7046f2c72cc8205ad5d", size = 49312, upload-time = "2026-03-02T19:58:46.45Z" }, +] + +[package.optional-dependencies] +redis = [ + { name = "redis" }, +] + +[[package]] +name = "pytest-cov" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042, upload-time = "2024-03-24T20:16:34.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990, upload-time = "2024-03-24T20:16:32.444Z" }, +] + +[[package]] +name = "pytest-django" +version = "4.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/cf/44510ac5479f281d6663a08dff0d93f56b21f4ee091980ea4d4b64491ad6/pytest-django-4.8.0.tar.gz", hash = "sha256:5d054fe011c56f3b10f978f41a8efb2e5adfc7e680ef36fb571ada1f24779d90", size = 83291, upload-time = "2024-01-30T13:14:19.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/5b/29555191e903881d05e1f7184205ec534c7021e0ee077d1e6a1ee8f1b1eb/pytest_django-4.8.0-py3-none-any.whl", hash = "sha256:ca1ddd1e0e4c227cf9e3e40a6afc6d106b3e70868fd2ac5798a22501271cd0c7", size = 23432, upload-time = "2024-01-30T13:14:17.953Z" }, +] + +[[package]] +name = "pytest-docker-tools" +version = "3.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/2a/4d68472c2bac257c4e7389b5ca3a6b6cd7da88bce012bed321fdd31372ae/pytest_docker_tools-3.1.9.tar.gz", hash = "sha256:1b6a0cb633c20145731313335ef15bcf5571839c06726764e60cbe495324782b", size = 42824, upload-time = "2025-03-16T13:48:23.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/79/9dae84c244dabebca6a952e098d6ac9d13719b701fc5323ba6d00abc675a/pytest_docker_tools-3.1.9-py2.py3-none-any.whl", hash = "sha256:36f8e88d56d84ea177df68a175673681243dd991d2807fbf551d90f60341bfdb", size = 29268, upload-time = "2025-03-16T13:48:22.184Z" }, +] + +[[package]] +name = "pytest-env" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/cc/df6940b2527bfa634c00940dfb6e3ec873bdfb7507b55894c93283fa3178/pytest_env-1.1.3.tar.gz", hash = "sha256:fcd7dc23bb71efd3d35632bde1bbe5ee8c8dc4489d6617fb010674880d96216b", size = 8627, upload-time = "2023-11-28T04:11:27.607Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/b2/bdc663a5647ce2034f7e8420122af340df87c01ba97745fc753b8c917acb/pytest_env-1.1.3-py3-none-any.whl", hash = "sha256:aada77e6d09fcfb04540a6e462c58533c37df35fa853da78707b17ec04d17dfc", size = 6154, upload-time = "2023-11-28T04:11:25.923Z" }, +] + +[[package]] +name = "pytest-randomly" +version = "3.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/d4/6e924a0b2855736d942703dec88dfc98b4fe0881c8fa849b6b0fbb9182fa/pytest_randomly-3.15.0.tar.gz", hash = "sha256:b908529648667ba5e54723088edd6f82252f540cc340d748d1fa985539687047", size = 21743, upload-time = "2023-08-15T18:04:59.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/d3/00e575657422055c4ea220b2f80e8cc6026ab7130372b7067444d1b0ac10/pytest_randomly-3.15.0-py3-none-any.whl", hash = "sha256:0516f4344b29f4e9cdae8bce31c4aeebf59d0b9ef05927c33354ff3859eeeca6", size = 8685, upload-time = "2023-08-15T18:04:57.913Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/c4/3c310a19bc1f1e9ef50075582652673ef2bfc8cd62afef9585683821902f/pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d", size = 84060, upload-time = "2024-04-28T19:29:54.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7", size = 46108, upload-time = "2024-04-28T19:29:52.813Z" }, +] + +[[package]] +name = "python-crontab" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/7f/c54fb7e70b59844526aa4ae321e927a167678660ab51dda979955eafb89a/python_crontab-3.3.0.tar.gz", hash = "sha256:007c8aee68dddf3e04ec4dce0fac124b93bd68be7470fc95d2a9617a15de291b", size = 57626, upload-time = "2025-07-13T20:05:35.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/42/bb4afa5b088f64092036221843fc989b7db9d9d302494c1f8b024ee78a46/python_crontab-3.3.0-py3-none-any.whl", hash = "sha256:739a778b1a771379b75654e53fd4df58e5c63a9279a63b5dfe44c0fcc3ee7884", size = 27533, upload-time = "2025-07-13T20:05:34.266Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-digitalocean" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpickle" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/f7/43cb73fb393c4c0da36294b6040c7424bc904042d55c1b37c73ecc9e7714/python-digitalocean-1.17.0.tar.gz", hash = "sha256:107854fde1aafa21774e8053cf253b04173613c94531f75d5a039ad770562b24", size = 33586, upload-time = "2021-10-02T21:05:21.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/4f/87c17d4e23a62273c192656300e92a26bf6d5d8b2908cf2fc4a5a96da4b2/python_digitalocean-1.17.0-py3-none-any.whl", hash = "sha256:0032168e022e85fca314eb3f8dfaabf82087f2ed40839eb28f1eeeeca5afb1fa", size = 40298, upload-time = "2021-10-02T21:05:20.806Z" }, +] + +[[package]] +name = "python3-saml" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "isodate" }, + { name = "lxml" }, + { name = "xmlsec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/98/6e0268c3a9893af3d4c5cf670183e0314cd6b5cb034a612d6a7cc5060df8/python3-saml-1.16.0.tar.gz", hash = "sha256:97c9669aecabc283c6e5fb4eb264f446b6e006f5267d01c9734f9d8bffdac133", size = 83468, upload-time = "2023-10-09T10:37:43.128Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/14/49d9828443b58bd5cc80a454c91b0f867fbf36a24975d501945e6cb9e32f/python3_saml-1.16.0-py3-none-any.whl", hash = "sha256:20b97d11b04f01ee22e98f4a38242e2fea2e28fbc7fbc9bdd57cab5ac7fc2d0d", size = 76155, upload-time = "2023-10-09T10:40:34.001Z" }, +] + +[[package]] +name = "pytz" +version = "2025.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/57/df1c9157c8d5a05117e455d66fd7cf6dbc46974f832b1058ed4856785d8a/pytz-2025.1.tar.gz", hash = "sha256:c2db42be2a2518b28e65f9207c4d05e6ff547d1efa4086469ef855e4ab70178e", size = 319617, upload-time = "2025-01-31T01:54:48.615Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/38/ac33370d784287baa1c3d538978b5e2ea064d4c1b93ffbd12826c190dd10/pytz-2025.1-py2.py3-none-any.whl", hash = "sha256:89dd22dca55b46eac6eda23b2d72721bf1bdfef212645d81513ef5d03038de57", size = 507930, upload-time = "2025-01-31T01:54:45.634Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +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/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { 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" }, +] + +[[package]] +name = "redis" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +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" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +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/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 = "reportlab" +version = "4.4.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/57/28bfbf0a775b618b6e4d854ef8dd3f5c8988e5d614d8898703502a35f61c/reportlab-4.4.10.tar.gz", hash = "sha256:5cbbb34ac3546039d0086deb2938cdec06b12da3cdb836e813258eb33cd28487", size = 3714962, upload-time = "2026-02-12T10:45:21.325Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/2e/e1798b8b248e1517e74c6cdf10dd6edd485044e7edf46b5f11ffcc5a0add/reportlab-4.4.10-py3-none-any.whl", hash = "sha256:5abc815746ae2bc44e7ff25db96814f921349ca814c992c7eac3c26029bf7c24", size = 1955400, upload-time = "2026-02-12T10:45:18.828Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + +[[package]] +name = "requests-file" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/f8/5dc70102e4d337063452c82e1f0d95e39abfe67aa222ed8a5ddeb9df8de8/requests_file-3.0.1.tar.gz", hash = "sha256:f14243d7796c588f3521bd423c5dea2ee4cc730e54a3cac9574d78aca1272576", size = 6967, upload-time = "2025-10-20T18:56:42.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/d5/de8f089119205a09da657ed4784c584ede8381a0ce6821212a6d4ca47054/requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2", size = 4514, upload-time = "2025-10-20T18:56:41.184Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "requestsexceptions" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/61b9652d3256503c99b0b8f145d9c8aa24c514caff6efc229989505937c1/requestsexceptions-1.4.0.tar.gz", hash = "sha256:b095cbc77618f066d459a02b137b020c37da9f46d9b057704019c9f77dba3065", size = 6880, upload-time = "2018-02-01T17:04:45.294Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/8c/49ca60ea8c907260da4662582c434bec98716177674e88df3fd340acf06d/requestsexceptions-1.4.0-py2.py3-none-any.whl", hash = "sha256:3083d872b6e07dc5c323563ef37671d992214ad9a32b0ca4a3d7f5500bf38ce3", size = 3802, upload-time = "2018-02-01T17:04:39.07Z" }, +] + +[[package]] +name = "retrying" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411, upload-time = "2025-08-03T03:35:25.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" }, +] + +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +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/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { 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/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "ruff" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/9a/dde343d95ecd0747207e4e8d143c373ef961cbd6b78c61a659f67582dbd2/ruff-0.5.0.tar.gz", hash = "sha256:eb641b5873492cf9bd45bc9c5ae5320648218e04386a5f0c264ad6ccce8226a1", size = 2587996, upload-time = "2024-06-27T15:42:16.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/5d/0d9510720d61df753df39bf24a96d6c141080c94fe6025568747fbea856a/ruff-0.5.0-py3-none-linux_armv6l.whl", hash = "sha256:ee770ea8ab38918f34e7560a597cc0a8c9a193aaa01bfbd879ef43cb06bd9c4c", size = 9434156, upload-time = "2024-06-27T15:40:40.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/5a/7f466f5449dce168c2d956ad4a207d62dc7b76836d46f1c04249a4daaf34/ruff-0.5.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:38f3b8327b3cb43474559d435f5fa65dacf723351c159ed0dc567f7ab735d1b6", size = 8536948, upload-time = "2024-06-27T15:40:47.907Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a2/afc6952d5a0199e7e6c0a2051d6f4780fb70376f5bd07f27838f8bc0cf47/ruff-0.5.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7594f8df5404a5c5c8f64b8311169879f6cf42142da644c7e0ba3c3f14130370", size = 8107163, upload-time = "2024-06-27T15:41:06.887Z" }, + { url = "https://files.pythonhosted.org/packages/34/54/ea77237405b7573298f5cc00045d1aceab609841d3cc88de3d7c3d2a6163/ruff-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:adc7012d6ec85032bc4e9065110df205752d64010bed5f958d25dbee9ce35de3", size = 9877009, upload-time = "2024-06-27T15:41:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/3f74873bc0ca915f79d26575e549eb5e633022d56315d314e6f9c0fa596a/ruff-0.5.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d505fb93b0fabef974b168d9b27c3960714d2ecda24b6ffa6a87ac432905ea38", size = 9219926, upload-time = "2024-06-27T15:41:15.032Z" }, + { url = "https://files.pythonhosted.org/packages/57/08/1052c80f3f44321631a8c1337e55883dd7a7b02b4efe5c9282258db42358/ruff-0.5.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9dc5cfd3558f14513ed0d5b70ce531e28ea81a8a3b1b07f0f48421a3d9e7d80a", size = 10031146, upload-time = "2024-06-27T15:41:20.601Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a2/f7c01c4a02b87998c9e1379ec8d7345d6a45f8b34e326e8700c13da391c3/ruff-0.5.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:db3ca35265de239a1176d56a464b51557fce41095c37d6c406e658cf80bbb362", size = 10770796, upload-time = "2024-06-27T15:41:24.665Z" }, + { url = "https://files.pythonhosted.org/packages/12/a1/5f45ab0948a202da7fe13c6e0678f907bd88caacc7e4f4909603d3774051/ruff-0.5.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b1a321c4f68809fddd9b282fab6a8d8db796b270fff44722589a8b946925a2a8", size = 10364804, upload-time = "2024-06-27T15:41:29.153Z" }, + { url = "https://files.pythonhosted.org/packages/7e/40/83f88d5bda41496a90871ec82dd82545def4c4683e1c2f4a42f5a168ae3e/ruff-0.5.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c4dfcd8d34b143916994b3876b63d53f56724c03f8c1a33a253b7b1e6bf2a7d", size = 11241308, upload-time = "2024-06-27T15:41:33.569Z" }, + { url = "https://files.pythonhosted.org/packages/af/79/8a57016a761d11491b913460a3d1545cdbe96dca6acb1279102814c9147b/ruff-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81e5facfc9f4a674c6a78c64d38becfbd5e4f739c31fcd9ce44c849f1fad9e4c", size = 10064506, upload-time = "2024-06-27T15:41:38.21Z" }, + { url = "https://files.pythonhosted.org/packages/67/34/fd7cd8be0d8cd4bcce0dbef807933f6c9685d5dc2549b729da7ee7a7a5cc/ruff-0.5.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e589e27971c2a3efff3fadafb16e5aef7ff93250f0134ec4b52052b673cf988d", size = 9866155, upload-time = "2024-06-27T15:41:42.551Z" }, + { url = "https://files.pythonhosted.org/packages/7b/54/8a654417265fe91de3ff303274a9d4d64774496eaa2eadd7da8e88a48b82/ruff-0.5.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2ffbc3715a52b037bcb0f6ff524a9367f642cdc5817944f6af5479bbb2eb50e", size = 9285874, upload-time = "2024-06-27T15:41:46.991Z" }, + { url = "https://files.pythonhosted.org/packages/86/39/564161e306b12ab40d2b6be0a0bc843c692a8295cc7101fa930db89e1e7e/ruff-0.5.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cd096e23c6a4f9c819525a437fa0a99d1c67a1b6bb30948d46f33afbc53596cf", size = 9645133, upload-time = "2024-06-27T15:41:52.535Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/3203d56ee41d3dee8d94c7926b298b13a150f105a55fef38b75ccf5e0901/ruff-0.5.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:46e193b36f2255729ad34a49c9a997d506e58f08555366b2108783b3064a0e1e", size = 10143022, upload-time = "2024-06-27T15:41:56.879Z" }, + { url = "https://files.pythonhosted.org/packages/71/2e/1bab3c5a3929f348cdc086a3f3013ea0b8823ec3d273f3334ef621f4f83f/ruff-0.5.0-py3-none-win32.whl", hash = "sha256:49141d267100f5ceff541b4e06552e98527870eafa1acc9dec9139c9ec5af64c", size = 7735210, upload-time = "2024-06-27T15:42:02.173Z" }, + { url = "https://files.pythonhosted.org/packages/48/05/04bf25784ba73abf0e639065fd7a785c005c895c4bf64aa2729d26a1984f/ruff-0.5.0-py3-none-win_amd64.whl", hash = "sha256:e9118f60091047444c1b90952736ee7b1792910cab56e9b9a9ac20af94cd0440", size = 8536440, upload-time = "2024-06-27T15:42:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/63/ab/a10ab4a751514d4f954079fbd2f645cc0c5982a18f510ab411048a2a5409/ruff-0.5.0-py3-none-win_arm64.whl", hash = "sha256:ed5c4df5c1fb4518abcb57725b576659542bdbe93366f4f329e8f398c4b71178", size = 7949476, upload-time = "2024-06-27T15:42:12.464Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, +] + +[[package]] +name = "scaleway" +version = "2.10.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "scaleway-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/ce/774eea10c35fb0f028d0ae2e45d404bc16d83ac5cdd12f410d348e6dd6a4/scaleway-2.10.3.tar.gz", hash = "sha256:b1f9dd1b1450767205234c6f5a345e5e25dc039c780253d698893b5c344ce594", size = 717421, upload-time = "2025-11-04T10:03:13.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/d6/2b41dfb4cac8bf51cc100050f70129aa5284d04344ceec1391176467b1cb/scaleway-2.10.3-py3-none-any.whl", hash = "sha256:dbf381440d6caf37c878cf16445a63f4969a4aac2257c9b72c744d10ff223a0c", size = 877950, upload-time = "2025-11-04T10:03:12.128Z" }, +] + +[[package]] +name = "scaleway-core" +version = "2.10.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/6a/9f53cc92ad2332f96b48bd4975e53e15b8099a148a4e7386d64d393538d5/scaleway_core-2.10.3.tar.gz", hash = "sha256:56432f755d694669429de51d51c1d0b3361b28dc2f939b28e4cb954610ee76be", size = 27282, upload-time = "2025-11-04T10:01:41.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/d0/b6b133a27a94fc272d316f7bc8722be701130b93ee12c60c0c2413c16199/scaleway_core-2.10.3-py3-none-any.whl", hash = "sha256:fd4112144554d6adae22ff737555eeb0e38cb1063250b3e88c9aebc1b957793b", size = 33523, upload-time = "2025-11-04T10:01:40.106Z" }, +] + +[[package]] +name = "schema" +version = "0.7.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contextlib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/e8/01e1b46d9e04cdaee91c9c736d9117304df53361a191144c8eccda7f0ee9/schema-0.7.5.tar.gz", hash = "sha256:f06717112c61895cabc4707752b88716e8420a8819d71404501e114f91043197", size = 48173, upload-time = "2021-12-01T20:49:24.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/93/ca8aa5a772efd69043d0a745172d92bee027caa7565c7f774a2f44b91207/schema-0.7.5-py2.py3-none-any.whl", hash = "sha256:f3ffdeeada09ec34bf40d7d79996d9f7175db93b7a5065de0faa7f41083c1e6c", size = 17603, upload-time = "2021-12-01T20:49:21.252Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.56.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/df/5008954f5466085966468612a7d1638487596ee6d2fd7fb51783a85351bf/sentry_sdk-2.56.0.tar.gz", hash = "sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168", size = 426820, upload-time = "2026-03-24T09:56:36.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/1a/b3a3e9f6520493fed7997af4d2de7965d71549c62f994a8fd15f2ecd519e/sentry_sdk-2.56.0-py2.py3-none-any.whl", hash = "sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02", size = 451568, upload-time = "2026-03-24T09:56:34.807Z" }, +] + +[package.optional-dependencies] +django = [ + { name = "django" }, +] + +[[package]] +name = "setuptools" +version = "80.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, +] + +[[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 = "shodan" +version = "1.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "click-plugins" }, + { name = "colorama" }, + { name = "requests" }, + { name = "tldextract" }, + { name = "xlsxwriter" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/06/c6dcc975a1e7d89bc764fd271da8138b318e18080b48e7f1acd2ab63df28/shodan-1.31.0.tar.gz", hash = "sha256:c73275386ea02390e196c35c660706a28dd4d537c5a21eb387ab6236fac251f6", size = 57939, upload-time = "2023-12-17T01:42:02.426Z" } + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "slack-sdk" +version = "3.39.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/dd/645f3eb93fce38eadbb649e85684730b1fc3906c2674ca59bddc2ca2bd2e/slack_sdk-3.39.0.tar.gz", hash = "sha256:6a56be10dc155c436ff658c6b776e1c082e29eae6a771fccf8b0a235822bbcb1", size = 247207, upload-time = "2025-11-20T15:27:57.556Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/1f/32bcf088e535c1870b1a1f2e3b916129c66fdfe565a793316317241d41e5/slack_sdk-3.39.0-py2.py3-none-any.whl", hash = "sha256:b1556b2f5b8b12b94e5ea3f56c4f2c7f04462e4e1013d325c5764ff118044fa8", size = 309850, upload-time = "2025-11-20T15:27:55.729Z" }, +] + +[[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 = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "statsd" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/29/05e9f50946f4cf2ed182726c60d9c0ae523bb3f180588c574dd9746de557/statsd-4.0.1.tar.gz", hash = "sha256:99763da81bfea8daf6b3d22d11aaccb01a8d0f52ea521daab37e758a4ca7d128", size = 27814, upload-time = "2022-11-06T14:17:36.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/d0/c9543b52c067a390ae6ae632d7fd1b97a35cdc8d69d40c0b7d334b326410/statsd-4.0.1-py2.py3-none-any.whl", hash = "sha256:c2676519927f7afade3723aca9ca8ea986ef5b059556a980a867721ca69df093", size = 13118, upload-time = "2022-11-06T14:17:34.258Z" }, +] + +[[package]] +name = "std-uritemplate" +version = "2.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/62/61866776cd32df3f984ff2f79b1428e10700e0a33ca7a7536e3fcba3cf2a/std_uritemplate-2.0.8.tar.gz", hash = "sha256:138ceff2c5bfef18a650372a5e8c82fe7f780c87235513de6c342fb5f7e18347", size = 6018, upload-time = "2025-10-16T15:51:29.774Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/97/b4f2f442fee92a1406f08b4fbc990bd7d02dc84b3b5e6315a59fa9b2a9f4/std_uritemplate-2.0.8-py3-none-any.whl", hash = "sha256:839807a7f9d07f0bad1a88977c3428bd97b9ff0d229412a0bf36123d8c724257", size = 6512, upload-time = "2025-10-16T15:51:28.713Z" }, +] + +[[package]] +name = "stevedore" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/5b/496f8abebd10c3301129abba7ddafd46c71d799a70c44ab080323987c4c9/stevedore-5.6.0.tar.gz", hash = "sha256:f22d15c6ead40c5bbfa9ca54aa7e7b4a07d59b36ae03ed12ced1a54cf0b51945", size = 516074, upload-time = "2025-11-20T10:06:07.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/40/8561ce06dc46fd17242c7724ab25b257a2ac1b35f4ebf551b40ce6105cfa/stevedore-5.6.0-py3-none-any.whl", hash = "sha256:4a36dccefd7aeea0c70135526cecb7766c4c84c473b1af68db23d541b6dc1820", size = 54428, upload-time = "2025-11-20T10:06:05.946Z" }, +] + +[[package]] +name = "tabulate" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, +] + +[[package]] +name = "tldextract" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "idna" }, + { name = "requests" }, + { name = "requests-file" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/7b/644fbbb49564a6cb124a8582013315a41148dba2f72209bba14a84242bf0/tldextract-5.3.1.tar.gz", hash = "sha256:a72756ca170b2510315076383ea2993478f7da6f897eef1f4a5400735d5057fb", size = 126105, upload-time = "2025-12-28T23:58:05.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/42/0e49d6d0aac449ca71952ec5bae764af009754fcb2e76a5cc097543747b3/tldextract-5.3.1-py3-none-any.whl", hash = "sha256:6bfe36d518de569c572062b788e16a659ccaceffc486d243af0484e8ecf432d9", size = 105886, upload-time = "2025-12-28T23:58:04.071Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "typer" +version = "0.21.1" +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" } +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" }, +] + +[[package]] +name = "types-aiobotocore-ecr" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/27/fc82f9d7522d554cbf7df02a037aa9c2f4a7e978efb82f2f60e06494f8b1/types_aiobotocore_ecr-3.1.1.tar.gz", hash = "sha256:155edc63c612e1a7861fa746376a5143cc4f3ca05b60c27d68ced23e8567a344", size = 37025, upload-time = "2026-01-21T02:07:06.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/34/8cf816860041845bcff546cefaa9f5f129391dae8c67580090bad47994b2/types_aiobotocore_ecr-3.1.1-py3-none-any.whl", hash = "sha256:e5c02e06ff057bbe7821fb40ac7de67d2335fdc7987ea31392051efe81ceb69c", size = 43575, upload-time = "2026-01-21T02:07:05.422Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +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/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 = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uuid6" +version = "2024.7.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/56/2560a9f1ccab9e12b1b3478a3c870796cf4d8ee5652bb19b61751cced14a/uuid6-2024.7.10.tar.gz", hash = "sha256:2d29d7f63f593caaeea0e0d0dd0ad8129c9c663b29e19bdf882e864bedf18fb0", size = 8705, upload-time = "2024-07-10T16:39:37.592Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/3e/4ae6af487ce5781ed71d5fe10aca72e7cbc4d4f45afc31b120287082a8dd/uuid6-2024.7.10-py3-none-any.whl", hash = "sha256:93432c00ba403751f722829ad21759ff9db051dea140bf81493271e8e4dd18b7", size = 6376, upload-time = "2024-07-10T16:39:36.148Z" }, +] + +[[package]] +name = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + +[[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 = "wcwidth" +version = "0.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/62/a7c072fbfefb2980a00f99ca994279cb9ecf310cb2e6b2a4d2a28fe192b3/wcwidth-0.5.3.tar.gz", hash = "sha256:53123b7af053c74e9fe2e92ac810301f6139e64379031f7124574212fb3b4091", size = 157587, upload-time = "2026-01-31T03:52:10.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/c1/d73f12f8cdb1891334a2ccf7389eed244d3941e74d80dd220badb937f3fb/wcwidth-0.5.3-py3-none-any.whl", hash = "sha256:d584eff31cd4753e1e5ff6c12e1edfdb324c995713f75d26c29807bb84bf649e", size = 92981, upload-time = "2026-01-31T03:52:09.14Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/43/76ded108b296a49f52de6bac5192ca1c4be84e886f9b5c9ba8427d9694fd/werkzeug-3.1.7.tar.gz", hash = "sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351", size = 875700, upload-time = "2026-03-24T01:08:07.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/b2/0bba9bbb4596d2d2f285a16c2ab04118f6b957d8441566e1abb892e6a6b2/werkzeug-3.1.7-py3-none-any.whl", hash = "sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f", size = 226295, upload-time = "2026-03-24T01:08:06.133Z" }, +] + +[[package]] +name = "workos" +version = "6.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "httpx" }, + { name = "pyjwt", extra = ["crypto"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/2f/99fb8718274116c5c146c745755620fd5c5943f78ca52ca9b17e94348286/workos-6.0.4.tar.gz", hash = "sha256:b0bfe8fd212b8567422c4ea3732eb33608794033eb3a69900c6b04db183c32d6", size = 172217, upload-time = "2026-04-16T03:09:28.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/f1/d2ab661e6dc2828a4c73e38f12630c3b109cfe2bc664ab70631c04f0db4b/workos-6.0.4-py3-none-any.whl", hash = "sha256:548668b3702673536f853ba72a7b5bbbc269e467aaf9ac4f477b6e0177df5e21", size = 511418, upload-time = "2026-04-16T03:09:27.098Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "xlsxwriter" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" }, +] + +[[package]] +name = "xmlsec" +version = "1.3.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/14/538b75379e6ab8f688f14d8663e2ab138d9c778bac4999d155b5f33c71c1/xmlsec-1.3.17.tar.gz", hash = "sha256:f3fac9ae679f66585925cc00c5f6839ae36c1d03157619571dee18acc05b9c01", size = 115637, upload-time = "2025-11-11T16:20:46.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/e4/970614d892749da00df253c370230fd24143028268923a1c35651fb3f962/xmlsec-1.3.17-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4a7ee007c6b55f7621330aee8330ef2dafa4225fce554064571ca826beafe7e", size = 3450577, upload-time = "2025-11-11T16:19:34.159Z" }, + { url = "https://files.pythonhosted.org/packages/50/4a/2f48ad48fecbd49dbbc6f2a5b540cd65277089fd5b8b5d8c7e816c3625c2/xmlsec-1.3.17-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1ef656421d01851618d0fe5518e57469159c14a48e05125f7bd3225631952f9", size = 3846698, upload-time = "2025-11-11T16:19:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/0130e0b711f7443d0abdec403ea5128392cd5b241bb53f4ec41d144d94db/xmlsec-1.3.17-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80fff2251d0e73714435b5860ce200990dffe85466dd91d08d75c4d64ee9967d", size = 4423233, upload-time = "2025-11-11T16:19:37.129Z" }, + { url = "https://files.pythonhosted.org/packages/00/f7/a4e588d61f602f25a51b6004be9a162e36e746fa1cbeb12248794a96766b/xmlsec-1.3.17-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f2bf6bbf04f8a912483d268b4c2727d400d1806d054624da13bee4b9f6fa28a", size = 4163716, upload-time = "2025-11-11T16:19:38.365Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a2/f8c019445134dfc59afb5874d1fc4fe212ec2dc45a8c33806a15b5c0c119/xmlsec-1.3.17-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a603584ceee175036e1bccdbe65d551c0fff67343fd506bfa6cec52bc64d9a75", size = 3875404, upload-time = "2025-11-11T16:19:40.008Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/90c0e26bb9f95799c64874ebee0b43eaf7e5b5ba912bcd87ed4cc46ea514/xmlsec-1.3.17-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:26cc3d81437b51839946d2e93d09371dfd73ed2831dc7e37eff0fb52fc33747c", size = 4460640, upload-time = "2025-11-11T16:19:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/be/7b85b0ff4281779293d93a8bbef70a6b72ba60d8a80d15653bd4967d0c07/xmlsec-1.3.17-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d862f023f56a49c06576be41dfaf213c9ac77e7a344e7f204278c365bb36d00e", size = 4209625, upload-time = "2025-11-11T16:19:43.289Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6d/028472e523c2f667a4634881b65acfa939bc4902ed37e1e9fe1d55d45ec0/xmlsec-1.3.17-cp311-cp311-win_amd64.whl", hash = "sha256:9877303e8c72d7aa2467d1af12e56d67b8fb50d324eda5848e0ec5ee2176aac5", size = 2445935, upload-time = "2025-11-11T16:19:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/f0/01/d36fd82b837167546951e7e088dbd2f0dacf553157d256b2a25802d28a95/xmlsec-1.3.17-cp311-cp311-win_arm64.whl", hash = "sha256:b3f306f5aef47336b8299d8dbee31fa0b2eba4579f9f41396070f7a97d0dcd49", size = 2261485, upload-time = "2025-11-11T16:19:46.212Z" }, + { url = "https://files.pythonhosted.org/packages/cd/a5/d91216f7dbb85cb65cb7249fcc894f5389a8a4843857aff678646cab77fa/xmlsec-1.3.17-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df4a8d7fef3ffe90e572400d47392ea480120e339c292f802830ed09d449e622", size = 3450960, upload-time = "2025-11-11T16:19:47.794Z" }, + { url = "https://files.pythonhosted.org/packages/b7/38/c37bd4e164259e0b271fe4d17d054f31c7287a1e4c47d24ef77d723b3493/xmlsec-1.3.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed63cbd87dd69ebcf3a9f82d87b67818c9a7d656325dd4fb34d6c4dfbaa84017", size = 3846774, upload-time = "2025-11-11T16:19:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ff/83430c5df33c6ad402728a681998c5b2872c090b556a558d02f8cf1d2f24/xmlsec-1.3.17-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c3008b32a15d24b6c9da39bf6ede8dc3122570a640a73795d763aea55a2193e", size = 4425910, upload-time = "2025-11-11T16:19:50.95Z" }, + { url = "https://files.pythonhosted.org/packages/02/41/bb94c7a97ea613b3860f6152bb7efcf5be524d135592e094ecc64ff79228/xmlsec-1.3.17-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a0b9a1dcda547e0340eefa6f4a04b87dbd9e40cd514487f347934f94fd559ab", size = 4169038, upload-time = "2025-11-11T16:19:52.217Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4c/852ba0805df27b7bd1e88e9524d9573b076c3a126e936b1f18c6f22fb968/xmlsec-1.3.17-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a53c14d4bc40b0f0fcc6d7908b88f3cbbcf36e25c392f796d88aee7dee5beea", size = 3876430, upload-time = "2025-11-11T16:19:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/b0/f0/08fec6adc65f6911b49b4fa71e920c8f6434f44fdc427c71360e6dd9e9ce/xmlsec-1.3.17-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5346616e1fe1015f7800698c15225c7902f45db199e217af2039a21989aff7e9", size = 4464419, upload-time = "2025-11-11T16:19:54.777Z" }, + { url = "https://files.pythonhosted.org/packages/25/ce/84789ba3929715806deae88f10bc31e1ff904aa735059ee3855c104a142d/xmlsec-1.3.17-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:64c1184d51c8a67e3d1eb3ac477e307a07e2b40fd03cd0c8084b147ea0f342db", size = 4215080, upload-time = "2025-11-11T16:19:56.293Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6e/57b5054187cd2b42e5310dc1f6d209fced456f93dae25345a422b3a290ef/xmlsec-1.3.17-cp312-cp312-win_amd64.whl", hash = "sha256:d360d4adfb53d3adeca398c225cb7e2a73a2246414455937082a1fa19bd8572b", size = 2445872, upload-time = "2025-11-11T16:19:57.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/7b/f64c95df054dd793ae1925f04248abd359b1c26cc2320d67407e7fd26e4d/xmlsec-1.3.17-cp312-cp312-win_arm64.whl", hash = "sha256:eee89c268a35f8a08a8e9abef6f466b97577e94f5cac8bf32c25e97cd5020097", size = 2261464, upload-time = "2025-11-11T16:19:58.937Z" }, +] + +[[package]] +name = "xmltodict" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/aa/917ceeed4dbb80d2f04dbd0c784b7ee7bba8ae5a54837ef0e5e062cd3cfb/xmltodict-1.0.2.tar.gz", hash = "sha256:54306780b7c2175a3967cad1db92f218207e5bc1aba697d887807c0fb68b7649", size = 25725, upload-time = "2025-09-17T21:59:26.459Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/20/69a0e6058bc5ea74892d089d64dfc3a62ba78917ec5e2cfa70f7c92ba3a5/xmltodict-1.0.2-py3-none-any.whl", hash = "sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d", size = 13893, upload-time = "2025-09-17T21:59:24.859Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" }, + { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" }, + { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" }, + { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" }, + { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" }, + { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" }, + { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" }, + { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" }, + { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" }, + { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" }, + { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" }, + { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +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" } +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" }, +] + +[[package]] +name = "zope-event" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/33/d3eeac228fc14de76615612ee208be2d8a5b5b0fada36bf9b62d6b40600c/zope_event-6.1.tar.gz", hash = "sha256:6052a3e0cb8565d3d4ef1a3a7809336ac519bc4fe38398cb8d466db09adef4f0", size = 18739, upload-time = "2025-11-07T08:05:49.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/b0/956902e5e1302f8c5d124e219c6bf214e2649f92ad5fce85b05c039a04c9/zope_event-6.1-py3-none-any.whl", hash = "sha256:0ca78b6391b694272b23ec1335c0294cc471065ed10f7f606858fc54566c25a0", size = 6414, upload-time = "2025-11-07T08:05:48.874Z" }, +] + +[[package]] +name = "zope-interface" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/a4/77daa5ba398996d16bb43fc721599d27d03eae68fe3c799de1963c72e228/zope_interface-8.2.tar.gz", hash = "sha256:afb20c371a601d261b4f6edb53c3c418c249db1a9717b0baafc9a9bb39ba1224", size = 254019, upload-time = "2026-01-09T07:51:07.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/97/9c2aa8caae79915ed64eb114e18816f178984c917aa9adf2a18345e4f2e5/zope_interface-8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c65ade7ea85516e428651048489f5e689e695c79188761de8c622594d1e13322", size = 208081, upload-time = "2026-01-09T08:05:06.623Z" }, + { url = "https://files.pythonhosted.org/packages/34/86/4e2fcb01a8f6780ac84923748e450af0805531f47c0956b83065c99ab543/zope_interface-8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1ef4b43659e1348f35f38e7d1a6bbc1682efde239761f335ffc7e31e798b65b", size = 208522, upload-time = "2026-01-09T08:05:07.986Z" }, + { url = "https://files.pythonhosted.org/packages/f6/eb/08e277da32ddcd4014922854096cf6dcb7081fad415892c2da1bedefbf02/zope_interface-8.2-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:dfc4f44e8de2ff4eba20af4f0a3ca42d3c43ab24a08e49ccd8558b7a4185b466", size = 255198, upload-time = "2026-01-09T08:05:09.532Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a1/b32484f3281a5dc83bc713ad61eca52c543735cdf204543172087a074a74/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", size = 259970, upload-time = "2026-01-09T08:05:11.477Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/bca0e8ae1e487d4093a8a7cfed2118aa2d4758c8cfd66e59d2af09d71f1c/zope_interface-8.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d2bb8e7364e18f083bf6744ccf30433b2a5f236c39c95df8514e3c13007098ce", size = 261153, upload-time = "2026-01-09T08:05:13.402Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/e3ff2a708011e56b10b271b038d4cb650a8ad5b7d24352fe2edf6d6b187a/zope_interface-8.2-cp311-cp311-win_amd64.whl", hash = "sha256:6f4b4dfcfdfaa9177a600bb31cebf711fdb8c8e9ed84f14c61c420c6aa398489", size = 212330, upload-time = "2026-01-09T08:05:15.267Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a0/1e1fabbd2e9c53ef92b69df6d14f4adc94ec25583b1380336905dc37e9a0/zope_interface-8.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:624b6787fc7c3e45fa401984f6add2c736b70a7506518c3b537ffaacc4b29d4c", size = 208785, upload-time = "2026-01-09T08:05:17.348Z" }, + { url = "https://files.pythonhosted.org/packages/c3/2a/88d098a06975c722a192ef1fb7d623d1b57c6a6997cf01a7aabb45ab1970/zope_interface-8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bc9ded9e97a0ed17731d479596ed1071e53b18e6fdb2fc33af1e43f5fd2d3aaa", size = 208976, upload-time = "2026-01-09T08:05:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e8/757398549fdfd2f8c89f32c82ae4d2f0537ae2a5d2f21f4a2f711f5a059f/zope_interface-8.2-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:532367553e4420c80c0fc0cabcc2c74080d495573706f66723edee6eae53361d", size = 259411, upload-time = "2026-01-09T08:05:20.567Z" }, + { url = "https://files.pythonhosted.org/packages/91/af/502601f0395ce84dff622f63cab47488657a04d0065547df42bee3a680ff/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", size = 264859, upload-time = "2026-01-09T08:05:22.234Z" }, + { url = "https://files.pythonhosted.org/packages/89/0c/d2f765b9b4814a368a7c1b0ac23b68823c6789a732112668072fe596945d/zope_interface-8.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0009d2d3c02ea783045d7804da4fd016245e5c5de31a86cebba66dd6914d59a2", size = 264398, upload-time = "2026-01-09T08:05:23.853Z" }, + { url = "https://files.pythonhosted.org/packages/4a/81/2f171fbc4222066957e6b9220c4fb9146792540102c37e6d94e5d14aad97/zope_interface-8.2-cp312-cp312-win_amd64.whl", hash = "sha256:845d14e580220ae4544bd4d7eb800f0b6034fe5585fc2536806e0a26c2ee6640", size = 212444, upload-time = "2026-01-09T08:05:25.148Z" }, +] + +[[package]] +name = "zstd" +version = "1.5.7.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/62/b9c075ad664e7c4cbb3d8d2be7c246506abe1bc7f778eb58d260ef9538c8/zstd-1.5.7.3.tar.gz", hash = "sha256:403e5205f4ac04b92e6b0cda654be2f51de268228a0db0067bc087faacf2f495", size = 672559, upload-time = "2026-01-08T16:24:43.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/0d/8c89c0d010b58c21a7865a239790bb1c6822029c053b1ded858d6b573e3a/zstd-1.5.7.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a3c1781a24e2ced2c0ddee11d45b1f04018b03615eeb622a62eca4d56d3358a", size = 267641, upload-time = "2026-01-08T16:30:50.812Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6d/155d8c344d96eca2a5a003a5ddd63373a5f13591fd5cf2b9490250d6805a/zstd-1.5.7.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6c7c81056362b60a04baa34632e713d596662a860ec34efd8e9b109c10e6ec7", size = 230962, upload-time = "2026-01-08T16:30:49.155Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c7/ab93916a26eb58cd501ad701974c31b4bc67a7f6abd6c24bef8fe4d7649b/zstd-1.5.7.3-cp311-cp311-manylinux_2_14_x86_64.whl", hash = "sha256:e564f34a55effc7d654eb293468edc80b64d476b0f899f82760ecd8323223ff5", size = 304166, upload-time = "2026-01-10T11:17:45.697Z" }, + { url = "https://files.pythonhosted.org/packages/c2/54/27a7040a360019a4602343e3c98c0c0a140f382186002c01e1992fd21837/zstd-1.5.7.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:fbc49a57188184931d5e3c9f1133cad7eea5a370a9e9418fb8122d58c14340a5", size = 1540288, upload-time = "2026-01-08T17:50:26.913Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/4a4d4edd1b2e809e0ebbb16000404bdcc9a09743c04ee1661442c9581b75/zstd-1.5.7.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:d121d3e63722819e1fe5effbcd9628d8a7cfea0cddabcc5bb37ea861a6a83424", size = 1619134, upload-time = "2026-01-08T17:50:32.324Z" }, + { url = "https://files.pythonhosted.org/packages/31/6b/cd6f0a7f4f0d98e4110aa77763cf3e85f594d983ea9ca3d64cc0cee10684/zstd-1.5.7.3-cp311-cp311-manylinux_2_4_i686.whl", hash = "sha256:621f2e7ca8e9eb52a83eb9c91ec3cd283d87591bf75cc658de486b65f44742c7", size = 300166, upload-time = "2026-01-10T11:12:27.938Z" }, + { url = "https://files.pythonhosted.org/packages/05/3f/c717e0d15127d04b7fa58ba9b4c56e8b88b803048b9766cd9d158dbb22ea/zstd-1.5.7.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:c1950fcae690ba32d0f31702b335c548fb42547821565925e48576afdad774a5", size = 1525776, upload-time = "2026-01-08T17:50:35.518Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a2/1813cd787d1a2f9ab8e8a90d28dcbc8e8098997dd04de38897ea8e75dd08/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bac4f0d03da69115878bedbfa03c4a3f64364e8396b432028c4ce0f05141a0fb", size = 2096057, upload-time = "2026-01-08T17:50:33.984Z" }, + { url = "https://files.pythonhosted.org/packages/36/ce/f5a3c7c12de458dd9ce15c484d627fe5412b60c155da23dacb5fcf08d9d5/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:da0ab134b7fd28023dedf013751ca850de300a090eb11f689d2a1c178c87d9dc", size = 2132659, upload-time = "2026-01-08T17:50:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/f1/66/151f9546498bfd8971a0b6ad67d87c26d7a0df17d57f724da674f3778666/zstd-1.5.7.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b9923175842ee8f7602ec9cc578f5fc396896f0e8460d3ac9a5adc3cea77244e", size = 2124811, upload-time = "2026-01-08T17:50:37.612Z" }, + { url = "https://files.pythonhosted.org/packages/6a/34/4d2dbb36cb2373d3f115c047cb901b64f89de0703d10779da39de9453812/zstd-1.5.7.3-cp311-cp311-win32.whl", hash = "sha256:0612b604948d7b58aecc6788c7ceb53c5f21d94a155bb6ea9bd0f54ffa43725d", size = 150363, upload-time = "2026-01-08T17:11:02.392Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/f53687e0dd8c0d0ebfaed9ae88f6a96a1a0388ae7424b469e74bb17ac57d/zstd-1.5.7.3-cp311-cp311-win_amd64.whl", hash = "sha256:5b7f8c81b2bd3b62c0345242247d484cafa4b518d59d18619813d9225af5c5c3", size = 167577, upload-time = "2026-01-08T17:11:03.356Z" }, + { url = "https://files.pythonhosted.org/packages/f2/58/d4a6a902e229e953ed273fe9b78587ed31f57567aa68d3e34af6056e42af/zstd-1.5.7.3-cp311-cp311-win_arm64.whl", hash = "sha256:ea112e3acd9e1765adca35df7b54ac75b36194290f64ea03a3a59664209c8527", size = 157238, upload-time = "2026-01-08T16:36:06.25Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ed/5a3bf2e29dc56d4cc7619929bb51f0c758de6d02967cc73c5d8755a862c0/zstd-1.5.7.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01a39efb0eeab7cc45cb308618233b624b0840d5e16dcf85456b6cca0592f203", size = 268124, upload-time = "2026-01-08T16:29:57.091Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1d/efc2074ac90af938e78f2ed4004639fe24f294d9086c5280f8d9a02b9897/zstd-1.5.7.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7a8e8838cf35fa3987bfe1958584cc22e1797efce8e155a63544b4144fc671f8", size = 230988, upload-time = "2026-01-08T16:29:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/2a/52/178393b8d70e23fba67f42dfce4663e4e8a30867110168beb490a36d4639/zstd-1.5.7.3-cp312-cp312-manylinux_2_14_i686.whl", hash = "sha256:f3920ac1d1cc7e9f252f3e29f217fe3cd36f2191bb3dbcae826c29e189b7ad54", size = 300207, upload-time = "2026-01-10T11:26:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7a/8dcd86a2efb2ed3f9dae39545a05d3c7ed26c7678330786ce4a44cd8b099/zstd-1.5.7.3-cp312-cp312-manylinux_2_14_x86_64.whl", hash = "sha256:143f9062953fb5590cbd47c1040d357336742c79696bf90b6d5b835279a68304", size = 304154, upload-time = "2026-01-10T11:17:40.91Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ce/0c96905ab01ffe0e53a3cec8132123b82db26bd583a71608029bcc789ebc/zstd-1.5.7.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d1fd8647e47e1f21b345e192f1a279e925678c23dad8236b547d04456cd699", size = 2162222, upload-time = "2026-01-08T18:02:22.762Z" }, + { url = "https://files.pythonhosted.org/packages/11/c4/db4807d6a68b4628c74fd379de7e3c67ec34f19a2a80ac246b3837cde6cb/zstd-1.5.7.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1538db419afa62773cf534fc7f3009ff59ecf55ecee4e889587ac2ef0010ed8", size = 2201732, upload-time = "2026-01-08T18:02:20.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/99/c19a3c0f5580ff9c33a74f06d98d6060ed1fa6bd09b55aed9be852ec191f/zstd-1.5.7.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5efd16adb092e2a547a7d51cfdaf6fd5680528227684c5bafc7669ab4a55f41", size = 2096459, upload-time = "2026-01-08T18:02:25.336Z" }, + { url = "https://files.pythonhosted.org/packages/23/fd/02eac30419475dbe50212c119043a2d0698a0cbc756da85fd3fd9abddf42/zstd-1.5.7.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:39b3438e64637d80a5b1860526903b92020acb9bae9ceb5adffd9838c1441328", size = 2125442, upload-time = "2026-01-08T18:02:17.715Z" }, + { url = "https://files.pythonhosted.org/packages/bb/43/3a16ff0a8c913bb9825379db1bd533c75c57c2d2f31dd9111aa9b53711f4/zstd-1.5.7.3-cp312-cp312-win32.whl", hash = "sha256:cbf48c53461e224ffc2490cfe5120a1ff40d14c84d2b512c6d6d99fc91685cf3", size = 150367, upload-time = "2026-01-08T17:03:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/83/b85875d7428e63dfa9247e41d17fac611443c774f7892f8643bd4164a6b2/zstd-1.5.7.3-cp312-cp312-win_amd64.whl", hash = "sha256:943a189910f2fea997462e3e4d7fbf727a06d231ef801ebee557b1c87568981c", size = 167604, upload-time = "2026-01-08T17:03:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/37/42/cf291e26804de2f55500cdac93f5e9fa6267cf315def8aa402529bae3a87/zstd-1.5.7.3-cp312-cp312-win_arm64.whl", hash = "sha256:85c4d508f8109afa7c51c4960626c3325af2cf1e442c6c36ebfea15d04757e3f", size = 157241, upload-time = "2026-01-08T16:47:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/04/b8/d13d584867d5eb1bc607877a870858e02a256d4706a4274e475413a000aa/zstd-1.5.7.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:76c49ea969bc08389ea59155cea7c5dea224522ffc62f443f3c0a915f5fd184d", size = 260025, upload-time = "2026-01-08T16:57:45.739Z" }, + { url = "https://files.pythonhosted.org/packages/16/a1/1e5faf75bedfd2bfccfb83e18736b115bed6e348504bd21800cd8f30dcea/zstd-1.5.7.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b1a638ff3dfce8f4cb1203c662fb5606dd99b4a62c5ddc4c406d2d1326bcfdd", size = 221038, upload-time = "2026-01-08T17:16:32.005Z" }, + { url = "https://files.pythonhosted.org/packages/b7/2c/0fe74d8b2029eef8000bc71aac5b3e5b55d00581238711cf627814183ea3/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5e96a5cb100a0edc162935227f2d9784b1031ce4a8a83e96e66eae2673c10143", size = 326792, upload-time = "2026-01-08T16:57:35.631Z" }, + { url = "https://files.pythonhosted.org/packages/96/e0/2c7f081f3524f872128ff31bea2acb6b21cb1dacccef920eb6a1a77a87c6/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bda0bbf3a9553720cd33f1f85940a259656c7ffba4be717ff82b7f062052188", size = 322283, upload-time = "2026-01-08T16:57:36.759Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a7/3bebfcc18d66b90bc7b506a61b2ff4af5ee1b0b16e784ea644afa06241c5/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", size = 311553, upload-time = "2026-01-08T16:57:38.465Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/8a791cae2c98e5e44a158e15db50d21b7ec0b37aeaffa68d151bc8ffb6d6/zstd-1.5.7.3-pp311-pypy311_pp73-manylinux_2_14_x86_64.whl", hash = "sha256:fa4d760a220541b18ce732a3a2cf7547ea05afc76d05b3b39edebfeb721f6079", size = 317071, upload-time = "2026-01-08T16:36:07.47Z" }, + { url = "https://files.pythonhosted.org/packages/2f/25/b6624e6b08d515242154436c9d06fb20b790d300ac82e84f3c4c133e25e1/zstd-1.5.7.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a69e60146bf8aaa6a0e6c9a94a7c5f3133d68091e2e5c5a3c5ababf71fd5ec7a", size = 167654, upload-time = "2026-01-08T17:00:56.667Z" }, +] diff --git a/claude_plugins/prowler/.claude-plugin/plugin.json b/claude_plugins/prowler/.claude-plugin/plugin.json new file mode 100644 index 0000000000..7bf822e2e7 --- /dev/null +++ b/claude_plugins/prowler/.claude-plugin/plugin.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", + "name": "prowler", + "version": "0.1.0", + "description": "Prowler for Claude Code — cloud security and compliance skills powered by the Prowler MCP server. Bundles compliance triage and remediation; more skills coming.", + "author": { + "name": "Prowler", + "email": "support@prowler.com", + "url": "https://prowler.com" + }, + "homepage": "https://docs.prowler.com", + "repository": "https://github.com/prowler-cloud/prowler", + "license": "Apache-2.0", + "keywords": [ + "prowler", + "security", + "compliance", + "cloud-security", + "mcp" + ], + "userConfig": { + "api_key": { + "type": "string", + "title": "Prowler API key", + "description": "API key token used to authenticate with Prowler Cloud / Prowler App via the Prowler MCP server. Create one at https://cloud.prowler.com.", + "sensitive": true, + "required": true + } + } +} diff --git a/claude_plugins/prowler/.mcp.json b/claude_plugins/prowler/.mcp.json new file mode 100644 index 0000000000..9788b6d982 --- /dev/null +++ b/claude_plugins/prowler/.mcp.json @@ -0,0 +1,9 @@ +{ + "prowler": { + "type": "http", + "url": "https://mcp.prowler.com/mcp", + "headers": { + "Authorization": "Bearer ${user_config.api_key}" + } + } +} diff --git a/claude_plugins/prowler/README.md b/claude_plugins/prowler/README.md new file mode 100644 index 0000000000..6e204fbe44 --- /dev/null +++ b/claude_plugins/prowler/README.md @@ -0,0 +1,80 @@ +# Prowler for Claude Code + +End-to-end cloud security and compliance from inside [Claude Code](https://www.claude.com/product/claude-code), powered by the [Prowler MCP server](https://docs.prowler.com/projects/prowler-mcp/). The plugin lets Claude walk a Prowler Cloud-connected account through a compliance assessment and remediate findings until the chosen security or industry framework is compliant. + +> **Preview**: this plugin is under active development. Report issues at or join the [Slack community](https://goto.prowler.com/slack). + +## Requirements + +- [Claude Code](https://www.claude.com/product/claude-code) installed and signed in. +- A [Prowler Cloud](https://cloud.prowler.com) account (the free tier is enough to start). +- A Prowler API key — create one at . + +## Installation + +Inside a Claude Code session: + +```text +/plugin marketplace add prowler-cloud/prowler +/plugin install prowler@prowler-plugins +``` + +Or, if you already have the repo checked out locally: + +```text +/plugin marketplace add /absolute/path/to/prowler +/plugin install prowler@prowler-plugins +``` + +## Configuration + +On first install, Claude Code prompts for your **Prowler API key**. It is stored securely (macOS keychain or `~/.claude/.credentials.json`) and used to authenticate against Prowler Cloud. + +To rotate the key, uninstall and reinstall the plugin — Claude Code will prompt again. + +## Verify the install + +In a Claude Code session: + +```text +/mcp → "prowler" appears as a connected server +/plugin → "prowler" enabled, skill listed as prowler:framework-compliance-triage +``` + +If `/mcp` reports the `prowler` server as failed, the most common cause is a rejected API key — re-issue one in Prowler Cloud and reinstall the plugin so it re-prompts. + +## Usage + +Open a conversation that mentions the framework you want to comply with. Examples: + +- *"Make my AWS production account compliant with CIS 4.0."* +- *"Make my current Terraform project compliant with the Prowler ThreatScore Compliance Framework based on the latest scan results."* +- *"Help me get to 100% on PCI-DSS for this GCP project."* + +You pick a **primary tool** (Terraform, gh / az / aws CLI, web console, or mixed) and a **mode**: + +- **Claude-assisted** (default). Claude shows each fix — target resource, exact commands, side effects, reversibility — and waits for your go-ahead before applying. +- **Claude autonomous**. Claude presents a single up-front plan grouped by shared fixes, waits for one confirmation, then proceeds. It pauses mid-loop if a fix has wide blast radius or a finding is not applicable. + +Claude tracks progress in a markdown report under `.prowler/` at your project root — one file per framework × account. Open it any time to see exactly where the flow is. When all findings are addressed, Claude proposes a fresh Prowler scan to verify everything end-to-end. + +## Uninstalling + +```text +/plugin uninstall prowler@prowler-plugins +/plugin marketplace remove prowler-plugins +``` + +The stored API key is removed automatically. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `/mcp` shows `prowler` as failed | Rejected API key | Generate a new one in Prowler Cloud and reinstall the plugin to re-prompt. | +| Skill not invoked when expected | The skill description didn't match the prompt | Mention the framework name plus "compliance" or "compliant" in your prompt. | +| "Framework not supported" | Prowler Hub does not list the framework for that provider | Open an issue or PR at . | + +## License + +Apache 2.0 — see [LICENSE](../../LICENSE). diff --git a/claude_plugins/prowler/skills/framework-compliance-triage/SKILL.md b/claude_plugins/prowler/skills/framework-compliance-triage/SKILL.md new file mode 100644 index 0000000000..1af29f82b9 --- /dev/null +++ b/claude_plugins/prowler/skills/framework-compliance-triage/SKILL.md @@ -0,0 +1,199 @@ +--- +name: framework-compliance-triage +description: Make a cloud account compliant with a security or industry framework using Prowler Cloud. +--- + +# Framework compliance + +Iterative, interactive flow that takes a cloud account through setup, reporting, and remediation until it complies with the chosen security or industry framework. + +## Checkpoints + +This skill uses **checkpoints** to mark moments where you must stop, post a clear question or summary to the user, and wait for the reply before continuing. Each checkpoint is rendered like this: + +> **Checkpoint — ** +> +> What to present, and what to wait for. + +Treat every checkpoint as a hard stop: + +- Do not skip a checkpoint because the user previously said "go ahead", "just do it", or similar. Confirmations are scoped to a single checkpoint and do not transfer to later ones. +- Do not bundle two checkpoints into one message. Post one, wait for the reply, then continue. +- Do not infer the user's answer from context or proceed on silence. Ask explicitly and wait. +- If a checkpoint is conditional (e.g. only fires when multiple accounts exist), evaluate the condition first; if it does not apply, continue without prompting. +- If the user's initial message already answers the question a checkpoint asks (e.g. "make my AWS subscription compliant with CIS using Terraform autonomously"), treat the checkpoint as satisfied for the parts they covered, and only ask for what is still missing. + +## 1. Initial Prowler Cloud setup + +> **Checkpoint — Provider and framework selection** +> +> If the user has not already specified both the provider and the framework, ask explicitly and wait for the answer. If they have specified them in their opening message, skip this checkpoint. + +Confirm both are supported by the Prowler Hub MCP: + +- Enumerate supported providers with `prowler_hub_list_providers`. +- Enumerate frameworks for the chosen provider with `prowler_hub_list_compliances`, passing the provider `id` as the only element of the `provider` input list. + +If the framework is not supported, tell the user, suggest they request it or contribute it themselves, and end the flow. Otherwise continue. + +### 1.1 Connect to Prowler Cloud + +Verify the Prowler MCP connection by calling `prowler_app_search_providers` — a successful response returns the list of providers. If the call fails, walk the user through troubleshooting: internet connectivity, Prowler Cloud credentials, and permissions on the Prowler Cloud account. +For getting accurate information about configurations use `prowler_docs_search` to pull relevant instructions from the Prowler documentation. + +### 1.2 Verify the provider is configured (or configure it) + +Call `prowler_app_search_providers` to check whether the target provider (AWS account, Azure Subscription, GitHub Account...) exists in the user's Prowler Cloud account. Handle the result based on what's found: + +- **Provider not present.** Guide the user through adding and configuring it. Retrieve the relevant connection, credential, and permission instructions with `prowler_docs_search`. +- **Provider present but misconfigured** (missing credentials, insufficient permissions, etc.). Walk the user through fixing the configuration, pulling the relevant guidance with `prowler_docs_search`. +- **Provider present and configured.** Continue. + +> **Checkpoint — Account selection** *(conditional: more than one account of the chosen provider is configured)* +> +> List the accounts with helpful detail (account name, uid, last scan date) and ask which one to use. Wait for the answer. If only one account exists, skip this checkpoint and use it. + +### 1.3 Review compliance report for the provider account + +The flow needs at least one completed scan with a compliance report available. + +Look for a completed scan first: call `prowler_app_list_scans` with the selected `provider_id` and `state: ["completed"]`, then call `prowler_app_get_compliance_overview` with each `scan_id` to find one whose compliance report is available. If one is found, continue to the next section. + +If no completed scan has a report, call `prowler_app_list_scans` again with `state: ["available", "executing"]` to detect a scan in progress. + +> **Checkpoint — Scan-in-progress decision** *(conditional: an in-progress scan was detected)* +> +> Tell the user a scan is already running and ask whether to wait for it to complete or start a fresh one. Wait for the answer. + +If no scan is running (or the user chose to start a fresh one), trigger a new scan with `prowler_app_trigger_scan` and the `provider_id`. The link `https://cloud.prowler.com/scans?filter%5Bprovider_uid__in%5D={provider_id}` lets the user monitor progress. + +When a scan is in progress (either pre-existing and elected to wait, or just triggered), stop the flow and ask the user to return when it's completed — restart this section to re-check the results. + +## 2. Compliance report + +Every iteration of the remediation loop reads and writes a single markdown file per provider account and framework, stored at `${CLAUDE_PROJECT_DIR}/.prowler/compliance--.md`. Sanitize `` to `[a-zA-Z0-9_-]` by replacing anything else with `-`. Create `.prowler/` if missing. + +Across iterations, edit only: status tags on failed requirements and their findings, the per-requirement `Fix plan` / `Fix applied` sub-bullets added during sections 3.3–3.4, the **Global remediation approach** block, and the **Activity log** (append-only, newest on top). Requirement descriptions, finding IDs, and the entire **Manual review requirements** section are read-only after first render. + +Status taxonomy for failed requirements and their findings: + +- `[FAIL]` — failing in the latest scan. +- `[IN PROGRESS]` — picked up by section 3.3. +- `[FIXED-UNVERIFIED]` — remediation applied; not yet confirmed. +- `[PASS]` — passing in the latest scan (set when a rescan in section 3.5 confirms the fix). +- `[SKIPPED]` — user explicitly deferred. + +### Report template + +A fresh report is rendered like this (substituting values from the `prowler_app_get_compliance_framework_state_details` Prowler MCP tool response): + +````markdown +# Compliance report: + +**Provider account**: +**Scan ID**: +**Generated**: +**Last update**: +**Status**: / passing (%) · failing · manual review + +## Global remediation approach + +- **Primary tool**: _Terraform | Azure CLI | AWS CLI | web console | mixed_ +- **Mode**: _Claude autonomous | Claude-assisted_ +- **Notes**: + +## Activity log +- — Report initialized from scan ``. + +## Failed requirements + +### — [FAIL] +**Description**: +**Findings** (): +- [FAIL] `` + +## Manual review requirements +- **** — [PENDING]: +```` + +### 2.1 Generate or refresh the report + +Resolve the report path for the current `compliance_id` and provider account. + +If the file does not exist, call `prowler_app_get_compliance_framework_state_details` for the target scan, render the template above, and write the file with one initialization entry in the activity log. + +If the file exists, read it and compare its `Scan ID` to the target scan from section 1.3. When the scan matches, reuse the file and summarize remaining `[FAIL]` and `[IN PROGRESS]` items in chat. + +> **Checkpoint — Report refresh** *(conditional: the file's `Scan ID` differs from the current target scan)* +> +> Tell the user the report on disk was generated from a different scan and ask whether to refresh it from the new scan. Wait for the answer. + +On confirmation, regenerate the failed-requirements section from the new `prowler_app_get_compliance_framework_state_details` response, carry forward the **Global remediation approach** block and the full activity log, and append an activity-log entry noting the scan change. + +Once the file is current, surface the top failing requirements in chat: sort by finding count descending, show the top 5 with their codes and counts, and point to the file path for the full list. + +## 3. Remediation loop + +### 3.1 Define the global remediation approach + +Two modes are available: + +- **Claude-assisted** (default when the user has not specified): per-requirement confirmation. For each requirement Claude shows the target resource, exact commands, side effects, and reversibility, then waits for explicit go-ahead before applying. +- **Claude autonomous**: no per-requirement gate, but Claude still presents one batch-level fix plan up front (§3.2) and waits for a single confirmation, and pauses if a finding looks not applicable, requires a paid feature, or has wide blast radius (breaks dev workflow, forces collaborator changes, is hard to reverse). + +If the user phrases their request as "just do it" or similar, treat that as autonomous **with** the batch-plan confirmation still required — the confirmation is a property of the skill, not the user's verbosity preference. + +> **Checkpoint — Global remediation approach** +> +> Ask the user which tool to use for fixes (Terraform, gh / az / aws CLI, web console, mixed...) and which mode to operate in. Wait for the answer before continuing. This checkpoint is non-negotiable: never assume a default tool, and never assume autonomous mode. + +Once answered, write the values into the **Global remediation approach** block of the report file. + +> **Checkpoint — Overwriting an existing approach** *(conditional: the block is already populated from a previous session)* +> +> Show the previous values and the new ones, and ask the user to confirm before overwriting. Wait for the answer. + +### 3.2 Present the batch fix plan *(autonomous mode only)* + +In **assisted** mode, skip this section — the per-requirement gate in §3.3 confirms each fix as it comes up. Only run §3.2 in **autonomous** mode, where the loop will otherwise apply fixes without further input. + +Before touching anything, post a single chat summary covering every `[FAIL]` requirement: + +- Group findings that share a fix (e.g. ten branch-protection requirements satisfied by one PUT call → present as one group). +- For each group: target resource, exact tool calls, side effects, reversibility. +- Call out findings that look **not applicable** to this target (e.g. an Organization-only check evaluated against a User account, a feature gated by a paid plan, a resource type the user doesn't have) and propose `[SKIPPED]` with the reason. +- Call out findings that require manual user action Claude cannot perform. + +> **Checkpoint — Batch fix plan approval** *(conditional: autonomous mode)* +> +> Post the grouped plan and wait for explicit confirmation. Do not start any fix before the user replies. + +Once approved, the loop proceeds through the batch without further prompts unless something deviates from the approved plan. + +### 3.3 Pick the first FAIL requirement and inspect its findings + +Pick the first `[FAIL]` requirement at the top of the failed-requirements section. Move its status and every finding under it to `[IN PROGRESS]`, and add a `**Fix plan**:` sub-bullet describing what will be done. + +Call `prowler_app_get_finding_details` for each `finding_id` to retrieve the failing resource and the Prowler Hub's remediation guidance for that check using the tool `prowler_hub_get_check_details` with the `check_id` from the finding details. Summarize the guidance in chat, and append it to the `**Fix plan**` note for each finding. + +If a finding does not apply to the target resource (Organization-only check on a User account, paid-tier feature, missing resource type, etc.), set the requirement status to `[SKIPPED]` with the reason, log it in the activity log, and move on without attempting the fix — even if it was missed during §3.2. + +> **Checkpoint — Per-requirement approval** *(conditional: assisted mode)* +> +> Post the per-requirement plan in chat — resource, command, side effects, reversibility — and wait for confirmation before moving to §3.4. In **autonomous** mode, post the plan for transparency but proceed unless it deviates from the batch plan agreed in §3.2. + +### 3.4 Diagnose, fix, verify + +Read the remediation guidance returned in §3.3, identify the root cause, and apply the fix using the tool defined in the **Global remediation approach** block. After applying, verify via the same tool that applied the fix or via a provider API call when applicable. If the re-read shows the change did not land, leave the status at `[IN PROGRESS]`, surface the error to the user, and stop the loop for this requirement. + +When the change is in place, append a `**Fix applied**: ` sub-bullet to the requirement, move each fixed finding to `[FIXED-UNVERIFIED]`, and add one activity-log entry describing the change. If no programmatic verification was possible (e.g. web console action), note in the activity log that confirmation depends on the rescan in §3.5. + +### 3.5 Loop + +Move to the next `[FAIL]` requirement and repeat from section 3.3. + +> **Checkpoint — Rescan trigger** *(conditional: no `[FAIL]` requirements remain; all are `[FIXED-UNVERIFIED]` or `[SKIPPED]`)* +> +> Summarize what was applied, list any `[SKIPPED]` items with reasons, and ask whether to trigger a fresh scan with `prowler_app_trigger_scan` to verify the fixes end-to-end. Wait for the answer. + +On confirmation, trigger the rescan. When it completes, restart section 2.1 with the carry-forward path — requirements no longer in the new FAIL list move to `[PASS]`, anything still failing reverts to `[FAIL]` with the previous fix attempt visible in the activity log. diff --git a/contrib/PowerBI/Multicloud CIS Benchmarks/Prowler Multicloud CIS Benchmarks.pbit b/contrib/PowerBI/Multicloud CIS Benchmarks/Prowler Multicloud CIS Benchmarks.pbit index b1dc68338e..2349ea33bd 100644 Binary files a/contrib/PowerBI/Multicloud CIS Benchmarks/Prowler Multicloud CIS Benchmarks.pbit and b/contrib/PowerBI/Multicloud CIS Benchmarks/Prowler Multicloud CIS Benchmarks.pbit differ diff --git a/contrib/aws/multi-account-securityhub/run-prowler-securityhub.sh b/contrib/aws/multi-account-securityhub/run-prowler-securityhub.sh index c101da8613..3a60faa1f8 100755 --- a/contrib/aws/multi-account-securityhub/run-prowler-securityhub.sh +++ b/contrib/aws/multi-account-securityhub/run-prowler-securityhub.sh @@ -1,8 +1,9 @@ #!/bin/bash # Run Prowler against All AWS Accounts in an AWS Organization -# Activate Poetry Environment -eval "$(poetry env activate)" +# Activate uv-managed virtualenv +# shellcheck disable=SC1091 +source .venv/bin/activate # Show Prowler Version prowler -v diff --git a/contrib/inventory-graph/README.md b/contrib/inventory-graph/README.md new file mode 100644 index 0000000000..37e6292b1b --- /dev/null +++ b/contrib/inventory-graph/README.md @@ -0,0 +1,335 @@ +# AWS Inventory Connectivity Graph + +A community-contributed tool that generates interactive connectivity graphs from Prowler AWS scans, visualizing relationships between AWS resources with zero additional API calls. + +## Overview + +This tool extends Prowler by producing two artifacts after a scan completes: + +- **`.inventory.json`** – Machine-readable graph (nodes + edges) +- **`.inventory.html`** – Interactive D3.js force-directed visualization + +### Why? + +Prowler's existing outputs (CSV, ASFF, OCSF, HTML) report individual check findings but provide no cross-service topology view. Security engineers need to understand **how** resources are connected—which Lambda functions sit inside which VPC, which IAM roles can be assumed by which services, which event sources trigger which functions—before they can reason about attack paths, blast-radius, or lateral-movement risk. + +This tool fills that gap by building a connectivity graph from the service clients that are already loaded during a Prowler scan. + +## Features + +### Supported AWS Services + +The tool currently extracts connectivity information from: + +- **Lambda** – Functions, VPC/subnet/SG edges, event source mappings, layers, DLQ, KMS +- **EC2** – Instances, security groups, subnet/VPC edges +- **VPC** – VPCs, subnets, peering connections +- **RDS** – DB instances, VPC/SG/cluster/KMS edges +- **ELBv2** – ALB/NLB load balancers, SG and VPC edges +- **S3** – Buckets, replication targets, logging buckets, KMS keys +- **IAM** – Roles, trust-relationship edges (who can assume what) + +### Edge Semantic Types + +Edges are typed for downstream filtering and attack-path analysis: + +- `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) +- `replicates_to` – S3 replication +- `encrypts` – KMS key encrypts the resource +- `logs_to` – Logging relationship + +### Interactive HTML Graph Features + +- Force-directed layout with drag-and-drop node pinning +- Zoom / pan (mouse wheel + click-drag on background) +- Per-service color-coded nodes with a legend +- Hover tooltips showing ARN + all metadata properties +- Service filter dropdown (show only Lambda, EC2, RDS, etc.) +- Adjustable link-distance and charge-strength physics sliders +- Edge labels on every arrow + +## Installation + +### Prerequisites + +- Python 3.9.1 or higher +- Prowler installed and configured (see [Prowler documentation](https://docs.prowler.com/)) + +### Setup + +1. Clone or download this directory to your local machine +2. Ensure Prowler is installed and working +3. No additional dependencies required beyond Prowler's existing requirements + +## Usage + +### Basic Usage + +Run Prowler with your desired checks, then use the inventory graph script: + +```bash +# Run Prowler scan (example) +prowler aws --output-formats csv + +# Generate inventory graph from the scan +python contrib/inventory-graph/inventory_graph.py --output-directory ./output +``` + +### Command-Line Options + +```bash +python contrib/inventory-graph/inventory_graph.py [OPTIONS] + +Options: + --output-directory DIR Directory to save output files (default: ./output) + --output-filename NAME Base filename without extension (default: prowler-inventory-) + --help Show this help message and exit +``` + +### Example Workflow + +```bash +# 1. Run a Prowler scan on your AWS account +prowler aws --profile my-aws-profile --output-formats csv html + +# 2. Generate the inventory graph +python contrib/inventory-graph/inventory_graph.py \ + --output-directory ./output \ + --output-filename my-aws-inventory + +# 3. Open the HTML file in your browser +open output/my-aws-inventory.inventory.html +``` + +### Integration with Prowler Scans + +The tool reads from already-loaded AWS service clients in memory (via `sys.modules`). This means: + +- **Zero extra AWS API calls** – Uses data already collected during the Prowler scan +- **Graceful degradation** – Services not scanned are silently skipped +- **Flexible** – Works with any subset of Prowler checks + +## Output Files + +### JSON Output (`*.inventory.json`) + +Machine-readable graph structure: + +```json +{ + "generated_at": "2026-03-19T12:34:56Z", + "nodes": [ + { + "id": "arn:aws:lambda:us-east-1:123456789012:function:my-function", + "type": "lambda_function", + "name": "my-function", + "service": "lambda", + "region": "us-east-1", + "account_id": "123456789012", + "properties": { + "runtime": "python3.9", + "vpc_id": "vpc-abc123" + } + } + ], + "edges": [ + { + "source_id": "arn:aws:lambda:...", + "target_id": "arn:aws:ec2:...:vpc/vpc-abc123", + "edge_type": "network", + "label": "in-vpc" + } + ], + "stats": { + "node_count": 42, + "edge_count": 87 + } +} +``` + +### HTML Output (`*.inventory.html`) + +Self-contained interactive visualization that opens in any modern browser. No server or build step required. + +## Architecture + +### Design Decisions + +| Decision | Rationale | +|----------|-----------| +| **Read from sys.modules** | Zero extra AWS API calls; services not scanned are silently skipped | +| **Self-contained HTML** | D3.js v7 via CDN; no server, no build step; opens in any browser | +| **One extractor per service** | Each extractor is independently testable; adding a new service = one new file + one line in the registry | +| **Typed edges** | Semantic types allow downstream consumers (attack-path tools, Neo4j import) to filter by relationship class | + +### Project Structure + +``` +contrib/inventory-graph/ +├── README.md # This file +├── inventory_graph.py # Main entry point script +├── lib/ +│ ├── __init__.py +│ ├── models.py # ResourceNode, ResourceEdge, ConnectivityGraph dataclasses +│ ├── graph_builder.py # Reads loaded service clients from sys.modules +│ ├── inventory_output.py # write_json(), write_html() +│ └── extractors/ +│ ├── __init__.py +│ ├── lambda_extractor.py # Lambda functions → VPC/subnet/SG/event-sources/layers/DLQ/KMS +│ ├── ec2_extractor.py # EC2 instances + security groups → subnet/VPC +│ ├── vpc_extractor.py # VPCs, subnets, peering connections +│ ├── rds_extractor.py # RDS instances → VPC/SG/cluster/KMS +│ ├── elbv2_extractor.py # ALB/NLB load balancers → SG/VPC +│ ├── s3_extractor.py # S3 buckets → replication targets/logging buckets/KMS keys +│ └── iam_extractor.py # IAM roles + trust-relationship edges +└── examples/ + └── sample_output.html # Example output (optional) +``` + +## Testing + +### Smoke Test (No AWS Credentials Needed) + +```python +import sys +from unittest.mock import MagicMock + +# Wire a fake Lambda client +mock_module = MagicMock() +mock_fn = MagicMock() +mock_fn.arn = "arn:aws:lambda:us-east-1:123:function:test" +mock_fn.name = "test" +mock_fn.region = "us-east-1" +mock_fn.vpc_id = "vpc-abc" +mock_fn.security_groups = ["sg-111"] +mock_fn.subnet_ids = {"subnet-aaa"} +mock_fn.environment = None +mock_fn.kms_key_arn = None +mock_fn.layers = [] +mock_fn.dead_letter_config = None +mock_fn.event_source_mappings = [] +mock_module.awslambda_client.functions = {mock_fn.arn: mock_fn} +mock_module.awslambda_client.audited_account = "123" +sys.modules["prowler.providers.aws.services.awslambda.awslambda_client"] = mock_module + +from contrib.inventory_graph.lib.graph_builder import build_graph +from contrib.inventory_graph.lib.inventory_output import write_json, write_html + +graph = build_graph() +write_json(graph, "/tmp/test.inventory.json") +write_html(graph, "/tmp/test.inventory.html") +# Open /tmp/test.inventory.html in a browser +``` + +## Extending + +### Adding a New Service + +1. Create a new extractor file in `lib/extractors/` (e.g., `dynamodb_extractor.py`) +2. Implement the `extract(client)` function that returns `(nodes, edges)` +3. Register it in `lib/graph_builder.py` in the `_SERVICE_REGISTRY` tuple + +Example extractor template: + +```python +from typing import List, Tuple +from prowler.lib.outputs.inventory.models import ResourceNode, ResourceEdge + +def extract(client) -> Tuple[List[ResourceNode], List[ResourceEdge]]: + """Extract DynamoDB tables and their relationships.""" + nodes = [] + edges = [] + + for table in client.tables: + nodes.append( + ResourceNode( + id=table.arn, + type="dynamodb_table", + name=table.name, + service="dynamodb", + region=table.region, + account_id=client.audited_account, + properties={"billing_mode": table.billing_mode} + ) + ) + + # Add edges for KMS encryption, streams, etc. + if table.kms_key_arn: + edges.append( + ResourceEdge( + source_id=table.kms_key_arn, + target_id=table.arn, + edge_type="encrypts", + label="encrypts" + ) + ) + + return nodes, edges +``` + +## Troubleshooting + +### No nodes discovered + +**Problem:** The tool reports "no nodes discovered" after running. + +**Solution:** Ensure you've run a Prowler scan first. The tool reads from in-memory service clients loaded during the scan. If no services were scanned, no nodes will be discovered. + +### Missing services in the graph + +**Problem:** Some AWS services are not appearing in the graph. + +**Solution:** The tool only includes services that have been scanned by Prowler. Run Prowler with the services you want to include, or run without service filters to scan all available services. + +### HTML file doesn't display properly + +**Problem:** The HTML visualization doesn't load or shows errors. + +**Solution:** +- Ensure you're opening the file in a modern browser (Chrome, Firefox, Safari, Edge) +- Check your browser's console for JavaScript errors +- Verify the file was generated completely (check file size > 0) +- The HTML requires internet access to load D3.js from CDN + +## Roadmap + +Potential future enhancements: + +- [ ] Support for additional AWS services (DynamoDB, SQS, SNS, etc.) +- [ ] Export to Neo4j / Cartography format +- [ ] Attack path analysis integration +- [ ] Multi-account/multi-region aggregation +- [ ] Custom edge type filtering in HTML UI +- [ ] Graph diff between two scans + +## Contributing + +This is a community contribution. If you'd like to enhance it: + +1. Fork the Prowler repository +2. Make your changes in `contrib/inventory-graph/` +3. Test thoroughly +4. Submit a pull request with a clear description + +## License + +This tool is part of the Prowler project and is licensed under the Apache License 2.0. + +## Credits + +- **Author:** [@sandiyochristan](https://github.com/sandiyochristan) +- **Related PR:** [#10382](https://github.com/prowler-cloud/prowler/pull/10382) +- **Prowler Project:** [prowler-cloud/prowler](https://github.com/prowler-cloud/prowler) + +## Support + +For issues or questions: + +- Open an issue in the [Prowler repository](https://github.com/prowler-cloud/prowler/issues) +- Join the [Prowler Community Slack](https://goto.prowler.com/slack) +- Tag your issue with `contrib:inventory-graph` diff --git a/contrib/inventory-graph/examples/example_usage.py b/contrib/inventory-graph/examples/example_usage.py new file mode 100644 index 0000000000..f651103b47 --- /dev/null +++ b/contrib/inventory-graph/examples/example_usage.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +""" +Example: Generate AWS Inventory Graph with Mock Data + +This example demonstrates how to use the inventory graph tool with mock AWS data. +No AWS credentials required. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from lib.graph_builder import build_graph +from lib.inventory_output import write_json, write_html + + +def create_mock_lambda_client(): + """Create a mock Lambda client with sample data.""" + mock_module = MagicMock() + + # Create a mock Lambda function + mock_fn = MagicMock() + mock_fn.arn = "arn:aws:lambda:us-east-1:123456789012:function:my-test-function" + mock_fn.name = "my-test-function" + mock_fn.region = "us-east-1" + mock_fn.vpc_id = "vpc-abc123" + mock_fn.security_groups = ["sg-111222"] + mock_fn.subnet_ids = {"subnet-aaa111", "subnet-bbb222"} + mock_fn.environment = {"Variables": {"ENV": "production"}} + mock_fn.kms_key_arn = ( + "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012" + ) + mock_fn.layers = [] + mock_fn.dead_letter_config = None + mock_fn.event_source_mappings = [] + + mock_module.awslambda_client.functions = {mock_fn.arn: mock_fn} + mock_module.awslambda_client.audited_account = "123456789012" + + return mock_module + + +def create_mock_ec2_client(): + """Create a mock EC2 client with sample data.""" + mock_module = MagicMock() + + # Create a mock EC2 instance + mock_instance = MagicMock() + mock_instance.arn = ( + "arn:aws:ec2:us-east-1:123456789012:instance/i-1234567890abcdef0" + ) + mock_instance.id = "i-1234567890abcdef0" + mock_instance.region = "us-east-1" + mock_instance.vpc_id = "vpc-abc123" + mock_instance.subnet_id = "subnet-aaa111" + mock_instance.security_groups = [MagicMock(id="sg-111222")] + mock_instance.state = "running" + mock_instance.type = "t3.micro" + mock_instance.tags = [{"Key": "Name", "Value": "test-instance"}] + + # Create a mock security group + mock_sg = MagicMock() + mock_sg.arn = "arn:aws:ec2:us-east-1:123456789012:security-group/sg-111222" + mock_sg.id = "sg-111222" + mock_sg.name = "test-security-group" + mock_sg.region = "us-east-1" + mock_sg.vpc_id = "vpc-abc123" + + mock_module.ec2_client.instances = [mock_instance] + mock_module.ec2_client.security_groups = [mock_sg] + mock_module.ec2_client.audited_account = "123456789012" + + return mock_module + + +def create_mock_vpc_client(): + """Create a mock VPC client with sample data.""" + mock_module = MagicMock() + + # Create a mock VPC + mock_vpc = MagicMock() + mock_vpc.arn = "arn:aws:ec2:us-east-1:123456789012:vpc/vpc-abc123" + mock_vpc.id = "vpc-abc123" + mock_vpc.region = "us-east-1" + mock_vpc.cidr_block = "10.0.0.0/16" + mock_vpc.tags = [{"Key": "Name", "Value": "test-vpc"}] + + # Create mock subnets + mock_subnet1 = MagicMock() + mock_subnet1.arn = "arn:aws:ec2:us-east-1:123456789012:subnet/subnet-aaa111" + mock_subnet1.id = "subnet-aaa111" + mock_subnet1.region = "us-east-1" + mock_subnet1.vpc_id = "vpc-abc123" + mock_subnet1.cidr_block = "10.0.1.0/24" + mock_subnet1.availability_zone = "us-east-1a" + + mock_subnet2 = MagicMock() + mock_subnet2.arn = "arn:aws:ec2:us-east-1:123456789012:subnet/subnet-bbb222" + mock_subnet2.id = "subnet-bbb222" + mock_subnet2.region = "us-east-1" + mock_subnet2.vpc_id = "vpc-abc123" + mock_subnet2.cidr_block = "10.0.2.0/24" + mock_subnet2.availability_zone = "us-east-1b" + + mock_module.vpc_client.vpcs = [mock_vpc] + mock_module.vpc_client.subnets = [mock_subnet1, mock_subnet2] + mock_module.vpc_client.vpc_peering_connections = [] + mock_module.vpc_client.audited_account = "123456789012" + + return mock_module + + +def main(): + """Main function to demonstrate the inventory graph generation.""" + print("=" * 70) + print("AWS Inventory Graph - Mock Data Example") + print("=" * 70) + print() + + # Create mock clients and inject them into sys.modules + print("Creating mock AWS service clients...") + sys.modules["prowler.providers.aws.services.awslambda.awslambda_client"] = ( + create_mock_lambda_client() + ) + sys.modules["prowler.providers.aws.services.ec2.ec2_client"] = ( + create_mock_ec2_client() + ) + sys.modules["prowler.providers.aws.services.vpc.vpc_client"] = ( + create_mock_vpc_client() + ) + print("✓ Mock clients created") + print() + + # Build the graph + print("Building connectivity graph...") + graph = build_graph() + print(f"✓ Graph built: {len(graph.nodes)} nodes, {len(graph.edges)} edges") + print() + + # Display discovered nodes + print("Discovered nodes:") + for node in graph.nodes: + print(f" - {node.type}: {node.name} ({node.region})") + print() + + # Display discovered edges + print("Discovered edges:") + for edge in graph.edges: + source_node = next((n for n in graph.nodes if n.id == edge.source_id), None) + target_node = next((n for n in graph.nodes if n.id == edge.target_id), None) + source_name = source_node.name if source_node else edge.source_id + target_name = target_node.name if target_node else edge.target_id + print(f" - {source_name} --[{edge.edge_type}]--> {target_name}") + print() + + # Write outputs + output_dir = Path(__file__).parent + json_path = output_dir / "example_output.inventory.json" + html_path = output_dir / "example_output.inventory.html" + + print("Writing output files...") + write_json(graph, str(json_path)) + write_html(graph, str(html_path)) + print(f"✓ JSON written to: {json_path}") + print(f"✓ HTML written to: {html_path}") + print() + + print("=" * 70) + print("✓ Example complete!") + print("=" * 70) + print() + print(f"Open the HTML file to view the interactive graph:") + print(f" open {html_path}") + print() + + +if __name__ == "__main__": + main() diff --git a/contrib/inventory-graph/inventory_graph.py b/contrib/inventory-graph/inventory_graph.py new file mode 100755 index 0000000000..d4f9c50582 --- /dev/null +++ b/contrib/inventory-graph/inventory_graph.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +AWS Inventory Connectivity Graph Generator + +A standalone tool that generates interactive connectivity graphs from Prowler AWS scans. +This tool reads from already-loaded AWS service clients in memory and produces: + - JSON graph (nodes + edges) + - Interactive HTML visualization + +Usage: + python inventory_graph.py --output-directory ./output --output-filename my-inventory + +For more information, see README.md +""" + +import argparse +import os +import sys +from datetime import datetime +from pathlib import Path + +# Add the contrib directory to the path so we can import the lib modules +CONTRIB_DIR = Path(__file__).parent +sys.path.insert(0, str(CONTRIB_DIR)) + +from lib.graph_builder import build_graph +from lib.inventory_output import write_json, write_html + + +def parse_arguments(): + """Parse command-line arguments.""" + parser = argparse.ArgumentParser( + description="Generate AWS inventory connectivity graph from Prowler scan data", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Generate graph with default settings + python inventory_graph.py + + # Specify custom output directory and filename + python inventory_graph.py --output-directory ./my-output --output-filename aws-inventory + + # After running a Prowler scan + prowler aws --profile my-profile + python inventory_graph.py --output-directory ./output + +For more information, see README.md + """, + ) + + parser.add_argument( + "--output-directory", + "-o", + default="./output", + help="Directory to save output files (default: ./output)", + ) + + parser.add_argument( + "--output-filename", + "-f", + default=None, + help="Base filename without extension (default: prowler-inventory-)", + ) + + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Enable verbose output", + ) + + return parser.parse_args() + + +def main(): + """Main entry point for the inventory graph generator.""" + args = parse_arguments() + + # Set up output paths + output_dir = Path(args.output_directory) + output_dir.mkdir(parents=True, exist_ok=True) + + # Generate filename with timestamp if not provided + if args.output_filename: + base_filename = args.output_filename + else: + timestamp = datetime.now().strftime("%Y%m%d%H%M%S") + base_filename = f"prowler-inventory-{timestamp}" + + json_path = output_dir / f"{base_filename}.inventory.json" + html_path = output_dir / f"{base_filename}.inventory.html" + + print("=" * 70) + print("AWS Inventory Connectivity Graph Generator") + print("=" * 70) + print() + + # Build the graph from loaded service clients + if args.verbose: + print("Building connectivity graph from loaded AWS service clients...") + + graph = build_graph() + + # Check if any nodes were discovered + if not graph.nodes: + print("⚠️ WARNING: No nodes discovered!") + print() + print("This usually means:") + print(" 1. No Prowler scan has been run yet in this Python session") + print(" 2. No AWS service clients are loaded in memory") + print() + print("To fix this:") + print(" 1. Run a Prowler scan first: prowler aws --output-formats csv") + print(" 2. Then run this script in the same session") + print() + print( + "Alternatively, integrate this tool directly into Prowler's output pipeline." + ) + sys.exit(1) + + print(f"✓ Discovered {len(graph.nodes)} nodes and {len(graph.edges)} edges") + print() + + # Write outputs + if args.verbose: + print(f"Writing JSON output to: {json_path}") + write_json(graph, str(json_path)) + + if args.verbose: + print(f"Writing HTML output to: {html_path}") + write_html(graph, str(html_path)) + + print() + print("=" * 70) + print("✓ Graph generation complete!") + print("=" * 70) + print() + print(f"📄 JSON: {json_path}") + print(f"🌐 HTML: {html_path}") + print() + print(f"Open the HTML file in your browser to explore the interactive graph:") + print(f" open {html_path}") + print() + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n\nInterrupted by user. Exiting...") + sys.exit(130) + except Exception as e: + print(f"\n❌ Error: {e}", file=sys.stderr) + if "--verbose" in sys.argv or "-v" in sys.argv: + import traceback + + traceback.print_exc() + sys.exit(1) diff --git a/prowler/lib/outputs/compliance/csa/__init__.py b/contrib/inventory-graph/lib/__init__.py similarity index 100% rename from prowler/lib/outputs/compliance/csa/__init__.py rename to contrib/inventory-graph/lib/__init__.py diff --git a/contrib/inventory-graph/lib/extractors/__init__.py b/contrib/inventory-graph/lib/extractors/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/inventory-graph/lib/extractors/ec2_extractor.py b/contrib/inventory-graph/lib/extractors/ec2_extractor.py new file mode 100644 index 0000000000..c383e6b6d5 --- /dev/null +++ b/contrib/inventory-graph/lib/extractors/ec2_extractor.py @@ -0,0 +1,94 @@ +from typing import List, Tuple + +from lib.models import ResourceEdge, ResourceNode + + +def extract(client) -> Tuple[List[ResourceNode], List[ResourceEdge]]: + """ + Extract EC2 instance and security-group nodes with their edges. + + Edges produced: + - instance → security-group [network] + - instance → subnet [network] + - security-group → VPC [network] + """ + nodes: List[ResourceNode] = [] + edges: List[ResourceEdge] = [] + + # EC2 Instances + for instance in client.instances: + name = instance.id + for tag in instance.tags or []: + if tag.get("Key") == "Name": + name = tag["Value"] + break + + props = { + "instance_type": getattr(instance, "type", None), + "state": getattr(instance, "state", None), + "vpc_id": getattr(instance, "vpc_id", None), + "subnet_id": getattr(instance, "subnet_id", None), + "public_ip": getattr(instance, "public_ip_address", None), + "private_ip": getattr(instance, "private_ip_address", None), + } + + nodes.append( + ResourceNode( + id=instance.arn, + type="ec2_instance", + name=name, + service="ec2", + region=instance.region, + account_id=client.audited_account, + properties={k: v for k, v in props.items() if v is not None}, + ) + ) + + for sg_id in instance.security_groups or []: + edges.append( + ResourceEdge( + source_id=instance.arn, + target_id=sg_id, + edge_type="network", + label="sg", + ) + ) + + if instance.subnet_id: + edges.append( + ResourceEdge( + source_id=instance.arn, + target_id=instance.subnet_id, + edge_type="network", + label="subnet", + ) + ) + + # Security Groups + for sg in client.security_groups.values(): + name = ( + sg.name if hasattr(sg, "name") else sg.id if hasattr(sg, "id") else sg.arn + ) + nodes.append( + ResourceNode( + id=sg.arn, + type="security_group", + name=name, + service="ec2", + region=sg.region, + account_id=client.audited_account, + properties={"vpc_id": sg.vpc_id}, + ) + ) + + if sg.vpc_id: + edges.append( + ResourceEdge( + source_id=sg.arn, + target_id=sg.vpc_id, + edge_type="network", + label="in-vpc", + ) + ) + + return nodes, edges diff --git a/contrib/inventory-graph/lib/extractors/elbv2_extractor.py b/contrib/inventory-graph/lib/extractors/elbv2_extractor.py new file mode 100644 index 0000000000..f06d3295c7 --- /dev/null +++ b/contrib/inventory-graph/lib/extractors/elbv2_extractor.py @@ -0,0 +1,60 @@ +from typing import List, Tuple + +from lib.models import ResourceEdge, ResourceNode + + +def extract(client) -> Tuple[List[ResourceNode], List[ResourceEdge]]: + """ + Extract ELBv2 (ALB/NLB) load balancer nodes and their edges. + + Edges produced: + - load_balancer → security-group [network] + - load_balancer → VPC [network] + """ + nodes: List[ResourceNode] = [] + edges: List[ResourceEdge] = [] + + for lb in client.loadbalancersv2.values(): + props = { + "type": getattr(lb, "type", None), + "scheme": getattr(lb, "scheme", None), + "dns_name": getattr(lb, "dns", None), + "vpc_id": getattr(lb, "vpc_id", None), + } + + name = getattr(lb, "name", lb.arn.split("/")[-2] if "/" in lb.arn else lb.arn) + + nodes.append( + ResourceNode( + id=lb.arn, + type="load_balancer", + name=name, + service="elbv2", + region=lb.region, + account_id=client.audited_account, + properties={k: v for k, v in props.items() if v is not None}, + ) + ) + + for sg_id in lb.security_groups or []: + edges.append( + ResourceEdge( + source_id=lb.arn, + target_id=sg_id, + edge_type="network", + label="sg", + ) + ) + + vpc_id = getattr(lb, "vpc_id", None) + if vpc_id: + edges.append( + ResourceEdge( + source_id=lb.arn, + target_id=vpc_id, + edge_type="network", + label="in-vpc", + ) + ) + + return nodes, edges diff --git a/contrib/inventory-graph/lib/extractors/iam_extractor.py b/contrib/inventory-graph/lib/extractors/iam_extractor.py new file mode 100644 index 0000000000..99d671e4ae --- /dev/null +++ b/contrib/inventory-graph/lib/extractors/iam_extractor.py @@ -0,0 +1,84 @@ +import json +from typing import Any, Dict, List, Tuple + +from prowler.lib.logger import logger +from lib.models import ResourceEdge, ResourceNode + + +def _parse_trust_principals(assume_role_policy: Any) -> List[str]: + """ + Return a flat list of principal strings from an IAM assume-role policy document. + The policy may be a dict already or a JSON string. + """ + if not assume_role_policy: + return [] + + if isinstance(assume_role_policy, str): + try: + assume_role_policy = json.loads(assume_role_policy) + except (json.JSONDecodeError, ValueError): + return [] + + principals = [] + for statement in assume_role_policy.get("Statement", []): + principal = statement.get("Principal", {}) + if isinstance(principal, str): + principals.append(principal) + elif isinstance(principal, dict): + for v in principal.values(): + if isinstance(v, list): + principals.extend(v) + else: + principals.append(v) + elif isinstance(principal, list): + principals.extend(principal) + + return principals + + +def extract(client) -> Tuple[List[ResourceNode], List[ResourceEdge]]: + """ + Extract IAM role nodes and their trust-relationship edges. + + Edges produced: + - trusted-principal → role [iam] (who can assume this role) + """ + nodes: List[ResourceNode] = [] + edges: List[ResourceEdge] = [] + + for role in client.roles: + props: Dict[str, Any] = { + "path": getattr(role, "path", None), + "create_date": str(getattr(role, "create_date", "") or ""), + } + + nodes.append( + ResourceNode( + id=role.arn, + type="iam_role", + name=role.name, + service="iam", + region="global", + account_id=client.audited_account, + properties={k: v for k, v in props.items() if v}, + ) + ) + + # Trust-relationship edges: principal → role (principal CAN assume role) + try: + for principal in _parse_trust_principals(role.assume_role_policy): + if principal and principal != "*": + edges.append( + ResourceEdge( + source_id=principal, + target_id=role.arn, + edge_type="iam", + label="can-assume", + ) + ) + except Exception as e: + logger.debug( + f"inventory iam_extractor: could not parse trust policy for {role.arn}: {e}" + ) + + return nodes, edges diff --git a/contrib/inventory-graph/lib/extractors/lambda_extractor.py b/contrib/inventory-graph/lib/extractors/lambda_extractor.py new file mode 100644 index 0000000000..63b0a0b6d0 --- /dev/null +++ b/contrib/inventory-graph/lib/extractors/lambda_extractor.py @@ -0,0 +1,118 @@ +from typing import List, Tuple + +from lib.models import ResourceEdge, ResourceNode + + +def extract(client) -> Tuple[List[ResourceNode], List[ResourceEdge]]: + """ + Extract Lambda function nodes and their edges from an awslambda_client. + + Edges produced: + - lambda → VPC [network] + - lambda → subnet [network] + - lambda → sg [network] + - lambda → event-source[triggers] (from EventSourceMapping) + - lambda → layer ARN [depends_on] + - lambda → DLQ target [data_flow] + - lambda → KMS key [encrypts] + """ + nodes: List[ResourceNode] = [] + edges: List[ResourceEdge] = [] + + for fn in client.functions.values(): + props = { + "runtime": fn.runtime, + "vpc_id": fn.vpc_id, + } + if fn.environment: + props["has_env_vars"] = True + if fn.kms_key_arn: + props["kms_key_arn"] = fn.kms_key_arn + + nodes.append( + ResourceNode( + id=fn.arn, + type="lambda_function", + name=fn.name, + service="lambda", + region=fn.region, + account_id=client.audited_account, + properties=props, + ) + ) + + # Network edges → VPC, subnets, security groups + if fn.vpc_id: + edges.append( + ResourceEdge( + source_id=fn.arn, + target_id=fn.vpc_id, + edge_type="network", + label="in-vpc", + ) + ) + for sg_id in fn.security_groups or []: + edges.append( + ResourceEdge( + source_id=fn.arn, + target_id=sg_id, + edge_type="network", + label="sg", + ) + ) + for subnet_id in fn.subnet_ids or set(): + edges.append( + ResourceEdge( + source_id=fn.arn, + target_id=subnet_id, + edge_type="network", + label="subnet", + ) + ) + + # Trigger edges from event source mappings + for esm in getattr(fn, "event_source_mappings", []): + edges.append( + ResourceEdge( + source_id=esm.event_source_arn, + target_id=fn.arn, + edge_type="triggers", + label=f"esm:{esm.state}", + ) + ) + + # Layer dependency edges + for layer in getattr(fn, "layers", []): + edges.append( + ResourceEdge( + source_id=fn.arn, + target_id=layer.arn, + edge_type="depends_on", + label="layer", + ) + ) + + # Dead-letter queue data-flow edge + dlq = getattr(fn, "dead_letter_config", None) + if dlq and dlq.target_arn: + edges.append( + ResourceEdge( + source_id=fn.arn, + target_id=dlq.target_arn, + edge_type="data_flow", + label="dlq", + ) + ) + + # KMS encryption edge + if fn.kms_key_arn: + edges.append( + ResourceEdge( + source_id=fn.kms_key_arn, + target_id=fn.arn, + edge_type="encrypts", + label="kms", + ) + ) + + return nodes, edges diff --git a/contrib/inventory-graph/lib/extractors/rds_extractor.py b/contrib/inventory-graph/lib/extractors/rds_extractor.py new file mode 100644 index 0000000000..3772453edc --- /dev/null +++ b/contrib/inventory-graph/lib/extractors/rds_extractor.py @@ -0,0 +1,86 @@ +from typing import List, Tuple + +from lib.models import ResourceEdge, ResourceNode + + +def extract(client) -> Tuple[List[ResourceNode], List[ResourceEdge]]: + """ + Extract RDS DB instance nodes and their edges. + + Edges produced: + - db_instance → security-group [network] + - db_instance → VPC [network] + - db_instance → cluster [depends_on] + - db_instance → KMS key [encrypts] + """ + nodes: List[ResourceNode] = [] + edges: List[ResourceEdge] = [] + + for db in client.db_instances.values(): + props = { + "engine": getattr(db, "engine", None), + "engine_version": getattr(db, "engine_version", None), + "instance_class": getattr(db, "db_instance_class", None), + "vpc_id": getattr(db, "vpc_id", None), + "multi_az": getattr(db, "multi_az", None), + "publicly_accessible": getattr(db, "publicly_accessible", None), + "storage_encrypted": getattr(db, "storage_encrypted", None), + } + + nodes.append( + ResourceNode( + id=db.arn, + type="rds_instance", + name=db.id, + service="rds", + region=db.region, + account_id=client.audited_account, + properties={k: v for k, v in props.items() if v is not None}, + ) + ) + + for sg in getattr(db, "security_groups", []): + sg_id = sg if isinstance(sg, str) else getattr(sg, "id", str(sg)) + edges.append( + ResourceEdge( + source_id=db.arn, + target_id=sg_id, + edge_type="network", + label="sg", + ) + ) + + vpc_id = getattr(db, "vpc_id", None) + if vpc_id: + edges.append( + ResourceEdge( + source_id=db.arn, + target_id=vpc_id, + edge_type="network", + label="in-vpc", + ) + ) + + cluster_arn = getattr(db, "cluster_arn", None) + if cluster_arn: + edges.append( + ResourceEdge( + source_id=db.arn, + target_id=cluster_arn, + edge_type="depends_on", + label="cluster-member", + ) + ) + + kms_key_id = getattr(db, "kms_key_id", None) + if kms_key_id: + edges.append( + ResourceEdge( + source_id=kms_key_id, + target_id=db.arn, + edge_type="encrypts", + label="kms", + ) + ) + + return nodes, edges diff --git a/contrib/inventory-graph/lib/extractors/s3_extractor.py b/contrib/inventory-graph/lib/extractors/s3_extractor.py new file mode 100644 index 0000000000..7be603c52a --- /dev/null +++ b/contrib/inventory-graph/lib/extractors/s3_extractor.py @@ -0,0 +1,92 @@ +from typing import List, Tuple + +from lib.models import ResourceEdge, ResourceNode + + +def extract(client) -> Tuple[List[ResourceNode], List[ResourceEdge]]: + """ + Extract S3 bucket nodes and their edges. + + Edges produced: + - bucket → replication-target bucket [replicates_to] + - bucket → KMS key [encrypts] + - bucket → logging bucket [logs_to] + """ + nodes: List[ResourceNode] = [] + edges: List[ResourceEdge] = [] + + for bucket in client.buckets.values(): + encryption = getattr(bucket, "encryption", None) + versioning = getattr(bucket, "versioning_enabled", None) + logging = getattr(bucket, "logging", None) + public = getattr(bucket, "public_access_block", None) + + props = {} + if versioning is not None: + props["versioning"] = versioning + if encryption: + enc_type = getattr(encryption, "type", str(encryption)) + props["encryption"] = enc_type + + nodes.append( + ResourceNode( + id=bucket.arn, + type="s3_bucket", + name=bucket.name, + service="s3", + region=bucket.region, + account_id=client.audited_account, + properties=props, + ) + ) + + # Replication edges + for rule in getattr(bucket, "replication_rules", None) or []: + dest_bucket = getattr(rule, "destination_bucket", None) + if dest_bucket: + dest_arn = ( + dest_bucket + if dest_bucket.startswith("arn:") + else f"arn:aws:s3:::{dest_bucket}" + ) + edges.append( + ResourceEdge( + source_id=bucket.arn, + target_id=dest_arn, + edge_type="replicates_to", + label="s3-replication", + ) + ) + + # Logging edges + if logging: + target_bucket = getattr(logging, "target_bucket", None) + if target_bucket: + target_arn = ( + target_bucket + if target_bucket.startswith("arn:") + else f"arn:aws:s3:::{target_bucket}" + ) + edges.append( + ResourceEdge( + source_id=bucket.arn, + target_id=target_arn, + edge_type="logs_to", + label="access-logs", + ) + ) + + # KMS encryption edges + if encryption: + kms_arn = getattr(encryption, "kms_master_key_id", None) + if kms_arn: + edges.append( + ResourceEdge( + source_id=kms_arn, + target_id=bucket.arn, + edge_type="encrypts", + label="kms", + ) + ) + + return nodes, edges diff --git a/contrib/inventory-graph/lib/extractors/vpc_extractor.py b/contrib/inventory-graph/lib/extractors/vpc_extractor.py new file mode 100644 index 0000000000..938554d2ae --- /dev/null +++ b/contrib/inventory-graph/lib/extractors/vpc_extractor.py @@ -0,0 +1,92 @@ +from typing import List, Tuple + +from lib.models import ResourceEdge, ResourceNode + + +def extract(client) -> Tuple[List[ResourceNode], List[ResourceEdge]]: + """ + Extract VPC and subnet nodes with their edges. + + Edges produced: + - subnet → VPC [depends_on] + - peering connection between VPCs [network] + """ + nodes: List[ResourceNode] = [] + edges: List[ResourceEdge] = [] + + # VPCs + for vpc in client.vpcs.values(): + name = vpc.id if hasattr(vpc, "id") else vpc.arn + for tag in vpc.tags or []: + if isinstance(tag, dict) and tag.get("Key") == "Name": + name = tag["Value"] + break + + nodes.append( + ResourceNode( + id=vpc.arn, + type="vpc", + name=name, + service="vpc", + region=vpc.region, + account_id=client.audited_account, + properties={ + "cidr_block": getattr(vpc, "cidr_block", None), + "is_default": getattr(vpc, "is_default", None), + }, + ) + ) + + # VPC Subnets + for subnet in client.vpc_subnets.values(): + name = subnet.id if hasattr(subnet, "id") else subnet.arn + for tag in getattr(subnet, "tags", None) or []: + if isinstance(tag, dict) and tag.get("Key") == "Name": + name = tag["Value"] + break + + nodes.append( + ResourceNode( + id=subnet.arn, + type="subnet", + name=name, + service="vpc", + region=subnet.region, + account_id=client.audited_account, + properties={ + "vpc_id": getattr(subnet, "vpc_id", None), + "cidr_block": getattr(subnet, "cidr_block", None), + "availability_zone": getattr(subnet, "availability_zone", None), + "public": getattr(subnet, "public", None), + }, + ) + ) + + vpc_id = getattr(subnet, "vpc_id", None) + if vpc_id: + # Find the VPC ARN for this vpc_id + vpc_arn = next( + (v.arn for v in client.vpcs.values() if v.id == vpc_id), + vpc_id, + ) + edges.append( + ResourceEdge( + source_id=subnet.arn, + target_id=vpc_arn, + edge_type="depends_on", + label="subnet-of", + ) + ) + + # VPC Peering Connections + for peering in getattr(client, "vpc_peering_connections", {}).values(): + edges.append( + ResourceEdge( + source_id=peering.arn, + target_id=getattr(peering, "accepter_vpc_id", peering.arn), + edge_type="network", + label="vpc-peer", + ) + ) + + return nodes, edges diff --git a/contrib/inventory-graph/lib/graph_builder.py b/contrib/inventory-graph/lib/graph_builder.py new file mode 100644 index 0000000000..073f122a9f --- /dev/null +++ b/contrib/inventory-graph/lib/graph_builder.py @@ -0,0 +1,106 @@ +""" +graph_builder.py +---------------- +Builds a ConnectivityGraph by reading already-loaded AWS service clients from +sys.modules. Only services that were actually scanned (i.e. whose client +module is already imported) contribute nodes and edges. Unknown / unloaded +services are silently skipped, so the output degrades gracefully when only a +subset of checks has been run. +""" + +import sys +from typing import Tuple + +from prowler.lib.logger import logger +from lib.models import ConnectivityGraph + +# Registry: (sys.modules key, attribute name inside that module, extractor module path) +_SERVICE_REGISTRY: Tuple[Tuple[str, str, str], ...] = ( + ( + "prowler.providers.aws.services.awslambda.awslambda_client", + "awslambda_client", + "lib.extractors.lambda_extractor", + ), + ( + "prowler.providers.aws.services.ec2.ec2_client", + "ec2_client", + "lib.extractors.ec2_extractor", + ), + ( + "prowler.providers.aws.services.vpc.vpc_client", + "vpc_client", + "lib.extractors.vpc_extractor", + ), + ( + "prowler.providers.aws.services.rds.rds_client", + "rds_client", + "lib.extractors.rds_extractor", + ), + ( + "prowler.providers.aws.services.elbv2.elbv2_client", + "elbv2_client", + "lib.extractors.elbv2_extractor", + ), + ( + "prowler.providers.aws.services.s3.s3_client", + "s3_client", + "lib.extractors.s3_extractor", + ), + ( + "prowler.providers.aws.services.iam.iam_client", + "iam_client", + "lib.extractors.iam_extractor", + ), +) + + +def build_graph() -> ConnectivityGraph: + """ + Iterate over every registered service, check whether its client module is + already loaded, and call the corresponding extractor. + + Returns a ConnectivityGraph with all discovered nodes and edges. + Duplicate node IDs are silently deduplicated (first occurrence wins). + """ + graph = ConnectivityGraph() + seen_node_ids: set = set() + + for client_module_key, client_attr, extractor_module_key in _SERVICE_REGISTRY: + client_module = sys.modules.get(client_module_key) + if client_module is None: + continue + + service_client = getattr(client_module, client_attr, None) + if service_client is None: + continue + + extractor_module = sys.modules.get(extractor_module_key) + if extractor_module is None: + try: + import importlib + + extractor_module = importlib.import_module(extractor_module_key) + except ImportError as e: + logger.debug( + f"inventory graph_builder: cannot import extractor {extractor_module_key}: {e}" + ) + continue + + try: + nodes, edges = extractor_module.extract(service_client) + except Exception as e: + logger.error( + f"inventory graph_builder: extractor {extractor_module_key} failed: " + f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}" + ) + continue + + for node in nodes: + if node.id not in seen_node_ids: + graph.add_node(node) + seen_node_ids.add(node.id) + + for edge in edges: + graph.add_edge(edge) + + return graph diff --git a/contrib/inventory-graph/lib/inventory_output.py b/contrib/inventory-graph/lib/inventory_output.py new file mode 100644 index 0000000000..359e7eb91f --- /dev/null +++ b/contrib/inventory-graph/lib/inventory_output.py @@ -0,0 +1,502 @@ +""" +inventory_output.py +------------------- +Writes the ConnectivityGraph produced by graph_builder to two files: + + .inventory.json – machine-readable graph (nodes + edges) + .inventory.html – interactive D3.js force-directed graph +""" + +import json +import os +from dataclasses import asdict +from datetime import datetime +from typing import Optional + +from prowler.lib.logger import logger +from lib.models import ConnectivityGraph + + +# --------------------------------------------------------------------------- +# JSON output +# --------------------------------------------------------------------------- + + +def write_json(graph: ConnectivityGraph, file_path: str) -> None: + """Serialise the graph to a JSON file.""" + try: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + data = { + "generated_at": datetime.utcnow().isoformat() + "Z", + "nodes": [asdict(n) for n in graph.nodes], + "edges": [asdict(e) for e in graph.edges], + "stats": { + "node_count": len(graph.nodes), + "edge_count": len(graph.edges), + }, + } + with open(file_path, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2, default=str) + logger.info(f"Inventory graph JSON written to {file_path}") + except Exception as e: + logger.error( + f"inventory_output.write_json: {e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}" + ) + + +# --------------------------------------------------------------------------- +# HTML output (self-contained, D3.js CDN) +# --------------------------------------------------------------------------- + +# Colour palette per node type +_NODE_COLOURS = { + "lambda_function": "#f59e0b", + "ec2_instance": "#3b82f6", + "security_group": "#6366f1", + "vpc": "#10b981", + "subnet": "#34d399", + "rds_instance": "#ef4444", + "load_balancer": "#8b5cf6", + "s3_bucket": "#06b6d4", + "iam_role": "#f97316", + "default": "#94a3b8", +} + +# Edge stroke colours per edge type +_EDGE_COLOURS = { + "network": "#64748b", + "iam": "#f97316", + "triggers": "#a855f7", + "data_flow": "#0ea5e9", + "depends_on": "#94a3b8", + "routes_to": "#22c55e", + "replicates_to": "#ec4899", + "encrypts": "#eab308", + "logs_to": "#78716c", +} + +_HTML_TEMPLATE = """\ + + + + + + Prowler – AWS Connectivity Graph + + + + + +
+ + + + +
+
+ +
+
+

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..81eda5f690 100644 --- a/contrib/k8s/helm/prowler-api/values.yaml +++ b/contrib/k8s/helm/prowler-api/values.yaml @@ -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: @@ -590,13 +590,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/values.yaml b/contrib/k8s/helm/prowler-app/values.yaml index 9a162bd67e..9acca09f2a 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 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/docker-compose-dev.yml b/docker-compose-dev.yml index b95945e7cb..522aaf5413 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -37,7 +37,7 @@ services: neo4j: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:${DJANGO_PORT:-8080}/api/v1/ || exit 1"] + 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 @@ -139,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 @@ -211,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 5a4514546a..1519946afd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,7 +33,7 @@ services: neo4j: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:${DJANGO_PORT:-8080}/api/v1/ || exit 1"] + 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 @@ -48,10 +48,16 @@ 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 @@ -123,6 +129,8 @@ 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 @@ -170,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/checks.mdx b/docs/developer-guide/checks.mdx index c5dacc72d9..2f0f45cd96 100644 --- a/docs/developer-guide/checks.mdx +++ b/docs/developer-guide/checks.mdx @@ -20,8 +20,8 @@ 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. 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/introduction.mdx b/docs/developer-guide/introduction.mdx index 8fccf1b202..117640c669 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,28 +97,21 @@ 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). - - - ### Pre-Commit Hooks -This repository uses Git pre-commit hooks managed by the [prek](https://prek.j178.dev/) 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 prek install @@ -155,11 +148,11 @@ Once installed, TruffleHog runs before each push and blocks the operation when v 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. @@ -183,7 +176,7 @@ 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 `poetry.lock` records the full resolved dependency tree and the artifact hashes for every package. Use `poetry install` from the lock file instead of ad-hoc `pip` installs when you need a reproducible environment. +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). @@ -209,8 +202,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/provider.mdx b/docs/developer-guide/provider.mdx index f7a1057d11..d3e7d3631f 100644 --- a/docs/developer-guide/provider.mdx +++ b/docs/developer-guide/provider.mdx @@ -1277,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/security-compliance-framework.mdx b/docs/developer-guide/security-compliance-framework.mdx index cf6c03816f..030d876aab 100644 --- a/docs/developer-guide/security-compliance-framework.mdx +++ b/docs/developer-guide/security-compliance-framework.mdx @@ -2,40 +2,228 @@ title: 'Creating a New Security Compliance Framework in Prowler' --- -This guide explains how to add a new security compliance framework to Prowler, end to end. It covers directory layout, the JSON schema, check mapping conventions, the Pydantic models that validate each framework, the CSV output formatter, local validation, testing, and the pull request process. +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 -A compliance framework in Prowler maps a public or custom control catalog (for example CIS, NIST 800-53, PCI DSS, HIPAA, ENS, CCC) 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. +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. -Prowler ships with 85+ compliance frameworks across All Providers. The catalog lives under `prowler/compliance//` (or `prowler/compliance/` for universal compliance frameworks) +Prowler ships 85+ compliance frameworks across all providers. The catalog lives under `prowler/compliance//` (legacy, per-provider) or `prowler/compliance/` (universal, multi-provider). -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 none of the existing Prowler checks can automate it. In that case, leave `Checks` as an empty array, but do not omit the requirement. +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. 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. +### Two supported schemas + +| 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:915`), 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//` for an existing JSON file matching the name and version. +- **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. `cis_2.0_aws.json` is the canonical reference for CIS-style frameworks. `ccc_aws.json`, `ens_rd2022_aws.json`, and `nist_800_53_revision_5_aws.json` illustrate other attribute shapes. +- **Review a reference framework.** Use an existing framework with a similar structure as your template: + - Universal: `prowler/compliance/dora.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`. -## Four-Layer Architecture +## Universal Compliance Framework -A compliance framework spans four layers. A complete contribution must touch each layer that applies. +### Where the file lives -- **Layer 1 – Schema validation:** The Pydantic models in `prowler/lib/check/compliance_models.py` define the canonical schema for each attribute shape (CIS, ENS, Mitre, CCC, C5, CSA CCM, ISO 27001, KISA ISMS-P, AWS Well-Architected, Prowler ThreatScore, and a generic fallback). -- **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. +Place the file at the top level of the compliance directory: -The rest of this guide walks each layer in order. +``` +prowler/compliance/.json +``` -## Directory Structure and File Naming +Examples in the repository: `prowler/compliance/csa_ccm_4.0.json`, `prowler/compliance/dora.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.json` → `dora`). + +### Top-level structure + +```json +{ + "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:739`) 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.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:669`) 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). + +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: @@ -46,8 +234,8 @@ prowler/compliance//__.json The filename conventions are: - All lowercase, words separated with underscores. -- `` is a supported provider identifier: `aws`, `azure`, `gcp`, `kubernetes`, `m365`, `github`, `googleworkspace`, `alibabacloud`, `oraclecloud`, `cloudflare`, `mongodbatlas`, `nhn`, `openstack`, `iac`, `llm`. -- `` is optional. Omit it when the framework has no versioning, as in `ccc_aws.json`. +- `` 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: @@ -62,48 +250,50 @@ The output formatter directory mirrors the framework name: ``` prowler/lib/outputs/compliance// -├── .py # CLI summary-table dispatcher +├── .py # CLI summary-table dispatcher ├── _.py # Per-provider transformer class ├── models.py # Pydantic CSV row model └── __init__.py ``` -## JSON Schema Reference +### JSON schema reference -Every compliance file is a JSON document with the following top-level keys. +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:329`). | 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 | Framework version, for example `2.0`. Use an empty string only for frameworks without versioning. See [Version Handling](#version-handling). | +| `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 +#### 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 used by frameworks that distinguish control name from description, such as NIST. | +| `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. | -### Attribute Objects +#### Attribute Objects -Attributes carry the metadata that Prowler App and the CSV output display for each requirement. The object shape is framework-specific and is validated by a dedicated Pydantic model in `prowler/lib/check/compliance_models.py`. The most common shapes are summarized below. +`Attributes` is parsed against the union declared in `Compliance_Requirement.Attributes` (`compliance_models.py:293`). 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. -#### CIS_Requirement_Attribute +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, for example `1 Identity and Access Management`. | +| `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`. | @@ -116,7 +306,7 @@ Used by every CIS benchmark. | `DefaultValue` | string | No | Default configuration value, when relevant. | | `References` | string | Yes | Colon-separated list of reference URLs. | -#### ENS_Requirement_Attribute +##### ENS_Requirement_Attribute Used by the Spanish ENS (Esquema Nacional de Seguridad) frameworks. @@ -132,13 +322,13 @@ Used by the Spanish ENS (Esquema Nacional de Seguridad) frameworks. | `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 +##### CCC_Requirement_Attribute Used by the Common Cloud Controls Catalog. | Field | Type | Required | Notes | |---|---|---|---| -| `FamilyName` | string | Yes | Control family, for example `Data`. | +| `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. | @@ -148,9 +338,9 @@ Used by the Common Cloud Controls Catalog. | `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 +##### Generic_Compliance_Requirement_Attribute -The fallback attribute model used when no framework-specific schema applies (for example NIST 800-53, PCI DSS, GDPR, HIPAA). +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 | |---|---|---|---| @@ -158,17 +348,17 @@ The fallback attribute model used when no framework-specific schema applies (for | `Section` | string | No | Section name. | | `SubSection` | string | No | Subsection name. | | `SubGroup` | string | No | Subgroup name. | -| `Service` | string | No | Affected service, for example `aws`, `iam`. | +| `Service` | string | No | Affected service, e.g. `iam`. | | `Type` | string | No | Control type. | | `Comment` | string | No | Free-form comment. | -Additional per-framework attribute models exist for `AWS_Well_Architected_Requirement_Attribute`, `ISO27001_2013_Requirement_Attribute`, `Mitre_Requirement_Attribute_`, `KISA_ISMSP_Requirement_Attribute`, `Prowler_ThreatScore_Requirement_Attribute`, `C5Germany_Requirement_Attribute`, and `CSA_CCM_Requirement_Attribute`. Consult `prowler/lib/check/compliance_models.py` for their full field sets. +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. +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 +#### 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. @@ -214,26 +404,26 @@ The following snippet is a complete, valid framework file named `my_framework_1. } ``` -## Mapping Checks to Requirements +### 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, so omitting an unmappable control inflates coverage and misrepresents the framework. -- List every check by its canonical identifier, the value of `CheckID` inside the check's `.metadata.json` file. +- **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` as an empty array when the requirement cannot be automated. The requirement still appears in the report, contributes to the total, and resolves to `MANUAL`. An empty mapping is valid; a missing requirement is not. +- 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 compliance file is bound to one provider, and cross-provider checks will never match findings in the scan. +- 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, run: +To discover available checks: ```bash -poetry run python prowler-cli.py --list-checks +uv run python prowler-cli.py --list-checks ``` -## Supporting Multiple Providers +### Supporting multiple providers (legacy) -Each compliance file targets a single provider. To cover several providers with the same framework (for example CIS across AWS, Azure, and GCP), ship one JSON file per provider: +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 @@ -241,15 +431,15 @@ 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, and change only the `Provider`, `Checks`, and provider-specific metadata. +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. -The CIS output formatter already supports every provider listed above. For a brand-new framework that spans several providers, add one transformer per provider in `prowler/lib/outputs/compliance//` and extend the summary-table dispatcher accordingly. See [Output Formatter](#output-formatter). +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 +### Output formatter -Prowler renders every compliance framework 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. +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 framework named `my_framework`, create: +For a new legacy framework named `my_framework`, create: ``` prowler/lib/outputs/compliance/my_framework/ @@ -259,19 +449,19 @@ prowler/lib/outputs/compliance/my_framework/ └── models.py # CSV row Pydantic model ``` -### Step 1 – Define the CSV Row 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 Class +#### 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 +#### 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 +#### 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. @@ -280,49 +470,94 @@ In `my_framework.py`, implement `get_my_framework_table(findings, bulk_checks_me 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. -## Version Handling +### Legacy-to-universal adapter + +At load time, every legacy file is transparently adapted to a `ComplianceFramework` via `adapt_legacy_to_universal()` (`compliance_models.py:819`), 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:464`). +- `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. + +## 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` 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`). +- 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. -- 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. +- 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 the Framework Locally +## Validating Your Framework -Follow the steps below before opening a pull request. +Before opening a PR, validate the JSON loads cleanly against the model and that every referenced check actually exists. -### 1. Run the Compliance Model Validator +### 1. Schema validation -```bash -poetry run python prowler-cli.py --list-compliance +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.json` that key is `dora`, 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 ``` -The framework must appear in the output. A validation error indicates a schema mismatch between the JSON file and the attribute model. +### 2. Check existence cross-check -### 2. Run a Scan Filtered by the Framework +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 -poetry run python prowler-cli.py \ - --compliance __ \ +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 in the framework. +- The CLI summary table lists every section / pillar of the framework. - Findings roll up under the expected requirements. -### 3. Inspect the CSV Output +### 4. Inspect the CSV output Open the generated CSV and confirm: -- All columns defined in `models.py` appear. -- Every requirement has at least one row per scanned resource. -- Values such as `Requirements_Attributes_Section` reflect the JSON content. +- 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. -### 4. Verify the Framework in Prowler App +### 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. @@ -331,49 +566,65 @@ Launch Prowler App locally (`docker compose up` from the repository root) and ru 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** 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. +- **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 -poetry run pytest -n auto tests/lib/check/universal_compliance_models_test.py \ +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). -## Submitting the Pull Request +## 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 - poetry run pre-commit run --all-files - poetry run pytest -n auto + 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, for example `feat(compliance): add My Framework 1.0 for AWS`. +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.** 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.** The Pydantic `Union` is ordered incorrectly, or the JSON matches only the generic shape. Move the generic model to the last Union position and ensure every required field is present in the JSON. -- **`--compliance` filter does not find the framework.** The filename does not match the expected pattern `__.json`, the version is empty, or the file lives outside `prowler/compliance//`. -- **CLI summary table is empty but the CSV is populated.** 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.** 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. +- **`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:669` 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 +## Reference examples Use the following files as templates when modeling a new contribution. -- `prowler/compliance/aws/cis_2.0_aws.json` – CIS attribute shape. -- `prowler/compliance/aws/nist_800_53_revision_5_aws.json` – Generic attribute shape. -- `prowler/compliance/aws/ccc_aws.json` – CCC attribute shape. -- `prowler/compliance/azure/ens_rd2022_azure.json` – ENS attribute shape. -- `prowler/lib/check/compliance_models.py` – Canonical Pydantic schemas. -- `prowler/lib/outputs/compliance/cis/` – Reference implementation of a multi-provider output formatter. -- `prowler/lib/outputs/compliance/generic/` – Reference implementation of a generic output formatter. +- `prowler/compliance/dora.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/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 bee52ec9bb..e31575878e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -73,6 +73,12 @@ "getting-started/products/prowler-lighthouse-ai" ] }, + { + "group": "Prowler for Claude Code", + "pages": [ + "getting-started/products/prowler-claude-code-plugin" + ] + }, { "group": "Prowler MCP Server", "pages": [ @@ -118,8 +124,8 @@ "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-app-alerts", + "user-guide/tutorials/prowler-import-findings", + "user-guide/tutorials/prowler-alerts", { "group": "Mutelist", "expanded": true, @@ -326,12 +332,33 @@ "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" + ] } ] }, @@ -346,7 +373,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" ] } ] @@ -380,7 +408,8 @@ "developer-guide/kubernetes-details", "developer-guide/m365-details", "developer-guide/github-details", - "developer-guide/llm-details" + "developer-guide/llm-details", + "developer-guide/stackit-details" ] }, { @@ -555,6 +584,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-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 2ed4481ae1..dea9c09446 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.26.1" -PROWLER_API_VERSION="5.26.1" +PROWLER_UI_VERSION="5.29.0" +PROWLER_API_VERSION="5.29.0" ``` diff --git a/docs/getting-started/installation/prowler-cli.mdx b/docs/getting-started/installation/prowler-cli.mdx index 2f82d53166..f87653dcfe 100644 --- a/docs/getting-started/installation/prowler-cli.mdx +++ b/docs/getting-started/installation/prowler-cli.mdx @@ -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 ``` @@ -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/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/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/introduction.mdx b/docs/introduction.mdx index 5747eeadb1..fe530e4a4f 100644 --- a/docs/introduction.mdx +++ b/docs/introduction.mdx @@ -32,9 +32,11 @@ Prowler supports a wide range of providers organized by category: | [Azure](/user-guide/providers/azure/getting-started-azure) | Official | Subscriptions | UI, API, CLI | | [Cloudflare](/user-guide/providers/cloudflare/getting-started-cloudflare) | Official | Accounts | UI, API, CLI | | [Google Cloud](/user-guide/providers/gcp/getting-started-gcp) | Official | Projects | UI, API, CLI | -| **NHN** | Unofficial | Tenants | CLI | +| **NHN** | [Contact us](https://prowler.com/contact) | Tenants | CLI | | [OpenStack](/user-guide/providers/openstack/getting-started-openstack) | Official | Projects | UI, API, CLI | | [Oracle Cloud](/user-guide/providers/oci/getting-started-oci) | Official | Tenancies / Compartments | UI, API, CLI | +| [Scaleway](/user-guide/providers/scaleway/getting-started-scaleway) | [Contact us](https://prowler.com/contact) | Organizations | CLI | +| [StackIT](/user-guide/providers/stackit/getting-started-stackit) | [Contact us](https://prowler.com/contact) | Projects | CLI | ### Infrastructure as Code Providers @@ -47,11 +49,12 @@ Prowler supports a wide range of providers organized by category: | 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 | 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 | -| [Vercel](/user-guide/providers/vercel/getting-started-vercel) | Official | Teams / Projects | 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 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/user-guide/cli/tutorials/configuration_file.mdx b/docs/user-guide/cli/tutorials/configuration_file.mdx index f6f91268a1..caf8853a1b 100644 --- a/docs/user-guide/cli/tutorials/configuration_file.mdx +++ b/docs/user-guide/cli/tutorials/configuration_file.mdx @@ -91,6 +91,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 | @@ -158,6 +159,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 @@ -525,6 +536,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/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 index 6221f1ad77..63b2502b25 100644 --- a/docs/user-guide/compliance/tutorials/compliance.mdx +++ b/docs/user-guide/compliance/tutorials/compliance.mdx @@ -149,6 +149,14 @@ 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. diff --git a/docs/user-guide/cookbooks/cicd-pipeline.mdx b/docs/user-guide/cookbooks/cicd-pipeline.mdx index ee7d49eb91..a88513d901 100644 --- a/docs/user-guide/cookbooks/cicd-pipeline.mdx +++ b/docs/user-guide/cookbooks/cicd-pipeline.mdx @@ -6,7 +6,7 @@ title: 'Run Prowler in CI/CD and Send Findings to Prowler Cloud' 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-app-import-findings). Examples cover GitHub Actions and GitLab CI. +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 @@ -19,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. @@ -195,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/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/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 00320bd34f..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 diff --git a/docs/user-guide/providers/github/getting-started-github.mdx b/docs/user-guide/providers/github/getting-started-github.mdx index e946660b51..079f9a1d7b 100644 --- a/docs/user-guide/providers/github/getting-started-github.mdx +++ b/docs/user-guide/providers/github/getting-started-github.mdx @@ -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 392518d85c..049b4fb4ae 100644 --- a/docs/user-guide/providers/googleworkspace/authentication.mdx +++ b/docs/user-guide/providers/googleworkspace/authentication.mdx @@ -18,7 +18,7 @@ Prowler requests the following read-only OAuth 2.0 scopes: | `https://www.googleapis.com/auth/admin.directory.domain.readonly` | Read access to domain information | | `https://www.googleapis.com/auth/admin.directory.customer.readonly` | Read access to customer information (Customer ID) | | `https://www.googleapis.com/auth/admin.directory.orgunit.readonly` | Read access to organizational unit hierarchy (identifies the root OU for policy filtering) | -| `https://www.googleapis.com/auth/cloud-identity.policies.readonly` | Read access to domain-level application policies (required for Calendar service checks) | +| `https://www.googleapis.com/auth/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 | @@ -40,7 +40,7 @@ In the [Google Cloud Console](https://console.cloud.google.com), select the targ | API | Required For | |-----|--------------| | **Admin SDK API** | Directory service checks (users, roles, domains) | -| **Cloud Identity API** | Calendar service checks (domain-level sharing and invitation policies) | +| **Cloud Identity API** | All service checks except Directory (domain-level application policies) | For each API: @@ -49,7 +49,7 @@ For each API: 3. Click **Enable** -Both APIs must be enabled in the same GCP project that hosts the Service Account. Calendar checks will return no findings if the Cloud Identity API is not enabled. +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 @@ -176,9 +176,9 @@ If Prowler connects but returns empty results or permission errors for specific - Verify all scopes are authorized in the Admin Console - Ensure the delegated user is an active super administrator -### Calendar Checks Return No Findings +### Policy API Checks Return No Findings -If the Directory checks run successfully but the Calendar checks (e.g., `calendar_external_sharing_primary_calendar`) return no findings, the Cloud Identity Policy API is not reachable for this Service Account. Verify: +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) 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/oci/getting-started-oci.mdx b/docs/user-guide/providers/oci/getting-started-oci.mdx index a5cb71b0c3..fc9fb620bf 100644 --- a/docs/user-guide/providers/oci/getting-started-oci.mdx +++ b/docs/user-guide/providers/oci/getting-started-oci.mdx @@ -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): 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/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/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-app-alerts.mdx b/docs/user-guide/tutorials/prowler-alerts.mdx similarity index 97% rename from docs/user-guide/tutorials/prowler-app-alerts.mdx rename to docs/user-guide/tutorials/prowler-alerts.mdx index f607844f97..3fa49f84ff 100644 --- a/docs/user-guide/tutorials/prowler-app-alerts.mdx +++ b/docs/user-guide/tutorials/prowler-alerts.mdx @@ -10,7 +10,7 @@ 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** with a paid subscription. +This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [paid subscription](https://prowler.com/pricing). ## Prerequisites diff --git a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx index ad0b1fcce1..23d8b8d5a2 100644 --- a/docs/user-guide/tutorials/prowler-app-attack-paths.mdx +++ b/docs/user-guide/tutorials/prowler-app-attack-paths.mdx @@ -173,6 +173,215 @@ RETURN r.name AS role_name, r.arn AS role_arn, p.arn AS trusted_service LIMIT 25 ``` +### Advanced Attack Path Scenarios + +The following scenarios show how to compose graph traversals into real attack-path stories. Each query can be pasted directly into the custom query box: the API auto-scopes them to the selected provider and injects tenant/provider isolation, so there is no need to include account identifiers or `$provider_uid` in the text. All queries are openCypher v9 (Neo4j and Neptune compatible). + +#### 1. Live attacker on the box that owns the keys + +**Query story:** Finds an internet-exposed EC2 under an active GuardDuty SSH brute-force whose instance role can assume a higher-privileged role that can read a sensitive S3 bucket. + +```cypher +MATCH path_ec2 = (acct:AWSAccount)--(ec2:EC2Instance) +WHERE ec2.exposed_internet = true +MATCH p0 = (gd:GuardDutyFinding)-[:AFFECTS]->(ec2) +MATCH p1 = (ec2)-[:INSTANCE_PROFILE]->(prof:AWSInstanceProfile)-[:ASSOCIATED_WITH]->(low:AWSRole) +MATCH p2 = (low)-[:STS_ASSUMEROLE_ALLOW]-(high:AWSRole) +MATCH p3 = (high)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) +OPTIONAL MATCH path_net = (internet:Internet)-[:CAN_ACCESS]->(ec2) +MATCH path_s3 = (acct)--(s3:S3Bucket) +WHERE high <> low + AND stmt.effect = 'Allow' + AND size([a IN stmt.action WHERE + toLower(a) STARTS WITH 's3:getobject' + OR toLower(a) STARTS WITH 's3:listbucket' + OR toLower(a) IN ['s3:*'] + ]) > 0 + AND size([r IN stmt.resource WHERE + r CONTAINS s3.name + ]) > 0 +RETURN path_net, path_ec2, p0, p1, p2, p3, path_s3 +``` + +**How it's built:** + +- `path_ec2` anchors the graph on the account node and its internet-exposed EC2 instance, via a real account-to-resource edge. This is the visible spine that keeps everything connected. +- `p0` ties a `GuardDutyFinding` to that instance through the `AFFECTS` edge (the live SSH brute-force alert). +- `p1` walks the real graph edges from the instance to its instance profile to the role it runs as. +- `p2` follows the `STS_ASSUMEROLE_ALLOW` edge to the higher-privileged role the low role can assume. It is undirected so it works regardless of how the assume edge was ingested. `high <> low` stops a role matching itself. +- `p3` walks that role into its policy and policy statement. +- `path_net` is the optional `Internet -[:CAN_ACCESS]-> instance` edge. It makes "from the internet" literal on screen. Optional so a missing `Internet` node never breaks the query live. +- `path_s3` connects the sensitive bucket to the same account node, so it draws connected instead of floating. There is no physical edge from a role to a bucket; the grant is logical, enforced in the `WHERE`: the statement must allow an S3 read action (list comprehension over the `action` array) and its resource must cover the bucket (`CONTAINS s3.name`). The account is the shared hub; the bucket hanging off it next to the role chain is the teaching moment — the access exists only in IAM. + +#### 2. Who can read the crown jewels + +**Query story:** The sensitive bucket from the previous scenario seen from the data side: every role whose IAM policy can read it, regardless of how the role is reached. + +```cypher +MATCH (s3:S3Bucket) +WHERE toLower(s3.name) CONTAINS 'sensitive' +MATCH (role:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) +WHERE stmt.effect = 'Allow' + AND size([a IN stmt.action WHERE + toLower(a) STARTS WITH 's3:get' + OR toLower(a) STARTS WITH 's3:list' + OR toLower(a) IN ['s3:*'] + ]) > 0 + AND size([r IN stmt.resource WHERE + r CONTAINS s3.name + ]) > 0 +WITH DISTINCT s3, role +LIMIT 25 +MATCH path_s3 = (acct:AWSAccount)--(s3) +MATCH path_role = (acct)--(role) +RETURN path_s3, path_role +``` + +**How it's built:** data-centric, not attacker-centric — the same bucket the previous kill chain exfiltrates, approached from the other direction. + +- The `S3Bucket` is bound first by name (one node), so everything else filters against it. +- `(role:AWSRole)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement)` reaches statements only *through a role*, never via a global statement scan. A blanket `AWSPolicyStatement` scan also hits resource-policy statements whose shape differs and makes the list comprehension fail outright. +- The `WHERE` filters in place: an S3 read action plus a resource that names that bucket. +- `WITH DISTINCT s3, role LIMIT 25` collapses undirected-traversal duplicates and hard-caps the result. +- `path_s3` and `path_role` attach the account hubs only after the cap, against at most 25 rows, so the bucket and role(s) draw connected through the account instead of floating. +- No internet or EC2 here; this answers "who has the keys" instead of "how would an attacker get in." + +#### 3. Lateral reach from an internet-exposed instance + +**Query story:** The wide-angle view of the live-attacker scenario: every internet-exposed EC2, the role it runs as, and every role that role can assume. The first scenario is one specific exfiltration path inside this reach, under live attack. + +```cypher +MATCH path_ec2 = (acct:AWSAccount)--(ec2:EC2Instance) +WHERE ec2.exposed_internet = true +MATCH p1 = (ec2)-[:INSTANCE_PROFILE]->(prof:AWSInstanceProfile)-[:ASSOCIATED_WITH]->(low:AWSRole) +MATCH p2 = (low)-[:STS_ASSUMEROLE_ALLOW]-(high:AWSRole) +OPTIONAL MATCH path_net = (internet:Internet)-[:CAN_ACCESS]->(ec2) +WHERE high <> low +RETURN path_net, path_ec2, p1, p2 +``` + +**How it's built:** widens the lens instead of filtering down. It stops at the assume-role hop and shows every role reachable from any internet-exposed instance, without filtering down to a specific S3 leg. + +- `path_ec2` is the account-to-instance spine. +- `p1` walks to the instance role. +- `p2` fans out to every role that role can assume. +- `path_net` adds the optional `Internet -[:CAN_ACCESS]->` edge. +- The first scenario is the specific exfiltration path under live attack; this is the broader privilege reach an attacker inherits the moment they land on the box. + +#### 4. Role-chain privilege escalation + +**Query story:** A pure-IAM escalation, no compromised instance: a role that can assume a second role whose policy lets it assume a third, admin-level role. + +```cypher +MATCH path_root = (acct:AWSAccount)--(r1:AWSRole) +MATCH p1 = (r1)-[:STS_ASSUMEROLE_ALLOW]-(r2:AWSRole) +MATCH p2 = (r2)--(pol:AWSPolicy)--(stmt:AWSPolicyStatement) +MATCH path_admin = (acct)--(admin:AWSRole) +WHERE r1 <> r2 AND r1 <> admin AND r2 <> admin + AND stmt.effect = 'Allow' + AND size([a IN stmt.action WHERE + toLower(a) IN ['sts:*', 'sts:assumerole'] + ]) > 0 + AND size([res IN stmt.resource WHERE + res CONTAINS admin.name + ]) > 0 +RETURN path_root, p1, p2, path_admin +``` + +**How it's built:** + +- `path_root` anchors role 1 to the account node, the spine that keeps the picture connected. +- `p1` is the one real assume edge in the chain (role 1 to role 2). +- `p2` walks role 2 into its policy and statement. +- `path_admin` connects the target admin role to the same account node so it draws connected. The third hop is not a graph edge: it exists only as `sts:AssumeRole` on that role's ARN inside the statement. The query proves it the same way the first scenario proves S3 access — the statement action must include an assume-role action and its resource list must reference the admin role's name. +- The three `<>` guards stop a role matching itself at any position. + +#### 5. External identity trust map + +**Query story:** Finds external identity providers (SSO, GitHub, GitLab, Terraform Cloud) and the AWS roles they are trusted to assume. + +```cypher +MATCH p = (role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]-(idp:AWSPrincipal) +WHERE idp.arn CONTAINS 'saml-provider' + OR idp.arn CONTAINS 'oidc-provider' +MATCH path_role = (acct:AWSAccount)--(role) +RETURN p, path_role +``` + +**How it's built:** federated principals are stored as `AWSPrincipal` nodes whose ARN contains `saml-provider` (SSO) or `oidc-provider` (GitHub, GitLab, Terraform Cloud). + +- `p` matches the trust edge undirected. It is written `(AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(AWSPrincipal)`, role to principal, so a directed `principal -> role` match returns nothing; undirected matches regardless of ingest direction. +- The `WHERE` keeps only SAML or OIDC providers, drawing a fan-out from each external identity provider to every role it can assume (including reserved SSO admin roles). +- `path_role` ties every trusted role to the account node so the provider stars share one spine instead of drawing as separate islands. + +#### 6. Federated SSO roles flagged as admin or privesc + +**Query story:** The dangerous subset of the trust map above — externally-federated SSO roles that Prowler also flags for AdministratorAccess or privilege escalation. + +```cypher +MATCH (idp:AWSPrincipal)-[:TRUSTS_AWS_PRINCIPAL]-(role:AWSRole) +WHERE idp.arn CONTAINS 'saml-provider' + OR idp.arn CONTAINS 'oidc-provider' +MATCH (role)-[:HAS_FINDING]-(pf:ProwlerFinding) +WHERE pf.status = 'FAIL' + AND pf.check_id IN [ + 'iam_inline_policy_allows_privilege_escalation', + 'iam_role_administratoraccess_policy', + 'iam_inline_policy_no_administrative_privileges', + 'iam_user_administrator_access_policy' + ] +WITH DISTINCT idp, role, pf +LIMIT 60 +MATCH path_root = (acct:AWSAccount)--(role) +MATCH p_trust = (idp)-[:TRUSTS_AWS_PRINCIPAL]-(role) +MATCH p_find = (role)-[:HAS_FINDING]-(pf) +RETURN path_root, p_trust, p_find +``` + +**How it's built:** a plain "list every flagged identity" query is a wide fan that draws as a column, and `ProwlerFinding` nodes accumulate across scans with no scan filter available in custom queries. + +- The first MATCH plus `WHERE` keeps only roles trusted by a SAML or OIDC provider (trust edge undirected, so direction does not matter). +- The second MATCH plus `check_id IN [...]` keeps only those carrying one of the four privilege-escalation or admin checks. +- `WITH DISTINCT ... LIMIT 60` collapses duplicate finding nodes and hard-caps the result. +- `p_trust`, `p_find`, and `path_root` draw it connected three ways: provider to role through the trust edge, role to its finding, and role to the account. +- The previous scenario shows who can walk in; this shows which of those roles Prowler already flags as over-privileged. + +#### 7. World-readable S3 buckets + +**Query story:** Unlike the IAM-gated sensitive bucket in scenarios 1 and 2, these buckets are open to anyone on the internet with no credentials at all. + +```cypher +MATCH path_s3 = (acct:AWSAccount)--(s3:S3Bucket) +WHERE s3.anonymous_access = true +OPTIONAL MATCH p = (s3)--(stmt:S3PolicyStatement) +RETURN path_s3, p +``` + +**How it's built:** the counterpoint to scenarios 1 and 2 — there the sensitive bucket is reachable only through an IAM role chain; here the bucket needs no role at all. + +- `path_s3` connects each public bucket to its account node so they draw connected. Cartography sets `anonymous_access = true` when a bucket's policy or ACL allows public access. +- `p` is an optional match that pulls in the `S3PolicyStatement` granting the access where one exists, so the public grant is visible next to the bucket. Buckets that are public via ACL only still show, connected to the account. + +#### 8. Internet exposure surface + +**Query story:** The raw external attack surface behind scenarios 1 and 3: every internet-exposed EC2 instance with its security groups and the exact inbound ports left open. + +```cypher +MATCH path_ec2 = (acct:AWSAccount)--(ec2:EC2Instance) +WHERE ec2.exposed_internet = true +MATCH p1 = (ec2)--(sg:EC2SecurityGroup)--(rule:IpPermissionInbound) +OPTIONAL MATCH path_net = (internet:Internet)-[:CAN_ACCESS]->(ec2) +OPTIONAL MATCH p2 = (ec2)-[:INSTANCE_PROFILE]->(:AWSInstanceProfile)-[:ASSOCIATED_WITH]->(:AWSRole) +RETURN path_net, path_ec2, p1, p2 +``` + +**How it's built:** `exposed_internet = true` is Cartography's computed reachability flag. + +- `path_ec2` hubs all exposed instances on the account node so they draw as one picture. +- `p1` joins each instance to its security groups and inbound rules so the open ports are on screen. +- `path_net` adds the optional `Internet -[:CAN_ACCESS]->` edge so the external reachability is explicit. +- `p2` optionally adds the instance role, which connects this surface view back to the kill chains in scenarios 1 and 3. + ### Tips for Writing Queries - Start small with `LIMIT` to inspect the shape of the data before broadening the pattern. diff --git a/docs/user-guide/tutorials/prowler-app-github-action.mdx b/docs/user-guide/tutorials/prowler-app-github-action.mdx index 5b80511b89..c874a37164 100644 --- a/docs/user-guide/tutorials/prowler-app-github-action.mdx +++ b/docs/user-guide/tutorials/prowler-app-github-action.mdx @@ -18,7 +18,7 @@ Source: [`prowler-cloud/prowler`](https://github.com/prowler-cloud/prowler) · M | `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-app-import-findings). When `true`, `PROWLER_CLOUD_API_KEY` is auto-forwarded | +| `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 | @@ -43,7 +43,7 @@ Source: [`prowler-cloud/prowler`](https://github.com/prowler-cloud/prowler) · M ### Push findings to Prowler Cloud -Send scan results directly to [Prowler Cloud](/user-guide/tutorials/prowler-app-import-findings) for centralized visibility, compliance tracking, and team collaboration. +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 diff --git a/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx b/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx index 26f73000b3..b353bc85c5 100644 --- a/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx +++ b/docs/user-guide/tutorials/prowler-app-multi-tenant.mdx @@ -67,7 +67,14 @@ The currently active organization is indicated by an **Active** badge. Switching ## Editing an Organization Name -Organization owners with the **Manage Account** permission can rename an organization: +Renaming an organization requires **both** of the following conditions to be met: + +* The user's **membership role** in that organization must be `owner` (visible as the `owner` badge in the Organizations card). +* The user must have a role that grants the **Manage Account** permission. + +Users who only meet one of the two conditions will not see the **Edit** button. For example, a user whose membership role is `member` will not see the **Edit** button even if their role grants `Manage Account`. + +To rename an organization: 1. Navigate to the **Profile** page. @@ -175,6 +182,6 @@ If the expelled organization was the user's **only** organization, the account i | View organizations | Any authenticated user | | Create an organization | Any authenticated user | | Switch organizations | Any authenticated user | -| Edit organization name | Organization owner with **Manage Account** permission | -| Delete an organization | Organization owner with **Manage Account** permission; must belong to more than one organization | +| Edit organization name | Membership role `owner` **and** a role with **Manage Account** permission | +| Delete an organization | Membership role `owner` **and** a role with **Manage Account** permission; must belong to more than one organization | | Expel a user from an organization | Organization owner (no additional permission required); last remaining owner cannot expel themselves | diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx index cccbbc27cc..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 @@ -239,7 +243,7 @@ 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. diff --git a/docs/user-guide/tutorials/prowler-app-import-findings.mdx b/docs/user-guide/tutorials/prowler-import-findings.mdx similarity index 99% rename from docs/user-guide/tutorials/prowler-app-import-findings.mdx rename to docs/user-guide/tutorials/prowler-import-findings.mdx index 9f1002c2a9..d4c8f8a74d 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 [paid subscription](https://prowler.com/pricing). ## OCSF Detection Finding format 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 b0a1d70565..8f1438a3bb 100644 --- a/mcp_server/CHANGELOG.md +++ b/mcp_server/CHANGELOG.md @@ -2,7 +2,27 @@ All notable changes to the **Prowler MCP Server** are documented in this file. -## [0.7.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 @@ -32,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/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/finding_groups.py b/mcp_server/prowler_mcp_server/prowler_app/models/finding_groups.py new file mode 100644 index 0000000000..9ff9822977 --- /dev/null +++ b/mcp_server/prowler_mcp_server/prowler_app/models/finding_groups.py @@ -0,0 +1,300 @@ +"""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/tools/finding_groups.py b/mcp_server/prowler_mcp_server/prowler_app/tools/finding_groups.py new file mode 100644 index 0000000000..363e45d970 --- /dev/null +++ b/mcp_server/prowler_mcp_server/prowler_app/tools/finding_groups.py @@ -0,0 +1,473 @@ +"""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/utils/auth.py b/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py index 32ab72e574..48535fb10a 100644 --- a/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py +++ b/mcp_server/prowler_mcp_server/prowler_app/utils/auth.py @@ -5,6 +5,7 @@ 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 @@ -68,7 +69,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: diff --git a/mcp_server/prowler_mcp_server/server.py b/mcp_server/prowler_mcp_server/server.py index 12060366ad..20b7966b45 100644 --- a/mcp_server/prowler_mcp_server/server.py +++ b/mcp_server/prowler_mcp_server/server.py @@ -1,5 +1,3 @@ -import asyncio - from fastmcp import FastMCP from prowler_mcp_server import __version__ from prowler_mcp_server.lib.logger import logger @@ -8,55 +6,58 @@ 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 2a6885fedb..154b835281 100644 --- a/mcp_server/pyproject.toml +++ b/mcp_server/pyproject.toml @@ -2,9 +2,16 @@ build-backend = "setuptools.build_meta" requires = ["setuptools>=61.0", "wheel"] +[dependency-groups] +dev = [ + "bandit==1.8.3", + "pytest==9.0.3", + "vulture==2.14" +] + [project] dependencies = [ - "fastmcp==2.14.0", + "fastmcp==3.2.4", "httpx==0.28.1" ] description = "MCP server for Prowler ecosystem" @@ -16,5 +23,8 @@ version = "0.5.0" [project.scripts] prowler-mcp = "prowler_mcp_server.main:main" +[tool.pytest.ini_options] +testpaths = ["tests"] + [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 b657ad6e92..fd0a9349f9 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.9" +version = "1.7.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, + { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } +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/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, + { 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,60 +201,60 @@ wheels = [ [[package]] name = "cryptography" -version = "47.0.0" +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/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } +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/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, - { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, - { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, - { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, - { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, - { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, - { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, - { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, - { url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, - { url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, - { url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, - { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, - { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, - { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, - { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, - { url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, - { url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, - { url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, - { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, - { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, - { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, - { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, - { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, - { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, - { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, - { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, - { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, - { url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, - { url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, + { 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" }, @@ -265,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]] @@ -290,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]] @@ -321,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]] @@ -415,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]] @@ -443,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" @@ -457,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]] @@ -485,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" }, @@ -495,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]] @@ -544,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" }, @@ -628,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]] @@ -644,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]] @@ -665,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]] @@ -721,29 +683,43 @@ dependencies = [ { name = "httpx" }, ] +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "pytest" }, + { name = "vulture" }, +] + [package.metadata] requires-dist = [ - { name = "fastmcp", specifier = "==2.14.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 = "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" }, @@ -751,35 +727,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" }, @@ -787,9 +747,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] @@ -799,79 +759,91 @@ 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]] @@ -899,38 +871,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]] @@ -960,173 +939,169 @@ 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.33.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, + { 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]] @@ -1142,71 +1117,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.0.0" 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/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } 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/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] [[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]] @@ -1220,74 +1163,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/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 5431a16975..0000000000 --- a/poetry.lock +++ /dev/null @@ -1,6738 +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.5" -description = "Async http client/server framework (asyncio)" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "aiohttp-3.13.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:02222e7e233295f40e011c1b00e3b0bd451f22cf853a0304c3595633ee47da4b"}, - {file = "aiohttp-3.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bace460460ed20614fa6bc8cb09966c0b8517b8c58ad8046828c6078d25333b5"}, - {file = "aiohttp-3.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f546a4dc1e6a5edbb9fd1fd6ad18134550e096a5a43f4ad74acfbd834fc6670"}, - {file = "aiohttp-3.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86969d012e51b8e415a8c6ce96f7857d6a87d6207303ab02d5d11ef0cad2274"}, - {file = "aiohttp-3.13.5-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b6f6cd1560c5fa427e3b6074bb24d2c64e225afbb7165008903bd42e4e33e28a"}, - {file = "aiohttp-3.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:636bc362f0c5bbc7372bc3ae49737f9e3030dbce469f0f422c8f38079780363d"}, - {file = "aiohttp-3.13.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6a7cbeb06d1070f1d14895eeeed4dac5913b22d7b456f2eb969f11f4b3993796"}, - {file = "aiohttp-3.13.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca9ef7517fd7874a1a08970ae88f497bf5c984610caa0bf40bd7e8450852b95"}, - {file = "aiohttp-3.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:019a67772e034a0e6b9b17c13d0a8fe56ad9fb150fc724b7f3ffd3724288d9e5"}, - {file = "aiohttp-3.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f34ecee82858e41dd217734f0c41a532bd066bcaab636ad830f03a30b2a96f2a"}, - {file = "aiohttp-3.13.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4eac02d9af4813ee289cd63a361576da36dba57f5a1ab36377bc2600db0cbb73"}, - {file = "aiohttp-3.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4beac52e9fe46d6abf98b0176a88154b742e878fdf209d2248e99fcdf73cd297"}, - {file = "aiohttp-3.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c180f480207a9b2475f2b8d8bd7204e47aec952d084b2a2be58a782ffcf96074"}, - {file = "aiohttp-3.13.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2837fb92951564d6339cedae4a7231692aa9f73cbc4fb2e04263b96844e03b4e"}, - {file = "aiohttp-3.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9010032a0b9710f58012a1e9c222528763d860ba2ee1422c03473eab47703e7"}, - {file = "aiohttp-3.13.5-cp310-cp310-win32.whl", hash = "sha256:7c4b6668b2b2b9027f209ddf647f2a4407784b5d88b8be4efcc72036f365baf9"}, - {file = "aiohttp-3.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:cd3db5927bf9167d5a6157ddb2f036f6b6b0ad001ac82355d43e97a4bde76d76"}, - {file = "aiohttp-3.13.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ab7229b6f9b5c1ba4910d6c41a9eb11f543eadb3f384df1b4c293f4e73d44d6"}, - {file = "aiohttp-3.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8f14c50708bb156b3a3ca7230b3d820199d56a48e3af76fa21c2d6087190fe3d"}, - {file = "aiohttp-3.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7d2f8616f0ff60bd332022279011776c3ac0faa0f1b463f7bb12326fbc97a1c"}, - {file = "aiohttp-3.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2567b72e1ffc3ab25510db43f355b29eeada56c0a622e58dcdb19530eb0a3cb"}, - {file = "aiohttp-3.13.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fb0540c854ac9c0c5ad495908fdfd3e332d553ec731698c0e29b1877ba0d2ec6"}, - {file = "aiohttp-3.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9883051c6972f58bfc4ebb2116345ee2aa151178e99c3f2b2bbe2af712abd13"}, - {file = "aiohttp-3.13.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2294172ce08a82fb7c7273485895de1fa1186cc8294cfeb6aef4af42ad261174"}, - {file = "aiohttp-3.13.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a807cabd5115fb55af198b98178997a5e0e57dead43eb74a93d9c07d6d4a7dc"}, - {file = "aiohttp-3.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6d0d932e0f39c02b80744273cd5c388a2d9bc07760a03164f229c8e02662f6"}, - {file = "aiohttp-3.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:60869c7ac4aaabe7110f26499f3e6e5696eae98144735b12a9c3d9eae2b51a49"}, - {file = "aiohttp-3.13.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26d2f8546f1dfa75efa50c3488215a903c0168d253b75fba4210f57ab77a0fb8"}, - {file = "aiohttp-3.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1162a1492032c82f14271e831c8f4b49f2b6078f4f5fc74de2c912fa225d51d"}, - {file = "aiohttp-3.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8b14eb3262fad0dc2f89c1a43b13727e709504972186ff6a99a3ecaa77102b6c"}, - {file = "aiohttp-3.13.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ca9ac61ac6db4eb6c2a0cd1d0f7e1357647b638ccc92f7e9d8d133e71ed3c6ac"}, - {file = "aiohttp-3.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7996023b2ed59489ae4762256c8516df9820f751cf2c5da8ed2fb20ee50abab3"}, - {file = "aiohttp-3.13.5-cp311-cp311-win32.whl", hash = "sha256:77dfa48c9f8013271011e51c00f8ada19851f013cde2c48fca1ba5e0caf5bb06"}, - {file = "aiohttp-3.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:d3a4834f221061624b8887090637db9ad4f61752001eae37d56c52fddade2dc8"}, - {file = "aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9"}, - {file = "aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416"}, - {file = "aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2"}, - {file = "aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4"}, - {file = "aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9"}, - {file = "aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5"}, - {file = "aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e"}, - {file = "aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1"}, - {file = "aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286"}, - {file = "aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9"}, - {file = "aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88"}, - {file = "aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3"}, - {file = "aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b"}, - {file = "aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe"}, - {file = "aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14"}, - {file = "aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3"}, - {file = "aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1"}, - {file = "aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61"}, - {file = "aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832"}, - {file = "aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9"}, - {file = "aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090"}, - {file = "aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b"}, - {file = "aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a"}, - {file = "aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8"}, - {file = "aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665"}, - {file = "aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540"}, - {file = "aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb"}, - {file = "aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46"}, - {file = "aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8"}, - {file = "aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d"}, - {file = "aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6"}, - {file = "aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c"}, - {file = "aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc"}, - {file = "aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83"}, - {file = "aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c"}, - {file = "aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be"}, - {file = "aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25"}, - {file = "aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56"}, - {file = "aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2"}, - {file = "aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a"}, - {file = "aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be"}, - {file = "aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b"}, - {file = "aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94"}, - {file = "aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d"}, - {file = "aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7"}, - {file = "aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772"}, - {file = "aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5"}, - {file = "aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1"}, - {file = "aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b"}, - {file = "aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3"}, - {file = "aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162"}, - {file = "aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a"}, - {file = "aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254"}, - {file = "aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36"}, - {file = "aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f"}, - {file = "aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800"}, - {file = "aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf"}, - {file = "aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b"}, - {file = "aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a"}, - {file = "aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8"}, - {file = "aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be"}, - {file = "aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b"}, - {file = "aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6"}, - {file = "aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037"}, - {file = "aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500"}, - {file = "aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9"}, - {file = "aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8"}, - {file = "aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9"}, - {file = "aiohttp-3.13.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:347542f0ea3f95b2a955ee6656461fa1c776e401ac50ebce055a6c38454a0adf"}, - {file = "aiohttp-3.13.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:178c7b5e62b454c2bc790786e6058c3cc968613b4419251b478c153a4aec32b1"}, - {file = "aiohttp-3.13.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af545c2cffdb0967a96b6249e6f5f7b0d92cdfd267f9d5238d5b9ca63e8edb10"}, - {file = "aiohttp-3.13.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:206b7b3ef96e4ce211754f0cd003feb28b7d81f0ad26b8d077a5d5161436067f"}, - {file = "aiohttp-3.13.5-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ee5e86776273de1795947d17bddd6bb19e0365fd2af4289c0d2c5454b6b1d36b"}, - {file = "aiohttp-3.13.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95d14ca7abefde230f7639ec136ade282655431fd5db03c343b19dda72dd1643"}, - {file = "aiohttp-3.13.5-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:912d4b6af530ddb1338a66229dac3a25ff11d4448be3ec3d6340583995f56031"}, - {file = "aiohttp-3.13.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e999f0c88a458c836d5fb521814e92ed2172c649200336a6df514987c1488258"}, - {file = "aiohttp-3.13.5-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:39380e12bd1f2fdab4285b6e055ad48efbaed5c836433b142ed4f5b9be71036a"}, - {file = "aiohttp-3.13.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9efcc0f11d850cefcafdd9275b9576ad3bfb539bed96807663b32ad99c4d4b88"}, - {file = "aiohttp-3.13.5-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:147b4f501d0292077f29d5268c16bb7c864a1f054d7001c4c1812c0421ea1ed0"}, - {file = "aiohttp-3.13.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d147004fede1b12f6013a6dbb2a26a986a671a03c6ea740ddc76500e5f1c399f"}, - {file = "aiohttp-3.13.5-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:9277145d36a01653863899c665243871434694bcc3431922c3b35c978061bdb8"}, - {file = "aiohttp-3.13.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4e704c52438f66fdd89588346183d898bb42167cf88f8b7ff1c0f9fc957c348f"}, - {file = "aiohttp-3.13.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8a4d3427e8de1312ddf309cc482186466c79895b3a139fed3259fc01dfa9a5b"}, - {file = "aiohttp-3.13.5-cp39-cp39-win32.whl", hash = "sha256:6f497a6876aa4b1a102b04996ce4c1170c7040d83faa9387dd921c16e30d5c83"}, - {file = "aiohttp-3.13.5-cp39-cp39-win_amd64.whl", hash = "sha256:cb979826071c0986a5f08333a36104153478ce6018c58cba7f9caddaf63d5d67"}, - {file = "aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1"}, -] - -[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.4" -description = "Alibaba Cloud openapi SDK Library for Python" -optional = false -python-versions = ">=3.7" -groups = ["main"] -files = [ - {file = "alibabacloud_tea_openapi-0.4.4-py3-none-any.whl", hash = "sha256:cea6bc1fe35b0319a8752cb99eb0ecb0dab7ca1a71b99c12970ba0867410995f"}, - {file = "alibabacloud_tea_openapi-0.4.4.tar.gz", hash = "sha256:1b0917bc03cd49417da64945e92731716d53e2eb8707b235f54e45b7473221ce"}, -] - -[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 = {version = ">=3.0.0,<47.0.0", markers = "python_version >= \"3.8\""} -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.9" -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.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3"}, - {file = "authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04"}, -] - -[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 = "24.0.0" -description = "Microsoft Azure Resource Management Client Library for Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "azure_mgmt_resource-24.0.0-py3-none-any.whl", hash = "sha256:27b32cd223e2784269f5a0db3c282042886ee4072d79cedc638438ece7cd0df4"}, - {file = "azure_mgmt_resource-24.0.0.tar.gz", hash = "sha256:cf6b8995fcdd407ac9ff1dd474087129429a1d90dbb1ac77f97c19b96237b265"}, -] - -[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-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 = "backports-datetime-fromisoformat" -version = "2.0.3" -description = "Backport of Python 3.11's datetime.fromisoformat" -optional = false -python-versions = ">3" -groups = ["dev"] -markers = "python_version == \"3.10\"" -files = [ - {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e81b26497a17c29595bc7df20bc6a872ceea5f8c9d6537283945d4b6396aec10"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:5ba00ead8d9d82fd6123eb4891c566d30a293454e54e32ff7ead7644f5f7e575"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:24d574cb4072e1640b00864e94c4c89858033936ece3fc0e1c6f7179f120d0a8"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9735695a66aad654500b0193525e590c693ab3368478ce07b34b443a1ea5e824"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63d39709e17eb72685d052ac82acf0763e047f57c86af1b791505b1fec96915d"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:1ea2cc84224937d6b9b4c07f5cb7c667f2bde28c255645ba27f8a675a7af8234"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4024e6d35a9fdc1b3fd6ac7a673bd16cb176c7e0b952af6428b7129a70f72cce"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5e2dcc94dc9c9ab8704409d86fcb5236316e9dcef6feed8162287634e3568f4c"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fa2de871801d824c255fac7e5e7e50f2be6c9c376fd9268b40c54b5e9da91f42"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:1314d4923c1509aa9696712a7bc0c7160d3b7acf72adafbbe6c558d523f5d491"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:b750ecba3a8815ad8bc48311552f3f8ab99dd2326d29df7ff670d9c49321f48f"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d5117dce805d8a2f78baeddc8c6127281fa0a5e2c40c6dd992ba6b2b367876"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb35f607bd1cbe37b896379d5f5ed4dc298b536f4b959cb63180e05cacc0539d"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:61c74710900602637d2d145dda9720c94e303380803bf68811b2a151deec75c2"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ece59af54ebf67ecbfbbf3ca9066f5687879e36527ad69d8b6e3ac565d565a62"}, - {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:d0a7c5f875068efe106f62233bc712d50db4d07c13c7db570175c7857a7b5dbd"}, - {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039"}, - {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06"}, - {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7100adcda5e818b5a894ad0626e38118bb896a347f40ebed8981155675b9ba7b"}, - {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e410383f5d6a449a529d074e88af8bc80020bb42b402265f9c02c8358c11da5"}, - {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2797593760da6bcc32c4a13fa825af183cd4bfd333c60b3dbf84711afca26ef"}, - {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35a144fd681a0bea1013ccc4cd3fd4dc758ea17ee23dca019c02b82ec46fc0c4"}, - {file = "backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d"}, -] - -[[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 = "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 = "46.0.7" -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.8" -groups = ["main", "dev"] -files = [ - {file = "cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb"}, - {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b"}, - {file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85"}, - {file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e"}, - {file = "cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457"}, - {file = "cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b"}, - {file = "cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1"}, - {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2"}, - {file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e"}, - {file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee"}, - {file = "cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298"}, - {file = "cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb"}, - {file = "cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006"}, - {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0"}, - {file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85"}, - {file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e"}, - {file = "cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246"}, - {file = "cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968"}, - {file = "cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4"}, - {file = "cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5"}, -] - -[package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} -typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""} - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] -docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox[uv] (>=2024.4.15)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"] -sdist = ["build (>=1.0.0)"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi (>=2024)", "cryptography-vectors (==46.0.7)", "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 = "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 = "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 = "4.3.0" -description = "A lightweight library for converting complex datatypes to and from native Python datatypes." -optional = false -python-versions = ">=3.10" -groups = ["dev"] -files = [ - {file = "marshmallow-4.3.0-py3-none-any.whl", hash = "sha256:46c4fe6984707e3cbd485dfebbf0a59874f58d695aad05c1668d15e8c6e13b46"}, - {file = "marshmallow-4.3.0.tar.gz", hash = "sha256:fb43c53b3fe240b8f6af37223d6ef1636f927ad9bea8ab323afad95dff090880"}, -] - -[package.dependencies] -backports-datetime-fromisoformat = {version = "*", markers = "python_version < \"3.11\""} -typing-extensions = {version = "*", markers = "python_version < \"3.11\""} - -[[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.55.0" -description = "The Microsoft Graph Python SDK" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "msgraph_sdk-1.55.0-py3-none-any.whl", hash = "sha256:c8e68ebc4b88af5111de312e7fa910a4e76ddf48a4534feadb1fb8a411c48cfc"}, - {file = "msgraph_sdk-1.55.0.tar.gz", hash = "sha256:6df691a31954a050d26b8a678968017e157d940fb377f2a8a4e17a9741b98756"}, -] - -[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 = "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.169.0" -description = "Oracle Cloud Infrastructure Python SDK" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "oci-2.169.0-py3-none-any.whl", hash = "sha256:c71bb5143f307791082b3e33cc1545c2490a518cfed85ab1948ef5107c36d30b"}, - {file = "oci-2.169.0.tar.gz", hash = "sha256:f3c5fff00b01783b5325ea7b13bf140053ec1e9f41da20bfb9c8a349ee7662fa"}, -] - -[package.dependencies] -certifi = "*" -circuitbreaker = {version = ">=1.3.1,<3.0.0", markers = "python_version >= \"3.7\""} -cryptography = ">=3.2.1,<47.0.0" -pyOpenSSL = ">=17.5.0,<27.0.0" -python-dateutil = ">=2.5.3,<3.0.0" -pytz = ">=2016.10" -urllib3 = {version = ">=2.6.3", markers = "python_version >= \"3.10.0\""} - -[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 = "prek" -version = "0.3.9" -description = "A Git hook manager written in Rust, designed as a drop-in alternative to pre-commit." -optional = false -python-versions = ">=3.8" -groups = ["dev"] -files = [ - {file = "prek-0.3.9-py3-none-linux_armv6l.whl", hash = "sha256:3ed793d51bfaa27bddb64d525d7acb77a7c8644f549412d82252e3eb0b88aad8"}, - {file = "prek-0.3.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:399c58400c0bd0b82a93a3c09dc1bfd88d8d0cfb242d414d2ed247187b06ead1"}, - {file = "prek-0.3.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ea1ffb124e92f081b8e2ca5b5a623a733efb3be0c5b1f4b7ffe2ee17d1f20c"}, - {file = "prek-0.3.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:aaf639f95b7301639298311d8d44aad0d0b4864e9736083ad3c71ce9765d37ab"}, - {file = "prek-0.3.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff104863b187fa443ea8451ca55d51e2c6e94f99f00d88784b5c3c4c623f1ebe"}, - {file = "prek-0.3.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:039ecaf87c63a3e67cca645ebd5bc5eb6aafa6c9d929e9a27b2921e7849d7ef9"}, - {file = "prek-0.3.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3bde2a3d045705095983c7f78ba04f72a7565fe1c2b4e85f5628502a254754ff"}, - {file = "prek-0.3.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28a0960a21543563e2c8e19aaad176cc8423a87aac3c914d0f313030d7a9244a"}, - {file = "prek-0.3.9-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0dfb5d5171d7523271909246ee306b4dc3d5b63752e7dd7c7e8a8908fc9490d1"}, - {file = "prek-0.3.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82b791bd36c1430c84d3ae7220a85152babc7eaf00f70adcb961bd594e756ba3"}, - {file = "prek-0.3.9-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:6eac6d2f736b041118f053a1487abed468a70dd85a8688eaf87bb42d3dcecf20"}, - {file = "prek-0.3.9-py3-none-musllinux_1_1_i686.whl", hash = "sha256:5517e46e761367a3759b3168eabc120840ffbca9dfbc53187167298a98f87dc4"}, - {file = "prek-0.3.9-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:92024778cf78683ca32687bb249ab6a7d5c33887b5ee1d1a9f6d0c14228f4cf3"}, - {file = "prek-0.3.9-py3-none-win32.whl", hash = "sha256:7f89c55e5f480f5d073769e319924ad69d4bf9f98c5cb46a83082e26e634c958"}, - {file = "prek-0.3.9-py3-none-win_amd64.whl", hash = "sha256:7722f3372eaa83b147e70a43cb7b9fe2128c13d0c78d8a1cdbf2a8ec2ee071eb"}, - {file = "prek-0.3.9-py3-none-win_arm64.whl", hash = "sha256:0bced6278d6cc8a4b46048979e36bc9da034611dc8facd77ab123177b833a929"}, - {file = "prek-0.3.9.tar.gz", hash = "sha256:f82b92d81f42f1f90a47f5fbbf492373e25ef1f790080215b2722dd6da66510e"}, -] - -[[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.3" -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.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"}, - {file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"}, -] - -[[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.20.0" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -files = [ - {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, - {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pyjwt" -version = "2.12.1" -description = "JSON Web Token implementation in Python" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"}, - {file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"}, -] - -[package.dependencies] -cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} -typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} - -[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.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 = "26.0.0" -description = "Python wrapper module around the OpenSSL library" -optional = false -python-versions = ">=3.8" -groups = ["main"] -files = [ - {file = "pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81"}, - {file = "pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc"}, -] - -[package.dependencies] -cryptography = ">=46.0.0,<47" -typing-extensions = {version = ">=4.9", markers = "python_version < \"3.13\" and python_version >= \"3.8\""} - -[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.33.1" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.10" -groups = ["main", "dev"] -files = [ - {file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"}, - {file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"}, -] - -[package.dependencies] -certifi = ">=2023.5.7" -charset_normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.26,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] - -[[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 = "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 = "d7e2ad41783a864bb845f63ccc10c88ae1e4ac36d61993ea106bbb4a5f58a843" diff --git a/prowler/AGENTS.md b/prowler/AGENTS.md index cccf95b135..ab3ba1ce67 100644 --- a/prowler/AGENTS.md +++ b/prowler/AGENTS.md @@ -7,7 +7,7 @@ > - [`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: @@ -44,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 @@ -85,13 +85,13 @@ class {check_name}(Check): ## TECH STACK -Python 3.10+ | Poetry 2.3+ | 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 @@ -112,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 ``` --- @@ -145,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 aa34d7e8c6..451b4aff2d 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,26 +2,154 @@ All notable changes to the **Prowler SDK** are documented in this file. -## [5.27.0] (Prowler UNRELEASED) +## [5.30.0] (Prowler UNRELEASED) ### 🚀 Added +- `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) + +### 🐞 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) + +--- + +## [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) + +### 🔐 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) flagged by osv-scanner [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499) + +--- + +## [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) - ---- - -## [5.26.2] (Prowler UNRELEASED) +- 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) --- diff --git a/prowler/__main__.py b/prowler/__main__.py index ebca7985c5..6cbaf575d9 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, @@ -85,11 +84,6 @@ from prowler.lib.outputs.compliance.compliance import ( display_compliance_table, process_universal_compliance_frameworks, ) -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.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 @@ -154,8 +148,11 @@ 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 @@ -207,9 +204,10 @@ def prowler(): # 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 - # Skip for IAC and LLM providers that don't use compliance frameworks - elif default_execution and provider not in ["iac", "llm"]: + # 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 @@ -247,7 +245,7 @@ def prowler(): 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( @@ -315,7 +313,7 @@ def prowler(): 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) @@ -414,6 +412,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 @@ -426,6 +428,28 @@ 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 + ) + 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: @@ -435,7 +459,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": @@ -445,12 +469,19 @@ def prowler(): findings = global_provider.run_scan(streaming_callback=streaming_callback) else: - # Original behavior for IAC and Image - 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: 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%. @@ -791,18 +822,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/" @@ -906,18 +925,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/" @@ -1021,18 +1028,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/" @@ -1267,18 +1262,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/" @@ -1307,18 +1290,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/" @@ -1343,6 +1314,30 @@ def prowler(): ) 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": 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/aws/aws_ai_security_framework_aws.json b/prowler/compliance/aws/aws_ai_security_framework_aws.json new file mode 100644 index 0000000000..c6a0a8504b --- /dev/null +++ b/prowler/compliance/aws/aws_ai_security_framework_aws.json @@ -0,0 +1,1160 @@ +{ + "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" + ] + }, + { + "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" + ] + }, + { + "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" + ] + }, + { + "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" + ] + }, + { + "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": [] + } + ] +} \ No newline at end of file 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..cea7ad1655 100644 --- a/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json +++ b/prowler/compliance/aws/aws_foundational_security_best_practices_aws.json @@ -1863,7 +1863,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", 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 98d87112c9..0000000000 --- a/prowler/compliance/aws/csa_ccm_4.0_aws.json +++ /dev/null @@ -1,7617 +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", - "bedrock_prompt_encrypted_with_cmk", - "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_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_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_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_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_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_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_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_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_bedrock_logging_enabled", - "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 8548e59a89..f6c574daef 100644 --- a/prowler/compliance/aws/ens_rd2022_aws.json +++ b/prowler/compliance/aws/ens_rd2022_aws.json @@ -2539,8 +2539,7 @@ } ], "Checks": [ - "vpc_subnet_separate_private_public", - "vpc_different_regions" + "vpc_subnet_separate_private_public" ] }, { @@ -2593,8 +2592,8 @@ } ], "Checks": [ - "vpc_subnet_different_az", - "vpc_different_regions" + "vpc_different_regions", + "vpc_subnet_different_az" ] }, { @@ -4262,6 +4261,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", diff --git a/prowler/compliance/azure/cis_2.1_azure.json b/prowler/compliance/azure/cis_2.1_azure.json index 805c81bbd8..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": [ { diff --git a/prowler/compliance/azure/cis_3.0_azure.json b/prowler/compliance/azure/cis_3.0_azure.json index 47344c19ee..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": [ { diff --git a/prowler/compliance/azure/cis_4.0_azure.json b/prowler/compliance/azure/cis_4.0_azure.json index ba15a661ef..c075ff4047 100644 --- a/prowler/compliance/azure/cis_4.0_azure.json +++ b/prowler/compliance/azure/cis_4.0_azure.json @@ -3021,7 +3021,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 49b18f9326..21785d92b6 100644 --- a/prowler/compliance/azure/cis_5.0_azure.json +++ b/prowler/compliance/azure/cis_5.0_azure.json @@ -3182,7 +3182,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/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/prowler_threatscore_azure.json b/prowler/compliance/azure/prowler_threatscore_azure.json index a65a7f60e8..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": [ { diff --git a/prowler/compliance/dora.json b/prowler/compliance/dora.json new file mode 100644 index 0000000000..2378a307c6 --- /dev/null +++ b/prowler/compliance/dora.json @@ -0,0 +1,597 @@ +{ + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + }, + { + "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" + ] + } + } + ] +} diff --git a/prowler/compliance/gcp/ccc_gcp.json b/prowler/compliance/gcp/ccc_gcp.json index df6b15bc5c..67a75841ef 100644 --- a/prowler/compliance/gcp/ccc_gcp.json +++ b/prowler/compliance/gcp/ccc_gcp.json @@ -889,7 +889,7 @@ } ], "Checks": [ - "kms_key_rotation_enabled" + "kms_key_rotation_max_90_days" ] }, { 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/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/googleworkspace/cis_1.3_googleworkspace.json b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json index 1e44d8a9a5..167842af4b 100644 --- a/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json +++ b/prowler/compliance/googleworkspace/cis_1.3_googleworkspace.json @@ -1084,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", @@ -1105,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", @@ -1126,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", @@ -1147,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", @@ -1168,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", @@ -1189,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", @@ -1210,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", @@ -1231,7 +1245,9 @@ { "Id": "3.1.6.2", "Description": "Ensure creating groups is restricted", - "Checks": [], + "Checks": [ + "groups_creation_restricted" + ], "Attributes": [ { "Section": "3 Apps", @@ -1252,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", @@ -1273,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", @@ -1294,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", @@ -1315,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", @@ -1336,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", @@ -1357,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", @@ -1378,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", @@ -1399,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", @@ -1420,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", @@ -1441,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", @@ -1462,7 +1498,9 @@ { "Id": "4.1.4.1", "Description": "Ensure login challenges are enforced", - "Checks": [], + "Checks": [ + "security_login_challenges_configured" + ], "Attributes": [ { "Section": "4 Security", @@ -1483,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", @@ -1504,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", @@ -1546,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", @@ -1609,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", @@ -1630,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", @@ -1672,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", @@ -1777,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", @@ -1798,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", @@ -1819,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", @@ -1840,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", @@ -1861,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", @@ -1882,7 +1942,9 @@ { "Id": "6.6", "Description": "Ensure Suspicious login is configured", - "Checks": [], + "Checks": [ + "rules_suspicious_login_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", @@ -1903,7 +1965,9 @@ { "Id": "6.7", "Description": "Ensure Leaked password is configured", - "Checks": [], + "Checks": [ + "rules_leaked_password_alert_configured" + ], "Attributes": [ { "Section": "6 Rules", @@ -1924,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 9d99ee577c..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", @@ -1466,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", @@ -1492,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", @@ -1622,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", @@ -1635,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", @@ -1648,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", @@ -1661,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", @@ -1674,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", @@ -1700,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/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 92deb103df..98f5290a80 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 @@ -48,7 +49,7 @@ class _MutableTimestamp: timestamp = _MutableTimestamp(datetime.today()) timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc)) -prowler_version = "5.27.0" +prowler_version = "5.30.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" @@ -75,20 +76,48 @@ class Provider(str, Enum): ALIBABACLOUD = "alibabacloud" OPENSTACK = "openstack" IMAGE = "image" + SCALEWAY = "scaleway" VERCEL = "vercel" + OKTA = "okta" + STACKIT = "stackit" # 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: path}.""" + dirs = {} + for ep in importlib.metadata.entry_points(group="prowler.compliance"): + try: + module = ep.load() + if hasattr(module, "__path__"): + dirs[ep.name] = module.__path__[0] + elif hasattr(module, "__file__"): + dirs[ep.name] = os.path.dirname(module.__file__) + 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 current_provider in providers: - compliance_dir = f"{actual_directory}/../compliance/{current_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: @@ -97,7 +126,8 @@ def get_available_compliance_frameworks(provider=None): available_compliance_frameworks.append( file.name.removesuffix(".json") ) - # Also scan top-level compliance/ for multi-provider (universal) JSONs. + # 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" @@ -114,6 +144,43 @@ def get_available_compliance_frameworks(provider=None): continue if name not in available_compliance_frameworks: available_compliance_frameworks.append(name) + # External per-provider compliance via entry points. + ep_dirs = _get_ep_compliance_dirs() + for prov, path in ep_dirs.items(): + if provider and prov != provider: + continue + if os.path.isdir(path): + 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 @@ -230,18 +297,26 @@ def load_and_validate_config_file(provider: str, config_file_path: str) -> dict: 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 validate_provider_config( provider=provider, diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index 6dcbaa2ba4..28f07c2051 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -467,6 +467,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 @@ -649,3 +661,17 @@ 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 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 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/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/check/check.py b/prowler/lib/check/check.py index fff9454213..300520f589 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 @@ -385,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 @@ -525,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() @@ -605,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() @@ -741,10 +785,22 @@ 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 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 diff --git a/prowler/lib/check/checks_loader.py b/prowler/lib/check/checks_loader.py index 9ef672df6b..77840084ff 100644 --- a/prowler/lib/check/checks_loader.py +++ b/prowler/lib/check/checks_loader.py @@ -2,10 +2,10 @@ import sys from colorama import Fore, Style -from prowler.config.config import EXTERNAL_TOOL_PROVIDERS from prowler.lib.check.check import parse_checks_from_file from prowler.lib.check.compliance_models import Compliance from prowler.lib.check.models import CheckMetadata, Severity +from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider from prowler.lib.logger import logger @@ -26,8 +26,13 @@ def load_checks_to_execute( ) -> set: """Generate the list of checks to execute based on the cloud provider and the input arguments given""" try: - # Bypass check loading for providers that use external tools directly - if provider in EXTERNAL_TOOL_PROVIDERS: + # 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 diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py index 82ac54e4d9..3610893008 100644 --- a/prowler/lib/check/compliance_models.py +++ b/prowler/lib/check/compliance_models.py @@ -1,3 +1,4 @@ +import importlib.metadata import json import os import sys @@ -434,26 +435,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}") @@ -462,18 +500,26 @@ 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) ───────────────────────── @@ -950,6 +996,25 @@ def get_bulk_compliance_frameworks_universal(provider: str) -> dict: 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/models.py b/prowler/lib/check/models.py index f5f0ff87e8..f9155c68f7 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( @@ -259,7 +259,7 @@ class CheckMetadata(BaseModel): ) 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))}." @@ -288,7 +288,9 @@ class CheckMetadata(BaseModel): 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( @@ -304,7 +306,9 @@ class CheckMetadata(BaseModel): 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" @@ -313,8 +317,9 @@ class CheckMetadata(BaseModel): return check_id @validator("CheckTitle", pre=True, always=True) + @classmethod def validate_check_title(cls, check_title, values): # noqa: F841 - if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + 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" @@ -326,14 +331,18 @@ class CheckMetadata(BaseModel): return check_title @validator("RelatedUrl", pre=True, always=True) + @classmethod def validate_related_url(cls, related_url, values): # noqa: F841 - if related_url and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + 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") + @classmethod def validate_recommendation_url(cls, remediation, values): # noqa: F841 - if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + 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( @@ -346,7 +355,7 @@ class CheckMetadata(BaseModel): 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}'." @@ -371,8 +380,9 @@ class CheckMetadata(BaseModel): return check_type @validator("Description", pre=True, always=True) + @classmethod def validate_description(cls, description, values): # noqa: F841 - if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + 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" @@ -380,8 +390,9 @@ class CheckMetadata(BaseModel): return description @validator("Risk", pre=True, always=True) + @classmethod def validate_risk(cls, risk, values): # noqa: F841 - if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS: + 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" @@ -433,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 @@ -470,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: @@ -495,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 = ( @@ -933,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.""" @@ -1195,6 +1255,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.""" @@ -1283,6 +1368,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 0e4807078f..9c9a9c0523 100644 --- a/prowler/lib/check/utils.py +++ b/prowler/lib/check/utils.py @@ -1,9 +1,43 @@ import importlib +import importlib.metadata +import importlib.util +import os import sys from pkgutil import walk_packages -from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS +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( @@ -15,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 providers that use external tools directly - if provider in EXTERNAL_TOOL_PROVIDERS: + # 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) @@ -64,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 providers that use external tools directly - if provider in EXTERNAL_TOOL_PROVIDERS: + # 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 24aa02083d..8f05fc30cb 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -20,19 +20,61 @@ from prowler.providers.common.arguments import ( validate_provider_arguments, validate_sarif_usage, ) +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", + } + 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,llm} ...", - epilog=""" + usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm{extra_providers_csv}}} ...", + epilog=f""" Available Cloud Providers: - {aws,azure,gcp,kubernetes,m365,github,googleworkspace,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,vercel} + {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel{extra_providers_csv}}} aws AWS Provider azure Azure Provider gcp GCP Provider @@ -40,22 +82,26 @@ 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 llm LLM Provider (Beta) image Container Image Provider nhn NHN Provider (Unofficial) mongodbatlas MongoDB Atlas Provider - vercel Vercel Provider + scaleway Scaleway Provider + vercel Vercel 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,8 +160,10 @@ 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 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 index 78ef53c5e6..c264c81505 100644 --- 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 @@ -37,9 +37,9 @@ class ASDEssentialEightAWS(ComplianceOutput): - None """ 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: for attribute in requirement.Attributes: compliance_row = ASDEssentialEightAWSModel( Provider=finding.provider, 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..4a157b0324 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 @@ -37,10 +37,9 @@ class AWSWellArchitected(ComplianceOutput): - 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: + # 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 = AWSWellArchitectedModel( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/c5/c5_aws.py b/prowler/lib/outputs/compliance/c5/c5_aws.py index 24d5239602..5f13b77d7a 100644 --- a/prowler/lib/outputs/compliance/c5/c5_aws.py +++ b/prowler/lib/outputs/compliance/c5/c5_aws.py @@ -35,10 +35,9 @@ class AWSC5(ComplianceOutput): - 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: + # 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 = AWSC5Model( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/c5/c5_azure.py b/prowler/lib/outputs/compliance/c5/c5_azure.py index 3c050a6581..1b3a9e6b90 100644 --- a/prowler/lib/outputs/compliance/c5/c5_azure.py +++ b/prowler/lib/outputs/compliance/c5/c5_azure.py @@ -35,10 +35,9 @@ class AzureC5(ComplianceOutput): - 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: + # 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 = AzureC5Model( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/c5/c5_gcp.py b/prowler/lib/outputs/compliance/c5/c5_gcp.py index 85ee9c25e9..84e66a141f 100644 --- a/prowler/lib/outputs/compliance/c5/c5_gcp.py +++ b/prowler/lib/outputs/compliance/c5/c5_gcp.py @@ -35,10 +35,9 @@ class GCPC5(ComplianceOutput): - 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: + # 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 = GCPC5Model( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/ccc/ccc_aws.py b/prowler/lib/outputs/compliance/ccc/ccc_aws.py index a9de0aeca7..1114425574 100644 --- a/prowler/lib/outputs/compliance/ccc/ccc_aws.py +++ b/prowler/lib/outputs/compliance/ccc/ccc_aws.py @@ -35,10 +35,9 @@ class CCC_AWS(ComplianceOutput): - 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: + # 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 = CCC_AWSModel( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/ccc/ccc_azure.py b/prowler/lib/outputs/compliance/ccc/ccc_azure.py index 2801b91f97..e0cdf32330 100644 --- a/prowler/lib/outputs/compliance/ccc/ccc_azure.py +++ b/prowler/lib/outputs/compliance/ccc/ccc_azure.py @@ -35,10 +35,9 @@ class CCC_Azure(ComplianceOutput): - 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: + # 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 = CCC_AzureModel( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/ccc/ccc_gcp.py b/prowler/lib/outputs/compliance/ccc/ccc_gcp.py index 9793834776..326a15e5c3 100644 --- a/prowler/lib/outputs/compliance/ccc/ccc_gcp.py +++ b/prowler/lib/outputs/compliance/ccc/ccc_gcp.py @@ -35,10 +35,9 @@ class CCC_GCP(ComplianceOutput): - 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: + # 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 = CCC_GCPModel( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/cis/cis_alibabacloud.py b/prowler/lib/outputs/compliance/cis/cis_alibabacloud.py index adf8ef2af1..a1880d3af9 100644 --- a/prowler/lib/outputs/compliance/cis/cis_alibabacloud.py +++ b/prowler/lib/outputs/compliance/cis/cis_alibabacloud.py @@ -35,10 +35,9 @@ class AlibabaCloudCIS(ComplianceOutput): - 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: + # 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 = AlibabaCloudCISModel( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/cis/cis_aws.py b/prowler/lib/outputs/compliance/cis/cis_aws.py index 749a3b7463..ac0250150c 100644 --- a/prowler/lib/outputs/compliance/cis/cis_aws.py +++ b/prowler/lib/outputs/compliance/cis/cis_aws.py @@ -35,10 +35,9 @@ class AWSCIS(ComplianceOutput): - 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: + # 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 = AWSCISModel( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/cis/cis_azure.py b/prowler/lib/outputs/compliance/cis/cis_azure.py index 5c5e4cd486..b1e09ca453 100644 --- a/prowler/lib/outputs/compliance/cis/cis_azure.py +++ b/prowler/lib/outputs/compliance/cis/cis_azure.py @@ -35,10 +35,9 @@ class AzureCIS(ComplianceOutput): - 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: + # 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 = AzureCISModel( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/cis/cis_gcp.py b/prowler/lib/outputs/compliance/cis/cis_gcp.py index 328726aa9b..b2889333be 100644 --- a/prowler/lib/outputs/compliance/cis/cis_gcp.py +++ b/prowler/lib/outputs/compliance/cis/cis_gcp.py @@ -35,10 +35,9 @@ class GCPCIS(ComplianceOutput): - 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: + # 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 = GCPCISModel( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/cis/cis_github.py b/prowler/lib/outputs/compliance/cis/cis_github.py index 0082b891e5..2ce5b64480 100644 --- a/prowler/lib/outputs/compliance/cis/cis_github.py +++ b/prowler/lib/outputs/compliance/cis/cis_github.py @@ -35,10 +35,9 @@ class GithubCIS(ComplianceOutput): - 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: + # 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 = GithubCISModel( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/cis/cis_googleworkspace.py b/prowler/lib/outputs/compliance/cis/cis_googleworkspace.py index a4b58bb3b4..d74375bf85 100644 --- a/prowler/lib/outputs/compliance/cis/cis_googleworkspace.py +++ b/prowler/lib/outputs/compliance/cis/cis_googleworkspace.py @@ -35,10 +35,9 @@ class GoogleWorkspaceCIS(ComplianceOutput): - 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: + # 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 = GoogleWorkspaceCISModel( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/cis/cis_kubernetes.py b/prowler/lib/outputs/compliance/cis/cis_kubernetes.py index e8fee839fc..9607123e44 100644 --- a/prowler/lib/outputs/compliance/cis/cis_kubernetes.py +++ b/prowler/lib/outputs/compliance/cis/cis_kubernetes.py @@ -35,10 +35,9 @@ class KubernetesCIS(ComplianceOutput): - 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: + # 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 = KubernetesCISModel( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/cis/cis_m365.py b/prowler/lib/outputs/compliance/cis/cis_m365.py index 3c7de43542..1a00166946 100644 --- a/prowler/lib/outputs/compliance/cis/cis_m365.py +++ b/prowler/lib/outputs/compliance/cis/cis_m365.py @@ -35,10 +35,9 @@ class M365CIS(ComplianceOutput): - 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: + # 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 = M365CISModel( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/cis/cis_oraclecloud.py b/prowler/lib/outputs/compliance/cis/cis_oraclecloud.py index 19e6e1d8c8..117c3ca004 100644 --- a/prowler/lib/outputs/compliance/cis/cis_oraclecloud.py +++ b/prowler/lib/outputs/compliance/cis/cis_oraclecloud.py @@ -35,10 +35,9 @@ class OracleCloudCIS(ComplianceOutput): - 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: + # 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 = OracleCloudCISModel( Provider=finding.provider, 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..9f1336e832 100644 --- a/prowler/lib/outputs/compliance/cisa_scuba/cisa_scuba_googleworkspace.py +++ b/prowler/lib/outputs/compliance/cisa_scuba/cisa_scuba_googleworkspace.py @@ -37,10 +37,9 @@ class GoogleWorkspaceCISASCuBA(ComplianceOutput): - 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: + # 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 = GoogleWorkspaceCISASCuBAModel( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/compliance.py b/prowler/lib/outputs/compliance/compliance.py index b6b2f1f81f..65ad8af0b3 100644 --- a/prowler/lib/outputs/compliance/compliance.py +++ b/prowler/lib/outputs/compliance/compliance.py @@ -10,7 +10,6 @@ from prowler.lib.outputs.compliance.cis.cis import get_cis_table from prowler.lib.outputs.compliance.compliance_check import ( # noqa: F401 - re-export for backward compatibility get_check_compliance, ) -from prowler.lib.outputs.compliance.csa.csa import get_csa_table from prowler.lib.outputs.compliance.ens.ens import get_ens_table from prowler.lib.outputs.compliance.generic.generic_table import ( get_generic_compliance_table, @@ -33,24 +32,28 @@ def process_universal_compliance_frameworks( 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 - creates both a CSV (UniversalComplianceOutput) and an OCSF JSON - (OCSFComplianceOutput) file. OCSF is always generated regardless of + *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. - The function is idempotent: it tracks already-created writers via - ``generated_outputs["compliance"]`` keyed by ``file_path``. If invoked - again for the same framework (e.g. once per streaming batch), it - reuses the existing writer instead of recreating it. This guarantees - one output writer per framework for the whole execution and keeps - the OCSF JSON array valid across multiple calls. + 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 that were processed so the caller - can remove them before entering the legacy per-provider output loop. + 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, @@ -65,6 +68,13 @@ def process_universal_compliance_frameworks( 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 ( @@ -75,37 +85,46 @@ def process_universal_compliance_frameworks( 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" ) - if csv_path not in existing_writers: - output = UniversalComplianceOutput( + 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(output) - existing_writers[csv_path] = output - output.batch_write_data_to_file() + 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" ) - if ocsf_path not in existing_writers: - ocsf_output = OCSFComplianceOutput( + 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_output) - existing_writers[ocsf_path] = ocsf_output - ocsf_output.batch_write_data_to_file() + 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) @@ -206,15 +225,6 @@ def display_compliance_table( output_directory, compliance_overview, ) - elif compliance_framework.startswith("csa_ccm_"): - get_csa_table( - findings, - bulk_checks_metadata, - compliance_framework, - output_filename, - output_directory, - compliance_overview, - ) elif compliance_framework.startswith("c5_"): get_c5_table( findings, @@ -243,14 +253,32 @@ def display_compliance_table( compliance_overview, ) else: - get_generic_compliance_table( - findings, - bulk_checks_metadata, - compliance_framework, - output_filename, - output_directory, - compliance_overview, - ) + # 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}" diff --git a/prowler/lib/outputs/compliance/csa/csa.py b/prowler/lib/outputs/compliance/csa/csa.py deleted file mode 100644 index ab8a021a70..0000000000 --- a/prowler/lib/outputs/compliance/csa/csa.py +++ /dev/null @@ -1,101 +0,0 @@ -from colorama import Fore, Style -from tabulate import tabulate - -from prowler.config.config import orange_color - - -def get_csa_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 = {} - 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 - ): - for requirement in compliance.Requirements: - for attribute in requirement.Attributes: - section = attribute.Section - - if section not in sections: - sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0} - - 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 - - sections = dict(sorted(sections.items())) - for section in sections: - section_table["Provider"].append(compliance.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_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/csa_oraclecloud.py b/prowler/lib/outputs/compliance/csa/csa_oraclecloud.py deleted file mode 100644 index 9b3d36c168..0000000000 --- a/prowler/lib/outputs/compliance/csa/csa_oraclecloud.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 OracleCloudCSAModel -from prowler.lib.outputs.finding import Finding - - -class OracleCloudCSA(ComplianceOutput): - """ - This class represents the OracleCloud 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 OracleCloud CSA compliance format. - """ - - def transform( - self, - findings: list[Finding], - compliance: Compliance, - compliance_name: str, - ) -> None: - """ - Transforms a list of findings into OracleCloud 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 = OracleCloudCSAModel( - Provider=finding.provider, - Description=compliance.Description, - TenancyId=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 = OracleCloudCSAModel( - Provider=compliance.Provider.lower(), - Description=compliance.Description, - TenancyId="", - 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/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_aws.py b/prowler/lib/outputs/compliance/ens/ens_aws.py index 01a212d9af..40d185294b 100644 --- a/prowler/lib/outputs/compliance/ens/ens_aws.py +++ b/prowler/lib/outputs/compliance/ens/ens_aws.py @@ -35,10 +35,9 @@ class AWSENS(ComplianceOutput): - 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: + # 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 = AWSENSModel( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/ens/ens_azure.py b/prowler/lib/outputs/compliance/ens/ens_azure.py index 35385ab673..20d727fed0 100644 --- a/prowler/lib/outputs/compliance/ens/ens_azure.py +++ b/prowler/lib/outputs/compliance/ens/ens_azure.py @@ -35,10 +35,9 @@ class AzureENS(ComplianceOutput): - 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: + # 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 = AzureENSModel( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/ens/ens_gcp.py b/prowler/lib/outputs/compliance/ens/ens_gcp.py index 81d6d33b14..8a2baaca66 100644 --- a/prowler/lib/outputs/compliance/ens/ens_gcp.py +++ b/prowler/lib/outputs/compliance/ens/ens_gcp.py @@ -35,10 +35,9 @@ class GCPENS(ComplianceOutput): - 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: + # 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 = GCPENSModel( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/generic/generic.py b/prowler/lib/outputs/compliance/generic/generic.py index d9dbe8abe9..b774f09577 100644 --- a/prowler/lib/outputs/compliance/generic/generic.py +++ b/prowler/lib/outputs/compliance/generic/generic.py @@ -34,61 +34,48 @@ class GenericCompliance(ComplianceOutput): Returns: - None """ + + 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. + 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=finding.status if finding else "MANUAL", + StatusExtended=(finding.status_extended if finding else "Manual check"), + 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/iso27001/iso27001_aws.py b/prowler/lib/outputs/compliance/iso27001/iso27001_aws.py index b6da583af2..282f27a218 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_aws.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_aws.py @@ -35,10 +35,9 @@ class AWSISO27001(ComplianceOutput): - 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: + # 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 = AWSISO27001Model( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_azure.py b/prowler/lib/outputs/compliance/iso27001/iso27001_azure.py index 1e36005440..0112bbd7e1 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_azure.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_azure.py @@ -35,10 +35,9 @@ class AzureISO27001(ComplianceOutput): - 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: + # 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 = AzureISO27001Model( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_gcp.py b/prowler/lib/outputs/compliance/iso27001/iso27001_gcp.py index 9f08e3d920..f60a30e67e 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_gcp.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_gcp.py @@ -35,10 +35,9 @@ class GCPISO27001(ComplianceOutput): - 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: + # 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 = GCPISO27001Model( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py b/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py index b0e8904ebe..59fd593ce6 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py @@ -35,10 +35,9 @@ class KubernetesISO27001(ComplianceOutput): - 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: + # 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 = KubernetesISO27001Model( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_m365.py b/prowler/lib/outputs/compliance/iso27001/iso27001_m365.py index cf12bbd841..cf6712a4a6 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_m365.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_m365.py @@ -35,9 +35,9 @@ class M365ISO27001(ComplianceOutput): - None """ 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: for attribute in requirement.Attributes: compliance_row = M365ISO27001Model( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_nhn.py b/prowler/lib/outputs/compliance/iso27001/iso27001_nhn.py index 6c89b7333f..2ad5a0bda4 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_nhn.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_nhn.py @@ -35,9 +35,9 @@ class NHNISO27001(ComplianceOutput): - None """ 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: for attribute in requirement.Attributes: compliance_row = NHNISO27001Model( Provider=finding.provider, 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..f927c2d4b1 100644 --- a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws.py +++ b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws.py @@ -35,10 +35,9 @@ class AWSKISAISMSP(ComplianceOutput): - 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: + # 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 = AWSKISAISMSPModel( Provider=finding.provider, 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..33a7aca74b 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_aws.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_aws.py @@ -36,10 +36,9 @@ class AWSMitreAttack(ComplianceOutput): - 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: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: compliance_row = AWSMitreAttackModel( Provider=finding.provider, Description=compliance.Description, 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..0254aad86e 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_azure.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_azure.py @@ -36,10 +36,9 @@ class AzureMitreAttack(ComplianceOutput): - 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: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: compliance_row = AzureMitreAttackModel( Provider=finding.provider, Description=compliance.Description, 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..4634273494 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_gcp.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_gcp.py @@ -36,10 +36,9 @@ class GCPMitreAttack(ComplianceOutput): - 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: + # Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift). + if finding.check_id in requirement.Checks: compliance_row = GCPMitreAttackModel( Provider=finding.provider, Description=compliance.Description, 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..510c098dff 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_alibaba.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_alibaba.py @@ -37,10 +37,9 @@ class ProwlerThreatScoreAlibaba(ComplianceOutput): - 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: + # 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 = ProwlerThreatScoreAlibabaModel( Provider=finding.provider, 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..ae992f8a75 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws.py @@ -37,10 +37,9 @@ class ProwlerThreatScoreAWS(ComplianceOutput): - 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: + # 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 = ProwlerThreatScoreAWSModel( Provider=finding.provider, 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..dd0a3b9a56 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure.py @@ -37,10 +37,9 @@ class ProwlerThreatScoreAzure(ComplianceOutput): - 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: + # 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 = ProwlerThreatScoreAzureModel( Provider=finding.provider, 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..c3bad98ade 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp.py @@ -37,10 +37,9 @@ class ProwlerThreatScoreGCP(ComplianceOutput): - 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: + # 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 = ProwlerThreatScoreGCPModel( Provider=finding.provider, 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..51f88348f0 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_kubernetes.py @@ -37,10 +37,9 @@ class ProwlerThreatScoreKubernetes(ComplianceOutput): - 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: + # 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 = ProwlerThreatScoreKubernetesModel( Provider=finding.provider, 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..d0b2ad635c 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365.py @@ -37,10 +37,9 @@ class ProwlerThreatScoreM365(ComplianceOutput): - 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: + # 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 = ProwlerThreatScoreM365Model( Provider=finding.provider, diff --git a/prowler/lib/outputs/compliance/universal/ocsf_compliance.py b/prowler/lib/outputs/compliance/universal/ocsf_compliance.py index 2ce69412e1..2886f7e4d2 100644 --- a/prowler/lib/outputs/compliance/universal/ocsf_compliance.py +++ b/prowler/lib/outputs/compliance/universal/ocsf_compliance.py @@ -79,30 +79,43 @@ def _to_snake_case(name: str) -> str: return s.lower() -def _build_requirement_attrs(requirement, framework) -> dict: - """Build a dict with requirement attributes for the unmapped section. +def _build_requirement_attrs(requirement, framework): + """Build the requirement attributes payload for the unmapped section. - Keys are normalized to snake_case for OCSF consistency. - Only includes attributes whose AttributeMetadata has output_formats.ocsf=True. - When no metadata is declared, all attributes are included. + 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. """ - attrs = requirement.attributes - if not attrs: + requirement_attributes = requirement.attributes + if not requirement_attributes: return {} - # Build set of keys allowed for OCSF output metadata = framework.attributes_metadata - if metadata: - ocsf_keys = {m.key for m in metadata if m.output_formats.ocsf} - else: - ocsf_keys = None # No metadata → include all + allowed_keys = ( + {entry.key for entry in metadata if entry.output_formats.ocsf} + if metadata + else None + ) - result = {} - for key, value in attrs.items(): - if ocsf_keys is not None and key not in ocsf_keys: - continue - result[_to_snake_case(key)] = value - return result + 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: @@ -147,7 +160,14 @@ class OCSFComplianceOutput: 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: @@ -170,6 +190,9 @@ class OCSFComplianceOutput: 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 diff --git a/prowler/lib/outputs/compliance/universal/universal_output.py b/prowler/lib/outputs/compliance/universal/universal_output.py index 5f99f05755..a3cdb1389a 100644 --- a/prowler/lib/outputs/compliance/universal/universal_output.py +++ b/prowler/lib/outputs/compliance/universal/universal_output.py @@ -198,8 +198,15 @@ class UniversalComplianceOutput: findings: list["Finding"], framework: ComplianceFramework, compliance_name: str, + include_manual: bool = True, ) -> None: - """Transform findings into universal compliance CSV rows.""" + """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: @@ -228,6 +235,9 @@ class UniversalComplianceOutput: 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 diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index 2c2638ed65..9f772d17c4 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -342,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) @@ -427,6 +441,33 @@ 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 == "alibabacloud": output_data["auth_method"] = get_nested_attribute( provider, "identity.identity_arn" @@ -476,6 +517,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 @@ -549,6 +595,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 5886a87b2f..52afd071ae 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -1076,6 +1076,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: """ @@ -1400,6 +1467,127 @@ 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_assessment_summary(provider: Provider) -> str: """ @@ -1420,11 +1608,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..f7f31666e1 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: @@ -1118,6 +1120,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", 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..a1f37a9dfc 100644 --- a/prowler/lib/outputs/outputs.py +++ b/prowler/lib/outputs/outputs.py @@ -7,39 +7,52 @@ 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 + 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 +72,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 +91,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/summary_table.py b/prowler/lib/outputs/summary_table.py index 6ed09ed159..43d4a547c1 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -70,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" @@ -108,6 +115,15 @@ 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 + 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): 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/providers/aws/aws_regions_by_service.json b/prowler/providers/aws/aws_regions_by_service.json index f525ce2155..2e451910fb 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": [ @@ -1590,6 +1608,7 @@ "ap-southeast-2", "ca-central-1", "eu-central-1", + "eu-south-2", "eu-west-1", "eu-west-2", "us-east-1", @@ -2193,6 +2212,7 @@ "ap-southeast-2", "ap-southeast-3", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -2255,6 +2275,8 @@ "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", @@ -2444,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", @@ -2589,7 +2614,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" @@ -2796,6 +2823,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -2806,6 +2834,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -3767,6 +3796,7 @@ "ap-southeast-5", "ap-southeast-7", "ca-central-1", + "ca-west-1", "eu-central-1", "eu-central-2", "eu-north-1", @@ -3829,7 +3859,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" @@ -3890,17 +3922,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" @@ -4681,15 +4718,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", @@ -4703,6 +4744,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -5260,6 +5302,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -5310,6 +5353,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -5360,6 +5404,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -5410,6 +5455,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -5460,6 +5506,7 @@ "ap-southeast-3", "ap-southeast-4", "ap-southeast-5", + "ap-southeast-6", "ap-southeast-7", "ca-central-1", "ca-west-1", @@ -6034,6 +6081,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -6081,6 +6129,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -7681,6 +7730,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", @@ -8065,6 +8116,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -8075,8 +8127,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", @@ -8088,6 +8142,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -8279,22 +8334,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", @@ -8315,6 +8379,7 @@ "ap-northeast-2", "ap-northeast-3", "ap-south-1", + "ap-south-2", "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", @@ -8574,6 +8639,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -8706,13 +8772,19 @@ "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" @@ -9034,6 +9106,7 @@ "eu-west-1", "eu-west-2", "eu-west-3", + "sa-east-1", "us-east-1", "us-east-2", "us-west-2" @@ -9136,11 +9209,14 @@ "regions": { "aws": [ "ap-northeast-1", + "ap-northeast-3", "ap-south-1", "ap-southeast-1", "ap-southeast-2", "eu-central-1", "eu-north-1", + "eu-south-1", + "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", @@ -9377,6 +9453,7 @@ "ap-southeast-1", "ap-southeast-2", "ap-southeast-5", + "ap-southeast-7", "ca-central-1", "eu-central-1", "eu-central-2", @@ -9395,7 +9472,9 @@ "aws-cn": [ "cn-northwest-1" ], - "aws-eusc": [], + "aws-eusc": [ + "eusc-de-east-1" + ], "aws-us-gov": [ "us-gov-west-1" ] @@ -9865,10 +9944,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", @@ -10012,7 +10093,10 @@ ], "aws-cn": [], "aws-eusc": [], - "aws-us-gov": [] + "aws-us-gov": [ + "us-gov-east-1", + "us-gov-west-1" + ] } }, "resource-groups": { @@ -10693,7 +10777,10 @@ "us-west-1", "us-west-2" ], - "aws-cn": [], + "aws-cn": [ + "cn-north-1", + "cn-northwest-1" + ], "aws-eusc": [], "aws-us-gov": [] } @@ -11315,7 +11402,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", @@ -11647,26 +11736,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": [ @@ -13063,6 +13132,7 @@ "eu-west-3", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -13414,6 +13484,7 @@ "ap-south-1", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-5", "ca-central-1", "eu-central-1", "eu-west-1", @@ -13422,6 +13493,7 @@ "il-central-1", "sa-east-1", "us-east-1", + "us-east-2", "us-west-2" ], "aws-cn": [ 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/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.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/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 index fe0e740a5e..a1299f3d70 100644 --- 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 @@ -10,6 +10,8 @@ class iam_policy_no_wildcard_marketplace_subscribe(Check): 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" 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_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..5093a4ac02 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_service.py +++ b/prowler/providers/aws/services/sagemaker/sagemaker_service.py @@ -15,13 +15,19 @@ class SageMaker(AWSService): self.sagemaker_notebook_instances = [] self.sagemaker_models = [] self.sagemaker_training_jobs = [] + 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_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) @@ -34,6 +40,7 @@ class SageMaker(AWSService): 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 @@ -47,6 +54,7 @@ class SageMaker(AWSService): 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...") @@ -203,6 +211,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 +291,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 +379,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 @@ -303,9 +456,38 @@ class ProductionVariant(BaseModel): 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/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/azure/azure_provider.py b/prowler/providers/azure/azure_provider.py index ce28d60bda..c9496ac0a5 100644 --- a/prowler/providers/azure/azure_provider.py +++ b/prowler/providers/azure/azure_provider.py @@ -241,7 +241,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 +413,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( @@ -507,6 +513,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 +586,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 +672,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 +686,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 +964,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: @@ -1039,7 +1054,11 @@ class AzureProvider(Provider): } """ 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 subscription_id, display_name in self._identity.subscriptions.items(): @@ -1084,7 +1103,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. @@ -1093,6 +1115,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. @@ -1129,8 +1154,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, @@ -1162,7 +1192,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. @@ -1170,6 +1202,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. @@ -1179,7 +1214,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", @@ -1188,7 +1229,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/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 f8cfd417c9..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,10 +53,32 @@ 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 subscription_id, display_name in identity.subscriptions.items(): - clients.update({subscription_id: service(credential=session)}) + clients.update( + { + subscription_id: service( + credential=session, + endpoint=region_config.logs_endpoint, + ) + } + ) else: for subscription_id, display_name in identity.subscriptions.items(): clients.update( 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/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 8ba04fb50f..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 @@ -10,9 +10,13 @@ class sqlserver_tde_encrypted_with_cmk(Check): 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 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_service.py b/prowler/providers/azure/services/storage/storage_service.py index 6ec88248bf..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, @@ -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 e46b951583..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,32 +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(), @@ -35,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_name} ({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 + 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_name} ({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_name} ({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/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 9745bab2ca..9e23274a90 100644 --- a/prowler/providers/common/arguments.py +++ b/prowler/providers/common/arguments.py @@ -16,18 +16,41 @@ def init_providers_parser(self): # We need to call the arguments parser for each provider 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( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - sys.exit(1) + # Discriminate built-in vs external upfront via find_spec, so an + # ImportError from a transitive dependency missing inside a built-in + # arguments module surfaces clearly instead of being silently + # re-routed to the entry-point path (which only has external providers). + if Provider.is_builtin(provider): + try: + getattr( + import_module( + f"{providers_path}.{provider}.{provider_arguments_lib_path}" + ), + init_provider_arguments_function, + )(self) + except ImportError as e: + logger.critical( + f"Failed to load arguments for built-in provider '{provider}'. " + f"Missing dependency: {e}. " + f"Ensure all required dependencies are installed." + ) + logger.debug("Full traceback:", exc_info=True) + sys.exit(1) + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + sys.exit(1) + else: + # External provider — init_parser classmethod via entry point + 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 validate_provider_arguments(arguments: Namespace) -> tuple[bool, str]: 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..120cc1a374 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 @@ -69,3 +70,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 b0486d9410..8bc7567795 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -1,4 +1,6 @@ import importlib +import importlib.metadata +import importlib.util import os import pkgutil import sys @@ -136,6 +138,106 @@ class Provider(ABC): """ return set() + # --- Dynamic provider contract methods (not @abstractmethod for incremental migration) --- + + _cli_help_text: str = "" + + @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()" + ) + + 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. @@ -159,20 +261,74 @@ 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 + # Discriminate built-in vs external upfront via find_spec, so an + # ImportError from a transitive dependency missing inside a + # built-in's own import chain surfaces clearly instead of being + # silently re-routed to the entry-point path. + provider_class = None + if Provider.is_builtin(arguments.provider): + # Built-in wins on provider-name collision. Plug-ins are + # first-class extenders (they can register new provider + # names) but cannot override existing built-ins — a security + # tool prefers fail-loud predictability over silent + # overrides. Surface the override so the user knows their + # plug-in is being ignored and can rename it. + # Match by name only — never ep.load() a shadowing plug-in. + if 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." + ) + provider_class_path = f"{providers_path}.{arguments.provider}.{arguments.provider}_provider" + provider_class_name = f"{arguments.provider.capitalize()}Provider" + try: + provider_class = getattr( + import_module(provider_class_path), provider_class_name + ) + except ImportError as e: + 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) + except AttributeError: + # Module exists but doesn't define the expected class — + # treat as external and try entry points. + provider_class = Provider._load_ep_provider(arguments.provider) + else: + provider_class = Provider._load_ep_provider(arguments.provider) + + if provider_class is None: + raise ImportError( + f"Provider '{arguments.provider}' not found as built-in or entry point" + ) + + # Kept for downstream forks that may extend the dispatch below + # with their own custom built-in branches and reference this name. + # The upstream chain dispatches by `arguments.provider` directly. + provider_class_name = ( + f"{arguments.provider.capitalize()}Provider" # noqa: F841 ) 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) @@ -196,7 +352,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, @@ -209,7 +365,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, @@ -223,7 +379,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, @@ -233,7 +389,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, @@ -247,7 +403,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, @@ -256,7 +412,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 = [] @@ -288,13 +463,13 @@ class Provider(ABC): 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, @@ -302,7 +477,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, @@ -315,13 +490,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, @@ -339,7 +514,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, @@ -348,7 +523,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, @@ -359,7 +534,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( @@ -384,7 +559,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, @@ -396,13 +571,50 @@ 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, + ) + 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( @@ -415,17 +627,102 @@ 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 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_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: + # Try built-in first + module_path = f"{providers_path}.{name}.{name}_provider" + module = import_module(module_path) + cls = None + for attr_name in dir(module): + attr = getattr(module, attr_name) + if ( + isinstance(attr, type) + and issubclass(attr, Provider) + and attr is not Provider + ): + cls = attr + break + help_text[name] = getattr(cls, "_cli_help_text", "") if cls else "" + except ImportError: + # External provider — load via entry point + cls = Provider._load_ep_provider(name) + help_text[name] = getattr(cls, "_cli_help_text", "") if cls else "" + 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/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_service.py b/prowler/providers/gcp/services/cloudsql/cloudsql_service.py index 2d04a4248c..137169999a 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,23 @@ 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", []), + instance_type=instance.get( + "instanceType", "CLOUD_SQL_INSTANCE" + ), + cmek_key_name=instance.get( + "diskEncryptionConfiguration", {} + ).get("kmsKeyName"), project_id=project_id, ) ) @@ -68,4 +76,6 @@ class Instance(BaseModel): ssl_mode: str automated_backups: bool flags: list + 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..3d5b0d1e79 100644 --- a/prowler/providers/gcp/services/logging/logging_service.py +++ b/prowler/providers/gcp/services/logging/logging_service.py @@ -12,6 +12,7 @@ class Logging(GCPService): self.sinks = [] self.metrics = [] self._get_sinks() + self._get_org_sinks() self._get_metrics() def _get_sinks(self): @@ -39,6 +40,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 +90,7 @@ class Logging(GCPService): type=metric["metricDescriptor"]["type"], filter=metric["filter"], project_id=project_id, + bucket_name=metric.get("bucketName", ""), ) ) @@ -76,6 +110,7 @@ class Sink(BaseModel): destination: str filter: str project_id: str + include_children: bool = False class Metric(BaseModel): @@ -83,3 +118,59 @@ class Metric(BaseModel): type: str filter: str project_id: str + bucket_name: str = "" + + +def get_projects_covered_by_aggregated_metric( + logging_client, monitoring_client, metric_filter +): + """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). + """ + # 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 getattr(sink, "filter", "all") != "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/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/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 index 4c7e71c102..3cca0005dd 100644 --- 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 @@ -13,9 +13,8 @@ "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://support.google.com/a/answer/6329284", - "https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.py b/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.py index da65a162ab..f2c8f58b2f 100644 --- a/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.py +++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_invitations_warning/calendar_external_invitations_warning.py @@ -20,7 +20,10 @@ class calendar_external_invitations_warning(Check): if calendar_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=calendar_client.provider.domain_resource, + 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 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 index 87a82946e5..d923754a9e 100644 --- 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 @@ -13,9 +13,8 @@ "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://support.google.com/a/answer/60765", "https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings" ], "Remediation": { "Code": { diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.py b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.py index 935b28bd02..42be5e9f0d 100644 --- a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.py +++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar.py @@ -20,7 +20,10 @@ class calendar_external_sharing_primary_calendar(Check): if calendar_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=calendar_client.provider.domain_resource, + 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 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 index bd2bbf5be7..d1ac914a0f 100644 --- 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 @@ -13,9 +13,8 @@ "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://support.google.com/a/answer/60765", "https://knowledge.workspace.google.com/admin/calendar/set-google-calendar-sharing-options", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings" ], "Remediation": { "Code": { diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.py b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.py index 6834b60ae7..5f53fd2089 100644 --- a/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.py +++ b/prowler/providers/googleworkspace/services/calendar/calendar_external_sharing_secondary_calendar/calendar_external_sharing_secondary_calendar.py @@ -20,7 +20,10 @@ class calendar_external_sharing_secondary_calendar(Check): if calendar_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=calendar_client.provider.domain_resource, + 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 diff --git a/prowler/providers/googleworkspace/services/calendar/calendar_service.py b/prowler/providers/googleworkspace/services/calendar/calendar_service.py index f976b654c0..aa822d4218 100644 --- a/prowler/providers/googleworkspace/services/calendar/calendar_service.py +++ b/prowler/providers/googleworkspace/services/calendar/calendar_service.py @@ -30,7 +30,10 @@ class Calendar(GoogleWorkspaceService): logger.error("Failed to build Cloud Identity service") return - request = service.policies().list(pageSize=100) + request = service.policies().list( + pageSize=100, + filter='setting.type.matches("calendar.*")', + ) fetch_succeeded = True while request is not 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_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 73d7313a76..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 @@ -24,6 +24,9 @@ class directory_super_admin_count(Check): 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, ) if 2 <= admin_count <= 4: diff --git a/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.metadata.json b/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.metadata.json index 0264078982..ec531f10c8 100644 --- 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 @@ -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_only_admin_roles/directory_super_admin_only_admin_roles.py b/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.py index cc2cb47e98..d267eb3140 100644 --- a/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.py +++ b/prowler/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles.py @@ -47,6 +47,9 @@ class directory_super_admin_only_admin_roles(Check): 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 = ( 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 index a79283f9c3..8f1c14510a 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/60781", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { diff --git a/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.py b/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.py index be4ee08654..435e9c0f7d 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.py +++ b/prowler/providers/googleworkspace/services/drive/drive_access_checker_recipients_only/drive_access_checker_recipients_only.py @@ -19,7 +19,10 @@ class drive_access_checker_recipients_only(Check): if drive_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=drive_client.provider.domain_resource, + 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 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 index 43a08b0cab..576f78b19f 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/7491144", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { diff --git a/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.py b/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.py index de69f180ea..189694adaf 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.py +++ b/prowler/providers/googleworkspace/services/drive/drive_desktop_access_disabled/drive_desktop_access_disabled.py @@ -20,7 +20,10 @@ class drive_desktop_access_disabled(Check): if drive_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=drive_client.provider.domain_resource, + 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 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 index 8cfd0b4de8..474cadfe20 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/60781", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { diff --git a/prowler/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users.py b/prowler/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users.py index e82da80e44..98da6de892 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users.py +++ b/prowler/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users.py @@ -18,7 +18,10 @@ class drive_external_sharing_warn_users(Check): if drive_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=drive_client.provider.domain_resource, + 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 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 index 9194d6d19e..df537051ba 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/60781", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { diff --git a/prowler/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content.py b/prowler/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content.py index ef65d6ad49..cdf9f1c642 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content.py +++ b/prowler/providers/googleworkspace/services/drive/drive_internal_users_distribute_content/drive_internal_users_distribute_content.py @@ -19,7 +19,10 @@ class drive_internal_users_distribute_content(Check): if drive_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=drive_client.provider.domain_resource, + 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 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 index 9968665841..323d7f07a1 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/60781", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { diff --git a/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.py b/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.py index e377113a33..397f1eee68 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.py +++ b/prowler/providers/googleworkspace/services/drive/drive_publishing_files_disabled/drive_publishing_files_disabled.py @@ -19,7 +19,10 @@ class drive_publishing_files_disabled(Check): if drive_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=drive_client.provider.domain_resource, + 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 diff --git a/prowler/providers/googleworkspace/services/drive/drive_service.py b/prowler/providers/googleworkspace/services/drive/drive_service.py index e7d89f8473..68c4b48453 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_service.py +++ b/prowler/providers/googleworkspace/services/drive/drive_service.py @@ -31,7 +31,10 @@ class Drive(GoogleWorkspaceService): logger.error("Failed to build Cloud Identity service") return - request = service.policies().list(pageSize=100) + request = service.policies().list( + pageSize=100, + filter='setting.type.matches("drive_and_docs.*")', + ) fetch_succeeded = True while request is not None: 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 index aba88e28b3..c27742b7ed 100644 --- 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 @@ -13,8 +13,8 @@ "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/answer/7212025", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "https://support.google.com/a/users/answer/7212025", + "https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings" ], "Remediation": { "Code": { diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.py b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.py index e82b995418..e381ad8f26 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.py +++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_creation_allowed/drive_shared_drive_creation_allowed.py @@ -20,7 +20,10 @@ class drive_shared_drive_creation_allowed(Check): if drive_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=drive_client.provider.domain_resource, + 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 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 index d4bf3300b7..b2a7738925 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/7662202", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.py b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.py index a065fc08d0..85972f3674 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.py +++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_disable_download_print_copy/drive_shared_drive_disable_download_print_copy.py @@ -19,7 +19,10 @@ class drive_shared_drive_disable_download_print_copy(Check): if drive_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=drive_client.provider.domain_resource, + 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 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 index adc3555dc4..62e46c2ae2 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/7662202", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.py b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.py index 72d01ea73b..b6f6d1b887 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.py +++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_managers_cannot_override/drive_shared_drive_managers_cannot_override.py @@ -19,7 +19,10 @@ class drive_shared_drive_managers_cannot_override(Check): if drive_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=drive_client.provider.domain_resource, + 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 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 index 0949a55040..d9495ea249 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/7662202", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { diff --git a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.py b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.py index 2056fdb2d2..34cde3f6c6 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.py +++ b/prowler/providers/googleworkspace/services/drive/drive_shared_drive_members_only_access/drive_shared_drive_members_only_access.py @@ -19,7 +19,10 @@ class drive_shared_drive_members_only_access(Check): if drive_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=drive_client.provider.domain_resource, + 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 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 index 46caa58bf7..ddba469b0b 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/60781", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { diff --git a/prowler/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains.py b/prowler/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains.py index 7a3d77bfce..766afecda5 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains.py +++ b/prowler/providers/googleworkspace/services/drive/drive_sharing_allowlisted_domains/drive_sharing_allowlisted_domains.py @@ -18,7 +18,10 @@ class drive_sharing_allowlisted_domains(Check): if drive_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=drive_client.provider.domain_resource, + 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 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 index e41d074a23..c788df1a27 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/60781", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { diff --git a/prowler/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains.py b/prowler/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains.py index 549c089881..23fd47a229 100644 --- a/prowler/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains.py +++ b/prowler/providers/googleworkspace/services/drive/drive_warn_sharing_with_allowlisted_domains/drive_warn_sharing_with_allowlisted_domains.py @@ -19,7 +19,10 @@ class drive_warn_sharing_with_allowlisted_domains(Check): if drive_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=drive_client.provider.domain_resource, + resource=drive_client.policies, + resource_id="drivePolicies", + resource_name="Drive Policies", + customer_id=drive_client.provider.identity.customer_id, ) warn_enabled = ( 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 index 77a1c68b38..2295591dba 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/7676854", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index 5bbb732f06..b8895d4e21 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_anomalous_attachment_protection_enabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + 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 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 index 87d3111130..14484c251c 100644 --- 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 @@ -13,8 +13,8 @@ "Risk": "With auto-forwarding enabled, an attacker who gains control of a user account can create **forwarding rules to exfiltrate** all incoming email to an external address. This can persist undetected and provide the attacker with continuous access to sensitive communications even after the account is recovered.", "RelatedUrl": "", "AdditionalURLs": [ - "https://support.google.com/a/answer/2491924", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index 9930089eb7..65cd6f1fcb 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_auto_forwarding_disabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + 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 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 index 157a3da673..530d7af908 100644 --- 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 @@ -13,8 +13,8 @@ "Risk": "Without comprehensive mail storage, messages sent through other Google services (Calendar, Drive, etc.) may not be stored in Gmail and therefore **not subject to Vault retention policies**. This creates gaps in **compliance coverage**, **eDiscovery**, and **audit trails** that could violate regulatory requirements.", "RelatedUrl": "", "AdditionalURLs": [ - "https://support.google.com/a/answer/3547347", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index dd0b61760a..704e5a391a 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_comprehensive_mail_storage_enabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + 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 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 index 4ae8bfb3c0..7e6be13d58 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/9157861", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index fe275d4564..d3a6bc94c1 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_domain_spoofing_protection_enabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + 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 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 index b72137019a..d6d107f360 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/9157861", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index f5131842dd..ea6283c940 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_employee_name_spoofing_protection_enabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + 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 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 index 459ce1f539..3367e11777 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/7676854", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index 6a98dd0088..30a58cb4b4 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_encrypted_attachment_protection_enabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + 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 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 index 69b1be1e4b..0f14ecd73b 100644 --- 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 @@ -13,8 +13,8 @@ "Risk": "Without enhanced pre-delivery scanning, some **sophisticated phishing and malware** messages may pass through standard filters and be delivered to users. The additional scanning layer catches threats that the first-pass filters miss, reducing the organization's exposure to **zero-day phishing campaigns** and **targeted attacks**.", "RelatedUrl": "", "AdditionalURLs": [ - "https://support.google.com/a/answer/7380368", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index ab27fd837f..33e5a58f9b 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_enhanced_pre_delivery_scanning_enabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + resource=gmail_client.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, ) scanning_enabled = ( 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 index d6a087183c..4df5f8bd84 100644 --- 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 @@ -13,8 +13,8 @@ "Risk": "Without external image scanning, attackers can use **linked images to track email opens**, deliver **exploit payloads via image rendering vulnerabilities**, or use images as part of sophisticated **phishing schemes** that mimic legitimate communications.", "RelatedUrl": "", "AdditionalURLs": [ - "https://support.google.com/a/answer/7676854", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index b9731fac42..c71de1459a 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_external_image_scanning_enabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + 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 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 index 0c3dd64761..c5f5ee61a9 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/9157861", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index 866a53989d..fd4238239f 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_groups_spoofing_protection_enabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + 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 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 index 9e285621c8..a049a7ede5 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/9157861", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index f637759a55..b9a22cadc6 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_inbound_domain_spoofing_protection_enabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + 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 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 index a23cf60741..a4f9fd0460 100644 --- 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 @@ -13,8 +13,8 @@ "Risk": "If users can delegate access to their mailbox, an attacker who compromises one account could silently delegate access to maintain persistent email surveillance. This also increases the risk of **insider threats** and **data exfiltration** through shared mailbox access.", "RelatedUrl": "", "AdditionalURLs": [ - "https://support.google.com/a/answer/7223765", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index 65cf54733b..5e32e58b99 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_mail_delegation_disabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + 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 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 index 2f68b3adbc..95cd7567c3 100644 --- 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 @@ -13,8 +13,8 @@ "Risk": "With per-user outbound gateways enabled, users can route outbound email through **external SMTP servers**, bypassing organizational **email security controls**, **DLP policies**, and **audit logging**. This creates an unmonitored channel for data exfiltration and policy circumvention.", "RelatedUrl": "", "AdditionalURLs": [ - "https://support.google.com/a/answer/176652", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index 95c5e6996f..3f384de1d0 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_per_user_outbound_gateway_disabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + 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 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 index 76affe3e47..48f077d8ea 100644 --- 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 @@ -13,8 +13,8 @@ "Risk": "With POP and IMAP enabled, users can access email through **legacy clients** that rely on simple password authentication, bypassing **multifactor authentication** and other modern security controls. This significantly increases the risk of **credential-based account compromise**.", "RelatedUrl": "", "AdditionalURLs": [ - "https://support.google.com/a/answer/105694", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index 17f7de3658..18b5034e31 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_pop_imap_access_disabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + 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 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 index fc2a3af1b0..cae7d474ac 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/7676854", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index ec34df7cee..1dd2ed59d1 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_script_attachment_protection_enabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + 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 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 index 4ea88e92d3..518b90b962 100644 --- 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 @@ -13,8 +13,8 @@ "Risk": "Without shortened URL scanning, attackers can use **URL shortening services** to hide malicious destinations in phishing emails. Users cannot visually verify where the link leads, increasing the success rate of **phishing and credential harvesting** attacks.", "RelatedUrl": "", "AdditionalURLs": [ - "https://support.google.com/a/answer/7676854", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index f9bfef5112..01411dcc04 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_shortener_scanning_enabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + 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 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 index 6199080224..ec730d0a8b 100644 --- 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 @@ -13,8 +13,8 @@ "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://support.google.com/a/answer/9157861", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index 86d2339bff..44ff6fa24d 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_unauthenticated_email_protection_enabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + 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 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 index eb350e3b0f..78678ff9f5 100644 --- 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 @@ -13,8 +13,8 @@ "Risk": "Without untrusted link warnings, users may click on **phishing links** or links to **malware distribution sites** without any warning. This significantly increases the success rate of **social engineering attacks** targeting the organization.", "RelatedUrl": "", "AdditionalURLs": [ - "https://support.google.com/a/answer/7676854", - "https://cloud.google.com/identity/docs/concepts/supported-policy-api-settings" + "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": { 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 index 82cc9a1153..a62480de3b 100644 --- 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 @@ -18,7 +18,10 @@ class gmail_untrusted_link_warnings_enabled(Check): if gmail_client.policies_fetched: report = CheckReportGoogleWorkspace( metadata=self.metadata(), - resource=gmail_client.provider.domain_resource, + resource=gmail_client.policies, + resource_id="gmailPolicies", + resource_name="Gmail Policies", + customer_id=gmail_client.provider.identity.customer_id, ) warnings_enabled = ( 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/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..0454d85f3d 100644 --- a/prowler/providers/m365/m365_provider.py +++ b/prowler/providers/m365/m365_provider.py @@ -1073,7 +1073,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 +1261,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 +1282,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_service.py b/prowler/providers/m365/services/admincenter/admincenter_service.py index 45be16d11a..3899dced67 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_service.py +++ b/prowler/providers/m365/services/admincenter/admincenter_service.py @@ -175,17 +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", ""), - group_types=getattr(group, "group_types", []) or [], - ) - } - ) + 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( 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_service.py b/prowler/providers/m365/services/entra/entra_service.py index e18c7782fc..20f2dbf3a3 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -90,6 +90,7 @@ class Entra(M365Service): self._get_directory_sync_settings(), self._get_authentication_method_configurations(), self._get_service_principals(), + self._get_app_registrations(), ) ) @@ -106,6 +107,7 @@ class Entra(M365Service): str, AuthenticationMethodConfiguration ] = attributes[9] self.service_principals: Dict[str, "ServicePrincipal"] = attributes[10] + self.app_registrations: Dict[str, "AppRegistration"] = attributes[11] self.user_accounts_status = {} if created_loop: @@ -828,6 +830,7 @@ class Entra(M365Service): "userType", "accountEnabled", "onPremisesSyncEnabled", + "employeeHireDate", ], ) ) @@ -888,6 +891,7 @@ class Entra(M365Service): "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) @@ -1195,6 +1199,10 @@ OAuthAppInfo 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 []: @@ -1205,6 +1213,10 @@ OAuthAppInfo 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( @@ -1255,12 +1267,108 @@ OAuthAppInfo 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 + class ConditionalAccessPolicyState(Enum): ENABLED = "enabled" @@ -1381,7 +1489,7 @@ class PlatformConditions(BaseModel): @validator("include_platforms", "exclude_platforms", pre=True) @classmethod - def normalize_platforms(cls, values): + def normalize_platforms(cls, values): # noqa: vulture if not values: return [] @@ -1619,6 +1727,7 @@ class User(BaseModel): 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 @@ -1629,6 +1738,7 @@ class User(BaseModel): account_enabled: bool = True authentication_methods: List[str] = [] user_type: Optional[str] = None + employee_hire_date: Optional[datetime] = None class InvitationsFrom(Enum): @@ -1700,12 +1810,15 @@ class PasswordCredential(BaseModel): 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: @@ -1774,6 +1887,12 @@ class ServicePrincipal(BaseModel): 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 @@ -1783,3 +1902,22 @@ class ServicePrincipal(BaseModel): 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_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 9485676d30..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 @@ -12,7 +13,8 @@ class entra_users_mfa_capable(Check): Microsoft 365 Foundations Benchmark recommendation 5.2.3.4 ("Ensure all member users are 'MFA capable'"). - Guest users and disabled accounts are excluded from the evaluation. + 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 @@ -38,6 +40,15 @@ class entra_users_mfa_capable(Check): for user in entra_client.users.values(): 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(), 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_service.py b/prowler/providers/m365/services/exchange/exchange_service.py index 9b3ee47da9..3ce6528aa9 100644 --- a/prowler/providers/m365/services/exchange/exchange_service.py +++ b/prowler/providers/m365/services/exchange/exchange_service.py @@ -8,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): """ @@ -34,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(): @@ -46,6 +56,7 @@ 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 @@ -355,6 +366,50 @@ 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): """ @@ -497,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/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..8859507ec7 --- /dev/null +++ b/prowler/providers/okta/okta_provider.py @@ -0,0 +1,466 @@ +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" + _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/oraclecloud/oraclecloud_provider.py b/prowler/providers/oraclecloud/oraclecloud_provider.py index 22fa10b276..5aa959d985 100644 --- a/prowler/providers/oraclecloud/oraclecloud_provider.py +++ b/prowler/providers/oraclecloud/oraclecloud_provider.py @@ -161,12 +161,14 @@ class OraclecloudProvider(Provider): # Get regions self._regions = self.get_regions_to_audit(region) - self._home_region = None - if self._regions: - self._home_region = next( - (region.key for region in self._regions if region.is_home_region), - self._regions[0].key, - ) + # 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 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/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/stackit_provider.py b/prowler/providers/stackit/stackit_provider.py new file mode 100644 index 0000000000..417555ef1d --- /dev/null +++ b/prowler/providers/stackit/stackit_provider.py @@ -0,0 +1,617 @@ +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.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 + + 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} + """ + 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 = IaasDefaultApi(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..d9c8cdb9be --- /dev/null +++ b/prowler/providers/stackit/stackit_regions_by_service.json @@ -0,0 +1,10 @@ +{ + "services": { + "iaas": { + "regions": [ + "eu01", + "eu02" + ] + } + } +} diff --git a/prowler/providers/vercel/models.py b/prowler/providers/vercel/models.py index 5f730bde76..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) diff --git a/pyproject.toml b/pyproject.toml index 70cca68ccc..f029f3d6f7 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==25.1.0", + "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==8.3.5", + "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] @@ -50,7 +73,7 @@ dependencies = [ "dash-bootstrap-components==2.0.3", "defusedxml==0.7.1", "detect-secrets==1.5.0", - "dulwich==0.23.0", + "dulwich==1.2.5", "google-api-python-client==2.163.0", "google-auth-httplib2==0.2.0", "jsonschema==4.23.0", @@ -59,6 +82,7 @@ dependencies = [ "microsoft-kiota-abstractions==1.9.2", "msgraph-sdk==1.55.0", "numpy==2.0.2", + "okta==3.4.2", "openstacksdk==4.2.0", "pandas==2.2.3", "py-ocsf-models==0.8.1", @@ -69,6 +93,9 @@ dependencies = [ "schema==0.7.5", "shodan==1.31.0", "slack-sdk==3.39.0", + "stackit-core==0.2.0", + "stackit-iaas==1.4.0", + "stackit-resourcemanager==0.8.0", "tabulate==0.9.0", "tzlocal==5.3.1", "uuid6==2024.7.10", @@ -87,7 +114,8 @@ 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" @@ -95,7 +123,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"} name = "prowler" readme = "README.md" requires-python = ">=3.10,<3.13" -version = "5.27.0" +version = "5.30.0" [project.scripts] prowler = "prowler.__main__:prowler" @@ -106,41 +134,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" -mock = "5.2.0" -moto = {extras = ["all"], version = "5.1.11"} -openapi-schema-validator = "0.6.3" -openapi-spec-validator = "0.7.1" -prek = "0.3.9" -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 = [ @@ -154,3 +152,214 @@ 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.13.5", + "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==25.1.0", + "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", + "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.2", + "microsoft-kiota-http==1.9.2", + "microsoft-kiota-serialization-form==1.9.2", + "microsoft-kiota-serialization-json==1.9.2", + "microsoft-kiota-serialization-multipart==1.9.2", + "microsoft-kiota-serialization-text==1.9.2", + "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", + "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==8.3.5", + "pytest-cov==6.0.0", + "pytest-env==1.1.5", + "pytest-randomly==3.16.0", + "pytest-xdist==3.6.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"] diff --git a/scripts/setup-git-hooks.sh b/scripts/setup-git-hooks.sh index c89ab607a7..c1d930b20f 100755 --- a/scripts/setup-git-hooks.sh +++ b/scripts/setup-git-hooks.sh @@ -1,7 +1,7 @@ #!/bin/bash # Setup Git Hooks for Prowler -# This script installs prek 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 @@ -32,27 +32,27 @@ fi echo "" -# Full setup requires Poetry for system hooks (pylint, bandit, safety, vulture, trufflehog) +# 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 poetry &>/dev/null && [ -f "pyproject.toml" ]; then - if poetry run prek --version &>/dev/null 2>&1; then - echo -e "${GREEN}✓${NC} prek and dependencies found via Poetry" +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}" - poetry install --with dev + uv sync fi echo -e "${YELLOW}🔗 Installing prek hooks...${NC}" - poetry run prek install --overwrite + uv run prek install --overwrite elif command -v prek &>/dev/null; then - # prek is available system-wide but without Poetry dev deps + # 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 Poetry:${NC}" - echo -e " pylint, bandit, safety, vulture, trufflehog" + 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}poetry install --with dev${NC}" + 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}" diff --git a/skills/README.md b/skills/README.md index 999760d079..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 ``` @@ -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-16/SKILL.md b/skills/nextjs-16/SKILL.md index 272339c2da..cea38e255d 100644 --- a/skills/nextjs-16/SKILL.md +++ b/skills/nextjs-16/SKILL.md @@ -14,7 +14,7 @@ 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 (/) 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..6dad976983 100644 --- a/skills/prowler-attack-paths-query/SKILL.md +++ b/skills/prowler-attack-paths-query/SKILL.md @@ -228,7 +228,7 @@ AWS_QUERIES: list[AttackPathsQueryDefinition] = [ **FIRST**, read all files in the queries module to understand the structure, type definitions, registration, and existing style: -``` +```text api/src/backend/api/attack_paths/queries/ ├── __init__.py # Module exports ├── types.py # AttackPathsQueryDefinition, AttackPathsQueryParameterDefinition @@ -250,7 +250,7 @@ grep cartography api/pyproject.toml Build the schema URL (ALWAYS use the specific tag, not master/main): -``` +```text # 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 @@ -283,7 +283,7 @@ Add the constant to the `{PROVIDER}_QUERIES` list. ### Query ID -``` +```text {provider}-{category}-{description} ``` @@ -291,7 +291,7 @@ Examples: `aws-ec2-privesc-passrole-iam`, `aws-ec2-instances-internet-exposed` ### Query constant name -``` +```text {PROVIDER}_{CATEGORY}_{DESCRIPTION} ``` diff --git a/skills/prowler-changelog/SKILL.md b/skills/prowler-changelog/SKILL.md index de9d1be5ba..67e853b0fd 100644 --- a/skills/prowler-changelog/SKILL.md +++ b/skills/prowler-changelog/SKILL.md @@ -132,6 +132,19 @@ Before editing any `CHANGELOG.md`, always inspect the active release boundary: **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) 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 51c68eb05f..f119c7fa9b 100644 --- a/skills/prowler-compliance/SKILL.md +++ b/skills/prowler-compliance/SKILL.md @@ -59,7 +59,7 @@ See "Compliance Framework Location" and "Framework-Specific Attribute Structures **Every framework directory follows this exact convention** — do not deviate: -``` +```text {framework}/ ├── __init__.py ├── {framework}.py # ONLY get_{framework}_table() — NO function docstring @@ -85,7 +85,7 @@ See "Compliance Framework Location" and "Framework-Specific Attribute Structures ### The CLI Pipeline (end-to-end) -``` +```text prowler aws --compliance ccc_aws ↓ Compliance.get_bulk("aws") → parses prowler/compliance/aws/*.json @@ -483,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` | @@ -508,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` | @@ -518,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` | @@ -528,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` | @@ -561,7 +565,7 @@ done 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/ @@ -902,7 +906,7 @@ Add fixtures to `tests/lib/outputs/compliance/fixtures.py`: one `Compliance` obj **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 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 c9f2811e97..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 @@ -155,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-ui/SKILL.md b/skills/prowler-ui/SKILL.md index bde8c966db..f0f2bae08e 100644 --- a/skills/prowler-ui/SKILL.md +++ b/skills/prowler-ui/SKILL.md @@ -27,7 +27,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task ## Tech Stack (Versions) -``` +```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 @@ -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 35fae8b68a..bb49447249 100644 --- a/skills/prowler/SKILL.md +++ b/skills/prowler/SKILL.md @@ -16,7 +16,7 @@ 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 16, React 19, Tailwind 4 | `ui/` | | MCP | FastMCP 2.13.1 | `mcp_server/` | @@ -25,13 +25,13 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task ```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/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 1540ef6d5b..2a7aecd330 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,6 +18,12 @@ MOCK_OLD_PROWLER_VERSION = "0.0.0" MOCK_PROWLER_MASTER_VERSION = "3.4.0" +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() @@ -464,6 +471,32 @@ class Test_Config: 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}" + ) + 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" @@ -501,6 +534,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 70361e4a69..39cba5f27d 100644 --- a/tests/config/fixtures/config.yaml +++ b/tests/config/fixtures/config.yaml @@ -488,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/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/models_test.py b/tests/lib/check/models_test.py index 71fd1f718b..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): 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 index 5a3e4aae56..c8f614488c 100644 --- a/tests/lib/check/universal_compliance_models_test.py +++ b/tests/lib/check/universal_compliance_models_test.py @@ -1,5 +1,7 @@ import json import os +import tempfile +from unittest.mock import MagicMock, patch import pytest from pydantic.v1 import ValidationError @@ -23,6 +25,7 @@ from prowler.lib.check.compliance_models import ( TableLabels, UniversalComplianceRequirement, adapt_legacy_to_universal, + get_bulk_compliance_frameworks_universal, load_compliance_framework_universal, ) from tests.lib.outputs.compliance.fixtures import ( @@ -1116,3 +1119,121 @@ class TestAttributesMetadataValidation: ], 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 d42b1482b3..cd602a3a61 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,llm} ..." +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,dashboard,iac,image,llm} ..." def mock_get_available_providers(): @@ -38,6 +38,7 @@ def mock_get_available_providers(): "llm", "cloudflare", "openstack", + "stackit", ] diff --git a/tests/lib/cli/redact_test.py b/tests/lib/cli/redact_test.py index 5de4cc8fd7..c7ff65a765 100644 --- a/tests/lib/cli/redact_test.py +++ b/tests/lib/cli/redact_test.py @@ -160,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.""" @@ -168,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/display_compliance_table_test.py b/tests/lib/outputs/compliance/display_compliance_table_test.py index 0d2cd5313b..a55789a7fd 100644 --- a/tests/lib/outputs/compliance/display_compliance_table_test.py +++ b/tests/lib/outputs/compliance/display_compliance_table_test.py @@ -94,21 +94,6 @@ class TestDispatchStartswith: display_compliance_table(compliance_framework=framework_name, **_COMMON) mock_fn.assert_called_once() - @pytest.mark.parametrize( - "framework_name", - [ - "csa_ccm_4.0_aws", - "csa_ccm_4.0_azure", - "csa_ccm_4.0_gcp", - "csa_ccm_4.0_oraclecloud", - "csa_ccm_4.0_alibabacloud", - ], - ) - @patch(f"{MODULE}.get_csa_table") - def test_csa_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"], diff --git a/tests/lib/outputs/compliance/fixtures.py b/tests/lib/outputs/compliance/fixtures.py index e1929ee4d4..f085460abd 100644 --- a/tests/lib/outputs/compliance/fixtures.py +++ b/tests/lib/outputs/compliance/fixtures.py @@ -128,7 +128,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=[ @@ -177,7 +177,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=[ @@ -259,6 +259,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", @@ -336,6 +337,7 @@ MITRE_ATTACK_AWS = Compliance( "inspector2_active_findings_exist", "awslambda_function_not_publicly_accessible", "ec2_instance_public_ip", + "service_test_check_id", ], ), Mitre_Requirement( @@ -412,6 +414,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( @@ -467,6 +470,7 @@ MITRE_ATTACK_GCP = Compliance( "compute_instance_public_ip", "compute_public_address_shodan", "kms_key_not_publicly_accessible", + "service_test_check_id", ], ), Mitre_Requirement( @@ -514,7 +518,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", @@ -562,7 +566,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", @@ -609,7 +613,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", @@ -666,7 +670,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", @@ -709,7 +716,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", @@ -759,6 +766,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( @@ -815,6 +823,7 @@ KISA_ISMSP_AWS = Compliance( Checks=[ "cloudwatch_log_metric_filter_authentication_failures", "cognito_user_pool_mfa_enabled", + "service_test_check_id", ], ), Compliance_Requirement( @@ -872,6 +881,7 @@ PROWLER_THREATSCORE_AWS = Compliance( ], Checks=[ "iam_root_mfa_enabled", + "service_test_check_id", ], ), Compliance_Requirement( @@ -916,6 +926,7 @@ PROWLER_THREATSCORE_AZURE = Compliance( ], Checks=[ "iam_root_mfa_enabled", + "service_test_check_id", ], ), Compliance_Requirement( @@ -960,6 +971,7 @@ PROWLER_THREATSCORE_GCP = Compliance( ], Checks=[ "iam_root_mfa_enabled", + "service_test_check_id", ], ), Compliance_Requirement( @@ -1004,6 +1016,7 @@ PROWLER_THREATSCORE_M365 = Compliance( ], Checks=[ "iam_root_mfa_enabled", + "service_test_check_id", ], ), Compliance_Requirement( 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/process_universal_test.py b/tests/lib/outputs/compliance/process_universal_test.py index fa8b737ddd..4dc957ed4f 100644 --- a/tests/lib/outputs/compliance/process_universal_test.py +++ b/tests/lib/outputs/compliance/process_universal_test.py @@ -12,6 +12,7 @@ Also validates that print_compliance_frameworks and print_compliance_requirement work with universal ComplianceFramework objects (dict checks, None provider). """ +import csv import json import os from datetime import datetime, timezone @@ -124,6 +125,41 @@ def _make_universal_framework(name="TestFW", version="1.0", with_table_config=Tr ) +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 ──────────────────────────────────────────────────────────── @@ -728,3 +764,243 @@ class TestIdempotency: # 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/universal/ocsf_compliance_test.py b/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py index 71429fc1ca..db6b78de28 100644 --- a/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py +++ b/tests/lib/outputs/compliance/universal/ocsf_compliance_test.py @@ -202,6 +202,26 @@ class TestOCSFComplianceOutput: 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", @@ -631,3 +651,103 @@ class TestNoTopLevelOCSFImport: 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_test.py b/tests/lib/outputs/compliance/universal/universal_output_test.py index 391a9015d7..d0fdf0f178 100644 --- a/tests/lib/outputs/compliance/universal/universal_output_test.py +++ b/tests/lib/outputs/compliance/universal/universal_output_test.py @@ -122,6 +122,43 @@ class TestManualRequirements: 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): diff --git a/tests/lib/outputs/finding_test.py b/tests/lib/outputs/finding_test.py index fb75a1f705..0f1cb14e25 100644 --- a/tests/lib/outputs/finding_test.py +++ b/tests/lib/outputs/finding_test.py @@ -1507,3 +1507,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..788d6ba7c9 100644 --- a/tests/lib/outputs/jira/jira_test.py +++ b/tests/lib/outputs/jira/jira_test.py @@ -1004,6 +1004,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..87901bd290 100644 --- a/tests/lib/outputs/outputs_test.py +++ b/tests/lib/outputs/outputs_test.py @@ -1256,3 +1256,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/summary_table_test.py b/tests/lib/outputs/summary_table_test.py index c1e5322c33..0c842225cc 100644 --- a/tests/lib/outputs/summary_table_test.py +++ b/tests/lib/outputs/summary_table_test.py @@ -40,3 +40,65 @@ class TestDisplaySummaryTable: 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/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/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 index b667e7dbfb..b3b41f5e7a 100644 --- 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 @@ -507,3 +507,84 @@ class Test_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/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_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..d49a506fd9 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, 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) == 5 + + # Test SageMaker list model package groups + def test_list_model_package_groups(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + sagemaker = SageMaker(aws_provider) + assert len(sagemaker.sagemaker_model_registries) == 1 + registry = sagemaker.sagemaker_model_registries[0] + assert registry.region == AWS_REGION_EU_WEST_1 + assert registry.has_groups is True + assert registry.has_approved_packages is True + + def test_list_model_package_groups_access_denied(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + def mock_access_denied(self, operation_name, kwarg): + if operation_name == "ListModelPackageGroups": + raise botocore.exceptions.ClientError( + { + "Error": { + "Code": "AccessDeniedException", + "Message": "User is not authorized to perform sagemaker:ListModelPackageGroups", + } + }, + "ListModelPackageGroups", + ) + return make_api_call(self, operation_name, kwarg) + + with patch("botocore.client.BaseClient._make_api_call", new=mock_access_denied): + sagemaker = SageMaker(aws_provider) + assert sagemaker.sagemaker_model_registries == [] 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/azure/azure_provider_test.py b/tests/providers/azure/azure_provider_test.py index 8f99df8637..1d9aa97e6b 100644 --- a/tests/providers/azure/azure_provider_test.py +++ b/tests/providers/azure/azure_provider_test.py @@ -1,4 +1,5 @@ -from unittest.mock import patch +import asyncio +from unittest.mock import AsyncMock, patch from uuid import uuid4 import pytest @@ -86,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", @@ -721,3 +723,382 @@ class TestAzureProviderSetupIdentitySubscriptions: 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/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/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 6add11d9c3..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 @@ -264,3 +264,200 @@ class Test_sqlserver_tde_encrypted_with_cmk: 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 + assert result[0].resource_id == sql_server_id + assert result[0].location == "location" 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_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 49bc32ba26..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 @@ -20,6 +20,7 @@ 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( @@ -44,6 +45,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm: 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( @@ -97,6 +99,7 @@ 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( @@ -154,6 +157,7 @@ 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( @@ -194,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_DISPLAY} 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): @@ -211,6 +215,7 @@ 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( @@ -251,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_DISPLAY} 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/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/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..eefde19545 --- /dev/null +++ b/tests/providers/external/test_dynamic_provider_loading.py @@ -0,0 +1,2139 @@ +""" +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 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: + """Tests the critical behavior fix: when a built-in provider's arguments + module exists but its imports fail (e.g. boto3 not installed), we must + fail loudly with a clear message — not silently fall through to entry + points as if the provider were external.""" + + @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 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"], + ), + pytest.raises(SystemExit), + ): + init_providers_parser(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) + + +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.get_available_providers") + def test_get_providers_help_text_builtin_path(self, mock_providers, mock_import): + """get_providers_help_text reads _cli_help_text from a built-in provider module.""" + import types + + mock_providers.return_value = ["fakebuiltin"] + + 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_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 diff --git a/tests/providers/gcp/gcp_fixtures.py b/tests/providers/gcp/gcp_fixtures.py index 99eb88d25b..ba6480ee22 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, @@ -1323,6 +1334,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/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_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..72e18eddbb 100644 --- a/tests/providers/gcp/services/logging/logging_service_test.py +++ b/tests/providers/gcp/services/logging/logging_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.gcp.services.logging.logging_service import Logging from tests.providers.gcp.gcp_fixtures import ( @@ -66,3 +66,237 @@ 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_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/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 index 0320ea4a82..5ff2cee8f6 100644 --- 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 @@ -5,7 +5,6 @@ from prowler.providers.googleworkspace.services.calendar.calendar_service import ) from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -40,7 +39,7 @@ class TestCalendarExternalInvitationsWarning: assert len(findings) == 1 assert findings[0].status == "PASS" assert "enabled" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Calendar Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_warnings_disabled(self): diff --git a/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar_test.py b/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar_test.py index 0d34c6a9be..175955f4b6 100644 --- a/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar_test.py +++ b/tests/providers/googleworkspace/services/calendar/calendar_external_sharing_primary_calendar/calendar_external_sharing_primary_calendar_test.py @@ -5,7 +5,6 @@ from prowler.providers.googleworkspace.services.calendar.calendar_service import ) from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -40,10 +39,15 @@ class TestCalendarExternalSharingPrimaryCalendar: assert len(findings) == 1 assert findings[0].status == "PASS" assert "free/busy information only" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN - assert findings[0].resource_id == CUSTOMER_ID + assert findings[0].resource_name == "Calendar Policies" + assert findings[0].resource_id == "calendarPolicies" assert findings[0].customer_id == CUSTOMER_ID - assert findings[0].resource == mock_provider.domain_resource.dict() + 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""" 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 index 800f9ab5f0..b513860125 100644 --- 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 @@ -5,7 +5,6 @@ from prowler.providers.googleworkspace.services.calendar.calendar_service import ) from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -40,7 +39,7 @@ class TestCalendarExternalSharingSecondaryCalendar: assert len(findings) == 1 assert findings[0].status == "PASS" assert "free/busy information only" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Calendar Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_read_only(self): 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_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 99ae59ef29..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,8 @@ 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_id == CUSTOMER_ID + 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() diff --git a/tests/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles_test.py b/tests/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles_test.py index 0d15eef2a5..c3341159bd 100644 --- a/tests/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles_test.py +++ b/tests/providers/googleworkspace/services/directory/directory_super_admin_only_admin_roles/directory_super_admin_only_admin_roles_test.py @@ -6,7 +6,6 @@ from prowler.providers.googleworkspace.services.directory.directory_service impo ) from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -90,8 +89,8 @@ class TestDirectorySuperAdminOnlyAdminRoles: 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 == DOMAIN - assert findings[0].resource_id == CUSTOMER_ID + 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() 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 index b439429e20..e200836bcd 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -38,7 +37,7 @@ class TestDriveAccessCheckerRecipientsOnly: assert len(findings) == 1 assert findings[0].status == "PASS" assert "recipients only" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Drive Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_recipients_or_audience(self): 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 index 00ed8dfa26..7225974c68 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -36,7 +35,7 @@ class TestDriveDesktopAccessDisabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "disabled" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Drive Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_desktop_enabled(self): diff --git a/tests/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users_test.py b/tests/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users_test.py index 8520971268..3b66837460 100644 --- a/tests/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users_test.py +++ b/tests/providers/googleworkspace/services/drive/drive_external_sharing_warn_users/drive_external_sharing_warn_users_test.py @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -36,10 +35,13 @@ class TestDriveExternalSharingWarnUsers: assert len(findings) == 1 assert findings[0].status == "PASS" assert "enabled" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN - assert findings[0].resource_id == CUSTOMER_ID + assert findings[0].resource_name == "Drive Policies" + assert findings[0].resource_id == "drivePolicies" assert findings[0].customer_id == CUSTOMER_ID - assert findings[0].resource == mock_provider.domain_resource.dict() + 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""" 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 index 5ac3067102..b089d52d58 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -38,7 +37,7 @@ class TestDriveInternalUsersDistributeContent: assert len(findings) == 1 assert findings[0].status == "PASS" assert "ELIGIBLE_INTERNAL_USERS" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Drive Policies" assert findings[0].customer_id == CUSTOMER_ID def test_pass_none_allowed(self): 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 index cbcef4f900..8d6a0efcff 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -36,7 +35,7 @@ class TestDrivePublishingFilesDisabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "disabled" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Drive Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_publishing_enabled(self): 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 index 016e643f2d..6ad0222f06 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -36,7 +35,7 @@ class TestDriveSharedDriveCreationAllowed: assert len(findings) == 1 assert findings[0].status == "PASS" assert "allowed" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Drive Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_creation_disabled(self): 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 index 05edcb57a2..4a7a926745 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -38,7 +37,7 @@ class TestDriveSharedDriveDisableDownloadPrintCopy: assert len(findings) == 1 assert findings[0].status == "PASS" assert "EDITORS_ONLY" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Drive Policies" assert findings[0].customer_id == CUSTOMER_ID def test_pass_managers_only(self): 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 index 4b29aa5a0a..99e4af7d16 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -38,7 +37,7 @@ class TestDriveSharedDriveManagersCannotOverride: assert len(findings) == 1 assert findings[0].status == "PASS" assert "cannot override" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Drive Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_override_allowed(self): 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 index 06b1877d52..b25fa33cf0 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -36,7 +35,7 @@ class TestDriveSharedDriveMembersOnlyAccess: assert len(findings) == 1 assert findings[0].status == "PASS" assert "members only" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Drive Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_non_member_access_enabled(self): 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 index 0323e5e399..337afdc9aa 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -38,7 +37,7 @@ class TestDriveSharingAllowlistedDomains: assert len(findings) == 1 assert findings[0].status == "PASS" assert "allowlisted domains" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Drive Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_allowed(self): 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 index 1731b81fcc..1aadeb924b 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -38,7 +37,7 @@ class TestDriveWarnSharingWithAllowlistedDomains: assert len(findings) == 1 assert findings[0].status == "PASS" assert "warned" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Drive Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_warning_disabled(self): 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 index c0b5ac2dbc..a57bee1ff1 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -38,7 +37,7 @@ class TestGmailAnomalousAttachmentProtectionEnabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "WARNING" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_no_action(self): 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 index 462f8b4f05..2de8a79b9b 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -35,10 +34,13 @@ class TestGmailAutoForwardingDisabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "disabled" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN - assert findings[0].resource_id == CUSTOMER_ID + assert findings[0].resource_name == "Gmail Policies" + assert findings[0].resource_id == "gmailPolicies" assert findings[0].customer_id == CUSTOMER_ID - assert findings[0].resource == mock_provider.domain_resource.dict() + assert ( + findings[0].resource + == GmailPolicies(enable_auto_forwarding=False).dict() + ) def test_fail_disabled(self): mock_provider = set_mocked_googleworkspace_provider() 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 index 8c2525e819..1dde307b71 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -37,7 +36,7 @@ class TestGmailComprehensiveMailStorageEnabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "enabled" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_disabled(self): 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 index db16061744..de18c6d9c6 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -38,7 +37,7 @@ class TestGmailDomainSpoofingProtectionEnabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "SPAM_FOLDER" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_no_action(self): 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 index d8a204a6de..d30284b852 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -38,7 +37,7 @@ class TestGmailEmployeeNameSpoofingProtectionEnabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "SPAM_FOLDER" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_no_action(self): 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 index 5b86095b0d..cc41ff3f66 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -38,7 +37,7 @@ class TestGmailEncryptedAttachmentProtectionEnabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "QUARANTINE" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_no_action(self): 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 index 140a41ec99..823f7ee899 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -37,7 +36,7 @@ class TestGmailEnhancedPreDeliveryScanningEnabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "enabled" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_disabled(self): 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 index a720df1c43..17b0bf7624 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -35,7 +34,7 @@ class TestGmailExternalImageScanningEnabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "enabled" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_disabled(self): 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 index 704fc8d2bb..b06092ebe5 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -39,7 +38,7 @@ class TestGmailGroupsSpoofingProtectionEnabled: 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 == DOMAIN + assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID def test_pass_private_groups_only(self): 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 index bae9d42b49..b319b8fdb1 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -38,7 +37,7 @@ class TestGmailInboundDomainSpoofingProtectionEnabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "QUARANTINE" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_no_action(self): 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 index f42062cc7e..e471d030ff 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -35,7 +34,7 @@ class TestGmailMailDelegationDisabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "disabled" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_delegation_enabled(self): 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 index 12c1267a07..2fe105df42 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -35,7 +34,7 @@ class TestGmailPerUserOutboundGatewayDisabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "disabled" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_disabled(self): 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 index 58c939b4f6..d5f87ac2c8 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -37,7 +36,7 @@ class TestGmailPopImapAccessDisabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "disabled" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_both_enabled(self): 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 index 25333e195b..9f04178457 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -38,7 +37,7 @@ class TestGmailScriptAttachmentProtectionEnabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "QUARANTINE" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_no_action(self): 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 index adfde86db6..11fbe29098 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -35,7 +34,7 @@ class TestGmailShortenerScanningEnabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "enabled" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_disabled(self): 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 index 90d9beefcc..3a8fd0d337 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -38,7 +37,7 @@ class TestGmailUnauthenticatedEmailProtectionEnabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "WARNING" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_no_action(self): 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 index 9146f699c2..70fc539836 100644 --- 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 @@ -3,7 +3,6 @@ from unittest.mock import patch from prowler.providers.googleworkspace.services.gmail.gmail_service import GmailPolicies from tests.providers.googleworkspace.googleworkspace_fixtures import ( CUSTOMER_ID, - DOMAIN, set_mocked_googleworkspace_provider, ) @@ -37,7 +36,7 @@ class TestGmailUntrustedLinkWarningsEnabled: assert len(findings) == 1 assert findings[0].status == "PASS" assert "enabled" in findings[0].status_extended - assert findings[0].resource_name == DOMAIN + assert findings[0].resource_name == "Gmail Policies" assert findings[0].customer_id == CUSTOMER_ID def test_fail_disabled(self): 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/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_service_test.py b/tests/providers/m365/services/admincenter/admincenter_service_test.py index ce0a2a3050..4eedba05e1 100644 --- a/tests/providers/m365/services/admincenter/admincenter_service_test.py +++ b/tests/providers/m365/services/admincenter/admincenter_service_test.py @@ -252,3 +252,49 @@ def test_admincenter__get_groups_maps_group_types(): 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_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_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 8bf0c88f77..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 @@ -326,6 +327,133 @@ class Test_entra_users_mfa_capable: 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 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 f2ee0b2c84..4643ea93cc 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -411,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", @@ -535,6 +536,7 @@ class Test_Entra_Service: "userType", "accountEnabled", "onPremisesSyncEnabled", + "employeeHireDate", } with_url_mock.assert_called_once_with("next-link") assert users["user-1"].directory_roles_ids == ["role-template-1"] @@ -548,6 +550,9 @@ class Test_Entra_Service: 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. 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_service_test.py b/tests/providers/m365/services/exchange/exchange_service_test.py index 8eed77ca99..8d812e3bb3 100644 --- a/tests/providers/m365/services/exchange/exchange_service_test.py +++ b/tests/providers/m365/services/exchange/exchange_service_test.py @@ -495,6 +495,104 @@ class Test_Exchange_Service: 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( 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/oraclecloud/oraclecloud_provider_test.py b/tests/providers/oraclecloud/oraclecloud_provider_test.py index 8f162ba232..7c437a35ac 100644 --- a/tests/providers/oraclecloud/oraclecloud_provider_test.py +++ b/tests/providers/oraclecloud/oraclecloud_provider_test.py @@ -255,3 +255,61 @@ class TestOraclecloudProviderInit: 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/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/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..8bcbe17f84 --- /dev/null +++ b/tests/providers/stackit/stackit_provider_test.py @@ -0,0 +1,413 @@ +"""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 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/.husky/pre-commit b/ui/.husky/pre-commit deleted file mode 100755 index 0755cd1872..0000000000 --- a/ui/.husky/pre-commit +++ /dev/null @@ -1,237 +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 - -# The Python pre-commit framework (see .pre-commit-config.yaml, hook "ui-checks") -# exports GIT_WORK_TREE, GIT_DIR, and GIT_INDEX_FILE pointing to its temp staging -# area. Unset them so git commands below resolve against the real repo and index. -# See: https://github.com/prowler-cloud/prowler/pull/10574 -unset GIT_WORK_TREE GIT_DIR GIT_INDEX_FILE GIT_PREFIX GIT_COMMON_DIR GIT_OBJECT_DIRECTORY - -# 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 index c92ea6cfd3..eab333ceea 100644 --- a/ui/.pre-commit-config.yaml +++ b/ui/.pre-commit-config.yaml @@ -3,21 +3,30 @@ orphan: true 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: 0 - - - id: ui-lint - name: UI - ESLint - entry: pnpm run lint:check - language: system - files: '\.(ts|tsx|js|jsx)$' - pass_filenames: false - priority: 0 + priority: 10 - id: ui-tests name: UI - Unit Tests @@ -26,12 +35,4 @@ repos: files: '\.(ts|tsx|js|jsx)$' exclude: '\.test\.|\.spec\.|vitest\.config|vitest\.setup' pass_filenames: true - priority: 1 - - - id: ui-build - name: UI - Build - entry: pnpm run build - language: system - files: '\.(ts|tsx|js|jsx|json|css)$' - pass_filenames: false - priority: 2 + priority: 10 diff --git a/ui/.prettierignore b/ui/.prettierignore index 40b878db5b..5377d96f2d 100644 --- a/ui/.prettierignore +++ b/ui/.prettierignore @@ -1 +1,19 @@ -node_modules/ \ No newline at end of file +# Dependencies +node_modules/ + +# Build outputs +.next/ +.now/ +build/ +coverage/ +dist/ +esm/ + +# 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 b0d131f10e..38af45860f 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -1,6 +1,7 @@ # 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 @@ -14,39 +15,39 @@ > - [`vitest`](../skills/vitest/SKILL.md) - Unit testing with React Testing Library > - [`tdd`](../skills/tdd/SKILL.md) - TDD workflow (MANDATORY for UI tasks) -### 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-16` | -| 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` | +| App Router / Server Actions | `nextjs-16` | +| 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` | --- @@ -88,7 +89,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 +97,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 +138,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 +164,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 }) => { @@ -188,7 +193,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 +225,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 9c50fcb00c..49e6ddf9f0 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,12 +2,115 @@ All notable changes to the **Prowler UI** are documented in this file. -## [1.27.0] (Prowler UNRELEASED) +## [1.30.0] (Prowler UNRELEASED) + +### 🚀 Added + +- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131) ### 🔄 Changed -- Trimmed unused npm dependencies [(#11115)](https://github.com/prowler-cloud/prowler/pull/11115) +- 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) + +--- + +## [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) + +### 🔐 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) --- @@ -18,7 +121,6 @@ All notable changes to the **Prowler UI** are documented in this file. - 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) diff --git a/ui/Dockerfile b/ui/Dockerfile index 9163be77cc..047df1171b 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -1,3 +1,4 @@ +# Keep in sync with ui/.nvmrc. FROM node:24.13.0-alpine@sha256:cd6fb7efa6490f039f3471a189214d5f548c11df1ff9e5b181aa49e22c14383e AS base LABEL maintainer="https://github.com/prowler-cloud" @@ -13,7 +14,7 @@ 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 RUN corepack install && pnpm install --frozen-lockfile diff --git a/ui/README.md b/ui/README.md index b1af73ffe3..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 @@ -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 -``` - -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, verify prek is installed and hooks are set up: - -```bash -# Check prek is available -prek --version - -# Re-install hooks if needed prek install --overwrite ``` + +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__/msw/handlers/attack-paths.ts b/ui/__tests__/msw/handlers/attack-paths.ts index b17c6c0596..39076666dd 100644 --- a/ui/__tests__/msw/handlers/attack-paths.ts +++ b/ui/__tests__/msw/handlers/attack-paths.ts @@ -10,7 +10,7 @@ import type { QueryResultAttributes, } from "@/types/attack-paths"; -const API = process.env.NEXT_PUBLIC_API_BASE_URL!; +const API = process.env.NEXT_PUBLIC_API_BASE_URL; type JsonApiErrorBody = { errors: Array<{ detail: string; status: string }>; 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 ca6fd2d099..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; diff --git a/ui/actions/findings/findings-by-resource.adapter.test.ts b/ui/actions/findings/findings-by-resource.adapter.test.ts index 87d37c192c..a94f34a9c4 100644 --- a/ui/actions/findings/findings-by-resource.adapter.test.ts +++ b/ui/actions/findings/findings-by-resource.adapter.test.ts @@ -116,6 +116,84 @@ describe("adaptFindingsByResourceResponse — malformed input", () => { 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 = { diff --git a/ui/actions/findings/findings-by-resource.adapter.ts b/ui/actions/findings/findings-by-resource.adapter.ts index d1685eaa9f..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; @@ -260,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/providers/providers.test.ts b/ui/actions/providers/providers.test.ts index ab3037db8b..4d56e45b4b 100644 --- a/ui/actions/providers/providers.test.ts +++ b/ui/actions/providers/providers.test.ts @@ -29,7 +29,7 @@ vi.mock("@/lib", () => ({ wait: vi.fn(), })); -vi.mock("@/lib/provider-credentials/build-crendentials", () => ({ +vi.mock("@/lib/provider-credentials/build-credentials", () => ({ buildSecretConfig: vi.fn(() => ({ secretType: "access-secret-key", secret: { key: "value" }, 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/scans/scans.ts b/ui/actions/scans/scans.ts index 08b0e8b451..318004ea96 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -1,5 +1,6 @@ "use server"; +import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib"; @@ -140,6 +141,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) { @@ -392,6 +394,27 @@ export const getComplianceCsv = async (scanId: string, complianceId: string) => "compliance report", ); +/** + * 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. * 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/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 index f334053b48..0ed6346de7 100644 --- a/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx +++ b/ui/app/(auth)/invitation/accept/accept-invitation-client.tsx @@ -3,53 +3,21 @@ import { Icon } from "@iconify/react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { signOut } from "next-auth/react"; 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; needsSignOut: boolean } + | { kind: "error"; message: string; canRetry: boolean } | { kind: "choose" }; -function mapApiError(status: number | undefined): { - message: string; - canRetry: boolean; - needsSignOut: boolean; -} { - switch (status) { - case 410: - return { - message: - "This invitation has expired. Please contact your administrator for a new one.", - canRetry: false, - needsSignOut: false, - }; - case 400: - return { - message: "This invitation has already been used.", - canRetry: false, - needsSignOut: false, - }; - case 404: - return { - message: - "This invitation was sent to a different email address. Please sign in with the correct account.", - canRetry: false, - needsSignOut: true, - }; - default: - return { - message: "Something went wrong while accepting the invitation.", - canRetry: true, - needsSignOut: false, - }; - } -} - export function AcceptInvitationClient({ isAuthenticated, token, @@ -72,20 +40,16 @@ export function AcceptInvitationClient({ const result = await acceptInvitation(token); if (result?.error) { - const { message, canRetry, needsSignOut } = mapApiError(result.status); - setState({ kind: "error", message, canRetry, needsSignOut }); + const { message, canRetry } = getInvitationErrorDisplay( + result, + INVITATION_ERROR_FLOW.ACCEPT, + ); + setState({ kind: "error", message, canRetry }); } else { router.push("/"); } } - async function handleSignOutAndRedirect() { - if (!token) return; - const callbackPath = `/invitation/accept?invitation_token=${encodeURIComponent(token)}`; - await signOut({ redirect: false }); - router.push(`/sign-in?callbackUrl=${encodeURIComponent(callbackPath)}`); - } - useEffect(() => { if (hasStartedRef.current) return; hasStartedRef.current = true; @@ -153,15 +117,9 @@ export function AcceptInvitationClient({

    {state.message}

    {state.canRetry && } - {state.needsSignOut ? ( - - ) : ( - - )} +
    )} diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx index b7a4b3a5ae..19c63cf3d2 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx @@ -1,9 +1,11 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { AccountsSelector } from "./accounts-selector"; const multiSelectContentSpy = vi.fn(); +const multiSelectSpy = vi.fn(); vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(), @@ -31,14 +33,31 @@ vi.mock("@/components/icons/providers-badge", () => ({ OracleCloudProviderBadge: () => Oracle Cloud, AlibabaCloudProviderBadge: () => Alibaba Cloud, VercelProviderBadge: () => Vercel, + OktaProviderBadge: () => Okta, })); vi.mock("@/components/shadcn/select/multiselect", () => ({ - MultiSelect: ({ children }: { children: React.ReactNode }) => ( -
    {children}
    - ), + MultiSelect: ({ + children, + open, + onOpenChange, + }: { + children: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; + }) => { + multiSelectSpy({ open }); + return ( +
    + + {children} +
    + ); + }, MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => ( -
    {children}
    +
    {children}
    ), MultiSelectValue: ({ placeholder }: { placeholder: string }) => ( {placeholder} @@ -55,16 +74,26 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({ }, MultiSelectItem: ({ children, + disabled, value, keywords, + onSelect, }: { children: React.ReactNode; + disabled?: boolean; value: string; keywords?: string[]; + onSelect?: (value: string) => void; }) => ( -
    +
    + ), })); @@ -113,8 +142,8 @@ describe("AccountsSelector", () => { render(); expect(multiSelectContentSpy).toHaveBeenCalledWith({ - placeholder: "Search accounts...", - emptyMessage: "No accounts found.", + placeholder: "Search Providers...", + emptyMessage: "No Providers found.", }); expect(screen.getByText("Production AWS")).toBeInTheDocument(); }); @@ -139,12 +168,97 @@ describe("AccountsSelector", () => { ).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 accounts/i }), + 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 e76700013b..0c389febb7 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -1,25 +1,13 @@ "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, - VercelProviderBadge, -} from "@/components/icons/providers-badge"; + ProviderTypeIcon, + ProviderTypeIconStack, +} from "@/components/icons/providers-badge/provider-type-icon"; +import { Badge } from "@/components/shadcn"; import { MultiSelect, MultiSelectContent, @@ -35,34 +23,22 @@ import { type ProviderType, } from "@/types/providers"; -const PROVIDER_ICON: Record = { - aws: , - azure: , - gcp: , - kubernetes: , - m365: , - github: , - googleworkspace: , - iac: , - image: , - oraclecloud: , - mongodbatlas: , - alibabacloud: , - cloudflare: , - openstack: , - vercel: , -}; +const ACCOUNT_SELECTOR_FILTER = { + PROVIDER_ID: "provider_id__in", + PROVIDER_UID: "provider_uid__in", +} as const; + +type AccountSelectorFilter = + (typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER]; /** Common props shared by both batch and instant modes. */ interface AccountsSelectorBaseProps { providers: ProviderProps[]; search?: MultiSelectSearchProp; - /** - * 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[]; + filterKey?: AccountSelectorFilter; + id?: string; + disabledValues?: string[]; + closeOnSelect?: boolean; } /** Batch mode: caller controls both pending state and notification callback (all-or-nothing). */ @@ -96,74 +72,105 @@ export function AccountsSelector({ providers, onBatchChange, selectedValues, - selectedProviderTypes, + filterKey = ACCOUNT_SELECTOR_FILTER.PROVIDER_ID, + id = "accounts-selector", + disabledValues = [], search = { - placeholder: "Search accounts...", - emptyMessage: "No accounts found.", + placeholder: "Search Providers...", + emptyMessage: "No Providers found.", }, + closeOnSelect = false, }: 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 === ACCOUNT_SELECTOR_FILTER.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 provider accounts"; - return (
    -
    {visibleProviders.map((p) => { - const id = p.id; + const value = getProviderValue(p); + const isDisabled = disabledValuesSet.has(value); const displayName = p.attributes.alias || p.attributes.uid; const providerType = p.attributes.provider as ProviderType; - const icon = PROVIDER_ICON[providerType]; const searchKeywords = [ displayName, p.attributes.alias, @@ -203,23 +210,30 @@ export function AccountsSelector({ ].filter(Boolean); return ( { + if (closeOnSelect) setSelectorOpen(false); + }} > - - {displayName} + + + {displayName} + {isDisabled && Disconnected} + ); })} ) : (
    - {selectedProviderTypes && selectedProviderTypes.length > 0 - ? `No accounts available for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}` - : "No connected accounts available"} + No connected Providers available
    )} diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx index 8307821992..b2c05b336d 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ProviderTypeSelector } from "./provider-type-selector"; @@ -31,6 +31,7 @@ vi.mock("@/components/icons/providers-badge", () => ({ CloudflareProviderBadge: () => Cloudflare, OpenStackProviderBadge: () => OpenStack, VercelProviderBadge: () => Vercel, + OktaProviderBadge: () => Okta, })); vi.mock("@/components/shadcn/select/multiselect", () => ({ @@ -38,7 +39,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
    {children}
    ), MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => ( -
    {children}
    +
    {children}
    ), MultiSelectValue: ({ placeholder }: { placeholder: string }) => ( {placeholder} @@ -113,8 +114,8 @@ describe("ProviderTypeSelector", () => { render(); expect(multiSelectContentSpy).toHaveBeenCalledWith({ - placeholder: "Search providers...", - emptyMessage: "No providers found.", + placeholder: "Search Provider Types...", + emptyMessage: "No Provider Types found.", }); expect(screen.getByText("Amazon Web Services")).toBeInTheDocument(); }); @@ -140,8 +141,30 @@ describe("ProviderTypeSelector", () => { render(); expect( - screen.getByRole("option", { name: /select all providers/i }), + screen.getByRole("option", { name: /select all Provider Types/i }), ).toHaveAttribute("aria-disabled", "true"); expect(screen.getByText("All selected")).toBeInTheDocument(); }); + + it("shows one icon per selected type and a count in the trigger", async () => { + const azure = { + ...providers[0], + id: "provider-2", + attributes: { ...providers[0].attributes, provider: "azure" as const }, + }; + + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + expect(await within(trigger).findByText("AWS")).toBeInTheDocument(); + expect( + within(trigger).getByText("2 Provider Types selected"), + ).toBeInTheDocument(); + }); }); diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx index f71245af30..e78412eeeb 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -1,8 +1,12 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { type ComponentType, lazy, Suspense } from "react"; +import { + PROVIDER_TYPE_DATA, + ProviderTypeIcon, + ProviderTypeIconStack, +} from "@/components/icons/providers-badge/provider-type-icon"; import { MultiSelect, MultiSelectContent, @@ -14,154 +18,6 @@ import { import { useUrlFilters } from "@/hooks/use-url-filters"; import { type ProviderProps, ProviderType } from "@/types/providers"; -const AWSProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.AWSProviderBadge, - })), -); -const AzureProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.AzureProviderBadge, - })), -); -const GCPProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.GCPProviderBadge, - })), -); -const KS8ProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.KS8ProviderBadge, - })), -); -const M365ProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.M365ProviderBadge, - })), -); -const GitHubProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.GitHubProviderBadge, - })), -); -const IacProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.IacProviderBadge, - })), -); -const ImageProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.ImageProviderBadge, - })), -); -const OracleCloudProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.OracleCloudProviderBadge, - })), -); -const MongoDBAtlasProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.MongoDBAtlasProviderBadge, - })), -); -const AlibabaCloudProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.AlibabaCloudProviderBadge, - })), -); -const CloudflareProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.CloudflareProviderBadge, - })), -); -const OpenStackProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.OpenStackProviderBadge, - })), -); -const GoogleWorkspaceProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.GoogleWorkspaceProviderBadge, - })), -); -const VercelProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.VercelProviderBadge, - })), -); - -type IconProps = { width: number; height: number }; - -const IconPlaceholder = ({ width, height }: IconProps) => ( -
    -); - -const PROVIDER_DATA: Record< - ProviderType, - { label: string; icon: ComponentType } -> = { - aws: { - label: "Amazon Web Services", - icon: AWSProviderBadge, - }, - azure: { - label: "Microsoft Azure", - icon: AzureProviderBadge, - }, - gcp: { - label: "Google Cloud Platform", - icon: GCPProviderBadge, - }, - kubernetes: { - label: "Kubernetes", - icon: KS8ProviderBadge, - }, - m365: { - label: "Microsoft 365", - icon: M365ProviderBadge, - }, - github: { - label: "GitHub", - icon: GitHubProviderBadge, - }, - googleworkspace: { - label: "Google Workspace", - icon: GoogleWorkspaceProviderBadge, - }, - iac: { - label: "Infrastructure as Code", - icon: IacProviderBadge, - }, - image: { - label: "Container Registry", - icon: ImageProviderBadge, - }, - oraclecloud: { - label: "Oracle Cloud Infrastructure", - icon: OracleCloudProviderBadge, - }, - mongodbatlas: { - label: "MongoDB Atlas", - icon: MongoDBAtlasProviderBadge, - }, - alibabacloud: { - label: "Alibaba Cloud", - icon: AlibabaCloudProviderBadge, - }, - cloudflare: { - label: "Cloudflare", - icon: CloudflareProviderBadge, - }, - openstack: { - label: "OpenStack", - icon: OpenStackProviderBadge, - }, - vercel: { - label: "Vercel", - icon: VercelProviderBadge, - }, -}; - /** Common props shared by both batch and instant modes. */ interface ProviderTypeSelectorBaseProps { providers: ProviderProps[]; @@ -201,8 +57,8 @@ export const ProviderTypeSelector = ({ onBatchChange, selectedValues, search = { - placeholder: "Search providers...", - emptyMessage: "No providers found.", + placeholder: "Search Provider Types...", + emptyMessage: "No Provider Types found.", }, }: ProviderTypeSelectorProps) => { const searchParams = useSearchParams(); @@ -238,34 +94,38 @@ export const ProviderTypeSelector = ({ .map((p) => p.attributes.provider), ), ) - .filter((type): type is ProviderType => type in PROVIDER_DATA) + .filter((type): type is ProviderType => type in PROVIDER_TYPE_DATA) .sort((a, b) => - PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label), + PROVIDER_TYPE_DATA[a].label.localeCompare(PROVIDER_TYPE_DATA[b].label), ); - const renderIcon = (providerType: ProviderType) => { - const IconComponent = PROVIDER_DATA[providerType].icon; - return ( - }> - - - ); - }; - const selectedLabel = () => { if (selectedTypes.length === 0) return null; if (selectedTypes.length === 1) { const providerType = selectedTypes[0] as ProviderType; return ( - {renderIcon(providerType)} - {PROVIDER_DATA[providerType].label} + + + {PROVIDER_TYPE_DATA[providerType].label} + ); } return ( - - {selectedTypes.length} providers selected + + ({ + key: type, + type, + tooltip: PROVIDER_TYPE_DATA[type].label, + }))} + /> + + {selectedTypes.length} Provider Types selected + ); }; @@ -277,7 +137,8 @@ export const ProviderTypeSelector = ({ className="sr-only" id="provider-type-label" > - Filter by provider type. Select one or more providers to view findings. + Filter by Provider Type. Select one or more Provider Types to view + findings. - {selectedLabel() || } + {selectedLabel() || ( + + )} {availableTypes.length > 0 ? ( @@ -296,7 +159,7 @@ export const ProviderTypeSelector = ({ role="option" aria-selected={selectedTypes.length === 0} aria-disabled={selectedTypes.length === 0} - aria-label="Select all providers (clears current selection to show all)" + aria-label="Select all Provider Types (clears current selection to show all)" tabIndex={0} className="text-text-neutral-secondary flex w-full cursor-pointer items-center gap-3 rounded-lg px-4 py-3 text-sm font-semibold hover:bg-slate-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:hover:bg-slate-700/50" onClick={() => { @@ -317,18 +180,23 @@ export const ProviderTypeSelector = ({ - - {PROVIDER_DATA[providerType].label} + + {PROVIDER_TYPE_DATA[providerType].label} ))} ) : (
    - No connected providers available + No connected Provider Types available
    )}
    diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-pipeline-view/risk-pipeline-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-pipeline-view/risk-pipeline-view.ssr.tsx index 509c92fa2c..b8479432e6 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/risk-pipeline-view/risk-pipeline-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-pipeline-view/risk-pipeline-view.ssr.tsx @@ -3,7 +3,7 @@ import { getFindingsBySeverity, SeverityByProviderType, } from "@/actions/overview"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; import { SankeyChart } from "@/components/graphs/sankey-chart"; import { SearchParamsProps } from "@/types"; @@ -20,7 +20,7 @@ export async function RiskPipelineViewSSR({ const providerIdFilter = filters["filter[provider_id__in]"]; // Fetch providers list to know account types - const providersListResponse = await getProviders({ pageSize: 200 }); + const providersListResponse = await getAllProviders(); const allProviders = providersListResponse?.data || []; // Build severityByProviderType based on filters diff --git a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx index ee69f0fea4..887eb7a5d5 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/risk-plot/risk-plot.ssr.tsx @@ -4,7 +4,7 @@ import { adaptToRiskPlotData, getProvidersRiskData, } from "@/actions/overview/risk-plot"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; import { SearchParamsProps } from "@/types"; import { pickFilterParams } from "../../_lib/filter-params"; @@ -21,7 +21,7 @@ export async function RiskPlotSSR({ const providerIdFilter = filters["filter[provider_id__in]"]; // Fetch all providers - const providersListResponse = await getProviders({ pageSize: 200 }); + const providersListResponse = await getAllProviders(); const allProviders = providersListResponse?.data || []; // Filter providers based on search params diff --git a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx index c3f37cfd45..be336be2a7 100644 --- a/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx +++ b/ui/app/(prowler)/alerts/_components/__tests__/alert-form-modal.test.tsx @@ -518,7 +518,7 @@ describe("AlertFormModal", () => { expect(alertEditGrid).toHaveClass("xl:grid-cols-3", "2xl:grid-cols-3"); expect(alertEditGrid).not.toHaveClass("xl:grid-cols-4", "2xl:grid-cols-5"); expect(screen.getAllByText("Amazon Web Services")[0]).toBeVisible(); - expect(screen.getByText("All accounts")).toBeVisible(); + expect(screen.getByText("All Providers")).toBeVisible(); expect(within(filterControls).getByText("All Delta")).toBeVisible(); expect(within(filterControls).getByText("All Resource Type")).toBeVisible(); expect( @@ -547,7 +547,9 @@ describe("AlertFormModal", () => { }); // When - await user.click(screen.getByLabelText(/provider type/i)); + await user.click( + screen.getByRole("combobox", { name: /filter by Provider Type/i }), + ); const providerOptions = await screen.findAllByText("Google Cloud Platform"); const visibleProviderOption = providerOptions.at(-1); expect(visibleProviderOption).toBeDefined(); @@ -597,7 +599,9 @@ describe("AlertFormModal", () => { }); // When - await user.click(screen.getByLabelText(/provider type/i)); + await user.click( + screen.getByRole("combobox", { name: /filter by Provider Type/i }), + ); const providerOptions = await screen.findAllByText("Google Cloud Platform"); const visibleProviderOption = providerOptions.at(-1); expect(visibleProviderOption).toBeDefined(); diff --git a/ui/app/(prowler)/alerts/page.tsx b/ui/app/(prowler)/alerts/page.tsx index f9c5ea5f69..28f238ef01 100644 --- a/ui/app/(prowler)/alerts/page.tsx +++ b/ui/app/(prowler)/alerts/page.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; import { getLatestMetadataInfo } from "@/actions/findings"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; import { getAlert, listAlerts } from "@/app/(prowler)/alerts/_actions"; import { AlertsManager } from "@/app/(prowler)/alerts/_components/alerts-manager"; @@ -58,7 +58,7 @@ export default async function AlertsPage({ searchParams }: AlertsPageProps) { const [result, providersData, scansData, metadataInfoData, editResult] = await Promise.all([ listAlerts(toAlertsSearchParams(resolvedSearchParams)), - getProviders({ pageSize: 50 }), + getAllProviders(), getScans({ pageSize: 50 }), getLatestMetadataInfo({}), editAlertId ? getAlert(editAlertId) : Promise.resolve(null), diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx index 74a1de7e5d..d29a262ee2 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { type NodeProps, Position } from "@xyflow/react"; import { describe, expect, it, vi } from "vitest"; @@ -91,5 +92,26 @@ describe("FindingNode", () => { expect(screen.getByText("logging")).toBeInTheDocument(); expect(screen.getByText("medium")).toBeInTheDocument(); }); + + it("should expose the full finding title as an immediate tooltip when truncated", async () => { + // Given + const title = + "Ensure administrator access policies are rotated regularly"; + const props = buildNodeProps(buildFindingNode("high", title)); + + // When + render(); + + // Then + expect(screen.getByText("Ensure")).toBeInTheDocument(); + expect(screen.getByText("administrator")).toBeInTheDocument(); + expect(screen.getByText("access policies")).toBeInTheDocument(); + expect(screen.getByText("are rotated…")).toBeInTheDocument(); + expect(screen.getByText("high")).toBeInTheDocument(); + + await userEvent.hover(screen.getByTestId("attack-path-finding-node")); + + expect(await screen.findAllByText(title)).not.toHaveLength(0); + }); }); }); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx index 986617def7..789a379551 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx @@ -2,21 +2,27 @@ import { type NodeProps, Position } from "@xyflow/react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; import type { GraphNode } from "@/types/attack-paths"; import { resolveNodeColors, resolveNodeVisual } from "../../../_lib"; +import { FINDING_NODE_DIMENSIONS } from "../../../_lib/node-dimensions"; +import { getNodeLabelDisplay } from "../../../_lib/node-label-lines"; import { HiddenHandles } from "./hidden-handles"; -import { getNodeLabelLines } from "./node-label-lines"; interface FindingNodeData { graphNode: GraphNode; [key: string]: unknown; } -const NODE_WIDTH = 150; -const NODE_HEIGHT = 112; -const TITLE_MAX_CHARS = 18; -const TITLE_MAX_LINES = 2; +const NODE_WIDTH = FINDING_NODE_DIMENSIONS.WIDTH; +const NODE_HEIGHT = FINDING_NODE_DIMENSIONS.HEIGHT; +const TITLE_MAX_CHARS = FINDING_NODE_DIMENSIONS.LABEL_MAX_CHARS; +const TITLE_MAX_LINES = FINDING_NODE_DIMENSIONS.LABEL_MAX_LINES; const BADGE_SIZE = 44; const BADGE_RADIUS = BADGE_SIZE / 2; const BADGE_CENTER_X = NODE_WIDTH / 2; @@ -29,7 +35,7 @@ const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2; const TEXT_X = BADGE_CENTER_X; const TITLE_Y = 66; const TITLE_LINE_HEIGHT = 13; -const SEVERITY_Y = 94; +const SEVERITY_Y = 118; const severityLabel = (severity: unknown): string | undefined => { if (!severity) return undefined; @@ -59,7 +65,7 @@ export const FindingNode = ({ data, selected }: NodeProps) => { graphNode.properties?.id || "Finding", ); - const displayTitleLines = getNodeLabelLines( + const displayTitle = getNodeLabelDisplay( title, TITLE_MAX_CHARS, TITLE_MAX_LINES, @@ -72,6 +78,85 @@ export const FindingNode = ({ data, selected }: NodeProps) => { const badgeStrokeWidth = selected ? 4 : 2.5; const glowRadius = selected ? 32 : 30; const glowOpacity = selected ? 0.34 : 0.28; + const nodeSvg = ( + + + + + + + + {displayTitle.lines.map((line, index) => ( + + {line} + + ))} + {severity && ( + + {severity} + + )} + + + ); return ( <> @@ -81,77 +166,14 @@ export const FindingNode = ({ data, selected }: NodeProps) => { targetPosition={Position.Left} targetStyle={{ left: BADGE_LEFT_X, top: BADGE_CENTER_Y }} /> - - - - - - - - {displayTitleLines.map((line, index) => ( - - {line} - - ))} - {severity && ( - - {severity} - - )} - - + {displayTitle.isTruncated ? ( + + {nodeSvg} + {title} + + ) : ( + nodeSvg + )} ); }; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/node-label-lines.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/node-label-lines.ts deleted file mode 100644 index a6ed74e70a..0000000000 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/node-label-lines.ts +++ /dev/null @@ -1,48 +0,0 @@ -const splitByMaxChars = (text: string, maxChars: number): string[] => { - const words = text.trim().split(/\s+/).filter(Boolean); - const lines: string[] = []; - let currentLine = ""; - - for (const word of words) { - if (!currentLine) { - currentLine = word; - continue; - } - - const nextLine = `${currentLine} ${word}`; - if (nextLine.length <= maxChars) { - currentLine = nextLine; - continue; - } - - lines.push(currentLine); - currentLine = word; - } - - if (currentLine) lines.push(currentLine); - return lines; -}; - -const splitLongToken = (text: string, maxChars: number): string[] => { - const lines: string[] = []; - - for (let index = 0; index < text.length; index += maxChars) { - lines.push(text.slice(index, index + maxChars)); - } - - return lines; -}; - -export const getNodeLabelLines = ( - text: string, - maxChars: number, - maxLines: number, -): string[] => { - if (!text.trim()) return []; - - const rawLines = text.includes(" ") - ? splitByMaxChars(text, maxChars) - : splitLongToken(text, maxChars); - - return rawLines.slice(0, maxLines); -}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx index 937e6cb2ac..4cdf1a46b3 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { type NodeProps, Position } from "@xyflow/react"; import { describe, expect, it, vi } from "vitest"; @@ -84,5 +85,41 @@ describe("ResourceNode", () => { expect(screen.getByText("main-vpc")).toBeInTheDocument(); expect(screen.getByText("VPC")).toBeInTheDocument(); }); + + it("should show up to four readable lines for long resource names", () => { + // Given + const props = buildNodeProps( + buildGraphNode("AWSRole", "AWSReservedSSO_AdministratorAccessExtra"), + ); + + // When + const { container } = render(); + + // Then + expect(screen.getByText("AWSReservedSSO_A")).toBeInTheDocument(); + expect(screen.getByText("dministratorAcce")).toBeInTheDocument(); + expect(screen.getByText("ssExtra")).toBeInTheDocument(); + expect(screen.getByText("AWS Role")).toBeInTheDocument(); + expect(container.querySelector("title")).toBeNull(); + }); + + it("should expose the full resource name as an immediate tooltip when truncated", async () => { + // Given + const name = + "arn:aws:iam::998057895221:role/OrganizationAccountAccessRole/integration"; + const props = buildNodeProps(buildGraphNode("AWSRole", name)); + + // When + render(); + + // Then + expect(screen.getByText("arn:aws:iam::998")).toBeInTheDocument(); + expect(screen.getByText("057895221:role/O")).toBeInTheDocument(); + expect(screen.getByText("ntAccessRole/in…")).toBeInTheDocument(); + + await userEvent.hover(screen.getByTestId("attack-path-resource-node")); + + expect(await screen.findAllByText(name)).not.toHaveLength(0); + }); }); }); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx index 324d3aa4b9..9860dbea27 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx @@ -2,11 +2,17 @@ import { type NodeProps, Position } from "@xyflow/react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; import type { GraphNode } from "@/types/attack-paths"; import { resolveNodeColors, resolveNodeVisual } from "../../../_lib"; +import { RESOURCE_NODE_DIMENSIONS } from "../../../_lib/node-dimensions"; +import { getNodeLabelDisplay } from "../../../_lib/node-label-lines"; import { HiddenHandles } from "./hidden-handles"; -import { getNodeLabelLines } from "./node-label-lines"; interface ResourceNodeData { graphNode: GraphNode; @@ -14,10 +20,10 @@ interface ResourceNodeData { [key: string]: unknown; } -const NODE_WIDTH = 136; -const NODE_HEIGHT = 112; -const NAME_MAX_CHARS = 16; -const NAME_MAX_LINES = 2; +const NODE_WIDTH = RESOURCE_NODE_DIMENSIONS.WIDTH; +const NODE_HEIGHT = RESOURCE_NODE_DIMENSIONS.HEIGHT; +const NAME_MAX_CHARS = RESOURCE_NODE_DIMENSIONS.LABEL_MAX_CHARS; +const NAME_MAX_LINES = RESOURCE_NODE_DIMENSIONS.LABEL_MAX_LINES; const BADGE_SIZE = 44; const BADGE_RADIUS = BADGE_SIZE / 2; const BADGE_CENTER_X = NODE_WIDTH / 2; @@ -30,7 +36,7 @@ const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2; const TEXT_X = BADGE_CENTER_X; const NAME_Y = 66; const NAME_LINE_HEIGHT = 13; -const TYPE_Y = 94; +const TYPE_Y = 118; const toIconTestId = (description: string): string => `attack-path-node-icon-${description @@ -52,13 +58,90 @@ export const ResourceNode = ({ data, selected }: NodeProps) => { const visual = resolveNodeVisual(graphNode); const Icon = visual.Icon; - const displayNameLines = getNodeLabelLines( + const displayName = getNodeLabelDisplay( visual.displayName, NAME_MAX_CHARS, NAME_MAX_LINES, ); const typeLabel = visual.description; const iconLabel = `${visual.description} icon`; + const nodeSvg = ( + + {glowRadius > 0 && ( + + )} + + + + + + {displayName.lines.map((line, index) => ( + + {line} + + ))} + {typeLabel && ( + + {typeLabel} + + )} + + + ); return ( <> @@ -68,75 +151,14 @@ export const ResourceNode = ({ data, selected }: NodeProps) => { targetPosition={Position.Left} targetStyle={{ left: BADGE_LEFT_X, top: BADGE_CENTER_Y }} /> - - {glowRadius > 0 && ( - - )} - - - - - - {displayNameLines.map((line, index) => ( - - {line} - - ))} - {typeLabel && ( - - {typeLabel} - - )} - - + {displayName.isTruncated ? ( + + {nodeSvg} + {visual.displayName} + + ) : ( + nodeSvg + )} ); }; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts index 5f4131e695..0e1e3ff2f3 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts @@ -126,6 +126,50 @@ describe("exportGraphAsPNG", () => { expect(link?.href).toBe("data:image/png;base64,AAAA"); }); + it("renders exported long resource labels with the same wrapping as graph nodes", async () => { + const container = buildContainerWithViewport(); + const longLabelGraphData: AttackPathGraphData = { + nodes: [ + { + id: "role-1", + labels: ["AWSRole"], + properties: { name: "AWSReservedSSO_AdministratorAccessExtra" }, + }, + ], + }; + + await exportGraphAsPNG(container, bounds, "graph.png", longLabelGraphData); + + const context = vi.mocked(HTMLCanvasElement.prototype.getContext).mock + .results[0]?.value as CanvasRenderingContext2D; + const fillText = vi.mocked(context.fillText); + + expect(fillText).toHaveBeenCalledWith( + "AWSReservedSSO_A", + expect.any(Number), + expect.any(Number), + 136, + ); + expect(fillText).toHaveBeenCalledWith( + "dministratorAcce", + expect.any(Number), + expect.any(Number), + 136, + ); + expect(fillText).toHaveBeenCalledWith( + "ssExtra", + expect.any(Number), + expect.any(Number), + 136, + ); + expect(fillText).toHaveBeenCalledWith( + "AWS Role", + expect.any(Number), + expect.any(Number), + 136, + ); + }); + it("re-throws a generic export error when canvas is unavailable", async () => { const container = buildContainerWithViewport(); vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts index 71926ad8c3..a10e0d474e 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts @@ -17,6 +17,11 @@ import { GRAPH_EDGE_COLOR_DARK, } from "./graph-colors"; import { layoutWithDagre } from "./layout"; +import { + FINDING_NODE_DIMENSIONS, + RESOURCE_NODE_DIMENSIONS, +} from "./node-dimensions"; +import { getNodeLabelDisplay } from "./node-label-lines"; import { resolveNodeVisual } from "./node-visuals"; interface ExportGraphOptions { @@ -40,9 +45,7 @@ const BADGE_CENTER_Y = 26; const GLOW_RADIUS = 30; const LABEL_Y = 66; const LABEL_LINE_HEIGHT = 13; -const TYPE_Y = 94; -const RESOURCE_NAME_MAX_CHARS = 18; -const RESOURCE_NAME_MAX_LINES = 2; +const TYPE_Y = 118; const downloadBlob = (blob: Blob, filename: string) => { const url = URL.createObjectURL(blob); @@ -165,33 +168,6 @@ const getResourcesWithFindings = ( return resourcesWithFindings; }; -const getLabelLines = (label: string, maxChars: number, maxLines: number) => { - const words = label.split(/\s+/).filter(Boolean); - const lines: string[] = []; - let current = ""; - - words.forEach((word) => { - const next = current ? `${current} ${word}` : word; - if (next.length <= maxChars) { - current = next; - return; - } - if (current) lines.push(current); - current = word; - }); - - if (current) lines.push(current); - if (lines.length === 0) lines.push(label); - - const visibleLines = lines.slice(0, maxLines); - if (lines.length > maxLines && visibleLines.length > 0) { - const lastIndex = visibleLines.length - 1; - visibleLines[lastIndex] = truncateLabel(visibleLines[lastIndex], maxChars); - } - - return visibleLines; -}; - const getFittedLayout = (graphData: AttackPathGraphData) => { const { rfNodes, rfEdges } = layoutWithDagre( graphData.nodes, @@ -464,16 +440,21 @@ const drawNode = ( context.textAlign = "center"; context.textBaseline = "middle"; context.font = "600 11px sans-serif"; - getLabelLines( + const dimensions = isFinding + ? FINDING_NODE_DIMENSIONS + : RESOURCE_NODE_DIMENSIONS; + const labelMaxWidth = dimensions.WIDTH; + + getNodeLabelDisplay( visual.displayName, - RESOURCE_NAME_MAX_CHARS, - RESOURCE_NAME_MAX_LINES, - ).forEach((line, index) => { + dimensions.LABEL_MAX_CHARS, + dimensions.LABEL_MAX_LINES, + ).lines.forEach((line, index) => { context.fillText( line, center.x, center.y + (LABEL_Y - BADGE_CENTER_Y) + index * LABEL_LINE_HEIGHT, - 150, + labelMaxWidth, ); }); @@ -483,7 +464,7 @@ const drawNode = ( typeLabel, center.x, center.y + (TYPE_Y - BADGE_CENTER_Y), - 150, + labelMaxWidth, ); }; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts index eb972c37be..7d8cb20c96 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts @@ -40,12 +40,12 @@ describe("layoutWithDagre", () => { expect(byId.get("finding-1")).toMatchObject({ type: "finding", width: 150, - height: 112, + height: 124, }); expect(byId.get("resource-1")).toMatchObject({ type: "resource", width: 136, - height: 112, + height: 124, }); expect(byId.get("internet-1")).toMatchObject({ type: "internet", diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts index d3df3dfd46..cb67d11083 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts @@ -8,12 +8,11 @@ import { type Edge, type Node, Position } from "@xyflow/react"; import type { GraphEdge, GraphNode } from "@/types/attack-paths"; -// Node dimensions matching the rendered React Flow custom nodes. -const RESOURCE_NODE_WIDTH = 136; -const RESOURCE_NODE_HEIGHT = 112; -const FINDING_NODE_WIDTH = 150; -const FINDING_NODE_HEIGHT = 112; -const INTERNET_DIAMETER = 80; // NODE_HEIGHT * 0.8 * 2 +import { + FINDING_NODE_DIMENSIONS, + INTERNET_NODE_DIMENSIONS, + RESOURCE_NODE_DIMENSIONS, +} from "./node-dimensions"; // Container relationships that get reversed for proper hierarchy const CONTAINER_RELATIONS = new Set([ @@ -49,10 +48,19 @@ const getNodeDimensions = ( type: NodeType, ): { width: number; height: number } => { if (type === NODE_TYPE.FINDING) - return { width: FINDING_NODE_WIDTH, height: FINDING_NODE_HEIGHT }; + return { + width: FINDING_NODE_DIMENSIONS.WIDTH, + height: FINDING_NODE_DIMENSIONS.HEIGHT, + }; if (type === NODE_TYPE.INTERNET) - return { width: INTERNET_DIAMETER, height: INTERNET_DIAMETER }; - return { width: RESOURCE_NODE_WIDTH, height: RESOURCE_NODE_HEIGHT }; + return { + width: INTERNET_NODE_DIMENSIONS.DIAMETER, + height: INTERNET_NODE_DIMENSIONS.DIAMETER, + }; + return { + width: RESOURCE_NODE_DIMENSIONS.WIDTH, + height: RESOURCE_NODE_DIMENSIONS.HEIGHT, + }; }; /** diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-dimensions.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-dimensions.ts new file mode 100644 index 0000000000..e6dea8af9b --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-dimensions.ts @@ -0,0 +1,17 @@ +export const RESOURCE_NODE_DIMENSIONS = { + WIDTH: 136, + HEIGHT: 124, + LABEL_MAX_CHARS: 16, + LABEL_MAX_LINES: 4, +} as const; + +export const FINDING_NODE_DIMENSIONS = { + WIDTH: 150, + HEIGHT: 124, + LABEL_MAX_CHARS: 18, + LABEL_MAX_LINES: 4, +} as const; + +export const INTERNET_NODE_DIMENSIONS = { + DIAMETER: 80, +} as const; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.test.ts new file mode 100644 index 0000000000..89c8060d57 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { getNodeLabelDisplay } from "./node-label-lines"; + +describe("getNodeLabelDisplay", () => { + it("adds an ellipsis within the max width when wrapped label text exceeds the visible line budget", () => { + expect( + getNodeLabelDisplay("AWSReservedSSO_AdministratorAccess", 16, 2).lines, + ).toEqual(["AWSReservedSSO_A", "dministratorAcc…"]); + }); + + it("splits long tokens so unbroken identifiers do not overflow node labels", () => { + expect( + getNodeLabelDisplay("OrganizationAccountAccessRole", 16, 4).lines, + ).toEqual(["OrganizationAcco", "untAccessRole"]); + }); + + it("reports whether the visible label was truncated", () => { + expect(getNodeLabelDisplay("short-name", 16, 4)).toMatchObject({ + isTruncated: false, + lines: ["short-name"], + }); + expect( + getNodeLabelDisplay( + "arn:aws:iam::998057895221:role/OrganizationAccountAccessRole/integration", + 16, + 4, + ), + ).toMatchObject({ isTruncated: true }); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.ts new file mode 100644 index 0000000000..3bceddfe4c --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-label-lines.ts @@ -0,0 +1,64 @@ +const splitLongToken = (text: string, maxChars: number): string[] => { + const lines: string[] = []; + + for (let index = 0; index < text.length; index += maxChars) { + lines.push(text.slice(index, index + maxChars)); + } + + return lines; +}; + +const splitByMaxChars = (text: string, maxChars: number): string[] => { + const words = text.trim().split(/\s+/).filter(Boolean); + const lines: string[] = []; + let currentLine = ""; + + for (const word of words) { + const wordLines = splitLongToken(word, maxChars); + + for (const wordLine of wordLines) { + if (!currentLine) { + currentLine = wordLine; + continue; + } + + const nextLine = `${currentLine} ${wordLine}`; + if (nextLine.length <= maxChars) { + currentLine = nextLine; + continue; + } + + lines.push(currentLine); + currentLine = wordLine; + } + } + + if (currentLine) lines.push(currentLine); + return lines; +}; + +const withEllipsis = (line: string, maxChars: number): string => { + if (maxChars <= 1) return "…"; + return `${line.slice(0, maxChars - 1)}…`; +}; + +export const getNodeLabelDisplay = ( + text: string, + maxChars: number, + maxLines: number, +): { lines: string[]; isTruncated: boolean } => { + if (!text.trim()) return { lines: [], isTruncated: false }; + + const rawLines = splitByMaxChars(text, maxChars); + const isTruncated = rawLines.length > maxLines; + const visibleLines = rawLines.slice(0, maxLines); + + if (isTruncated && visibleLines.length > 0) { + visibleLines[visibleLines.length - 1] = withEllipsis( + visibleLines[visibleLines.length - 1], + maxChars, + ); + } + + return { lines: visibleLines, isTruncated }; +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts index 34c7b15450..fca0e4ae67 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts @@ -33,6 +33,7 @@ import { KS8ProviderBadge, M365ProviderBadge, MongoDBAtlasProviderBadge, + OktaProviderBadge, OpenStackProviderBadge, OracleCloudProviderBadge, VercelProviderBadge, @@ -175,6 +176,11 @@ const KNOWN_NODE_VISUALS = { description: "Vercel Account", Icon: VercelProviderBadge, }, + oktaaccount: { + category: NODE_CATEGORY.ACCOUNT, + description: "Okta Account", + Icon: OktaProviderBadge, + }, s3bucket: { category: NODE_CATEGORY.STORAGE, description: "S3 Bucket", diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx index 1d9ea021ca..9e455a4b6d 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx @@ -81,7 +81,7 @@ describe("running a query", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); expect(graph.background).toBeTruthy(); expect(graph.minimap).toBeTruthy(); @@ -91,7 +91,7 @@ describe("running a query", () => { test("nodes are laid out at distinct positions", async ({ mountWith }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); const positions = graph.nodePositions; expect(positions.some((p) => p.x !== 0 || p.y !== 0)).toBe(true); @@ -102,7 +102,7 @@ describe("running a query", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(1); + await graph.waitForGraphStable(1); expect(graph.toolbar.zoomInButton).toBeTruthy(); expect(graph.toolbar.zoomOutButton).toBeTruthy(); @@ -115,7 +115,7 @@ describe("running a query", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); expect(graph.findingNodes.length).toBeGreaterThan(0); @@ -128,7 +128,7 @@ describe("running a query", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); expect(graph.findingEdges.length).toBeGreaterThan(0); @@ -138,7 +138,7 @@ describe("running a query", () => { test("edges connect string source and target ids", async ({ mountWith }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(2); + await graph.waitForGraphStable(2, 1); const edgeIds = graph.renderedEdgeIds; expect(edgeIds.length).toBeGreaterThan(0); @@ -153,7 +153,7 @@ describe("running a query", () => { }) => { const graph = await mountWith(fixtures.singleNode()); await graph.executeQuery(); - await graph.waitForLayoutStable(1); + await graph.waitForGraphStable(1); expect(graph.nodes).toHaveLength(1); }); @@ -175,7 +175,7 @@ describe("running a query", () => { const graph = await mountWith(fixtures.large(200)); const start = performance.now(); await graph.executeQuery(); - await graph.waitForLayoutStable(1); + await graph.waitForGraphStable(1); const elapsed = performance.now() - start; expect(elapsed).toBeLessThan(5000); }); @@ -183,7 +183,7 @@ describe("running a query", () => { test("disconnected components are both visible", async ({ mountWith }) => { const graph = await mountWith(fixtures.disconnected()); await graph.executeQuery(); - await graph.waitForLayoutStable(4); + await graph.waitForGraphStable(4); expect(graph.nodes.length).toBe(4); }); @@ -192,7 +192,7 @@ describe("running a query", () => { }) => { const graph = await mountWith(fixtures.resourcesOnly()); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); expect(graph.findingNodes.length).toBe(0); expect(graph.resourceNodes.length).toBe(3); }); @@ -202,7 +202,7 @@ describe("running a query", () => { }) => { const graph = await mountWith(fixtures.findingsOnly()); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); expect(graph.findingNodes.length).toBe(3); expect(graph.resourceNodes.length).toBe(0); @@ -239,7 +239,7 @@ describe("running a query", () => { const graph = await mountWith(fixture); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); // Then - hidden findings do not influence initial resource coordinates. for (const node of visibleNodes) { @@ -254,7 +254,7 @@ describe("running a query", () => { }) => { const graph = await mountWith(fixtures.edgeCases()); await graph.executeQuery(); - await graph.waitForLayoutStable(5); + await graph.waitForGraphStable(5); expect(graph.nodes.length).toBe(7); expect(graph.containsText(/🔒-secure-bucket-日本語/)).toBe(true); @@ -267,7 +267,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); expect(graph.isInFilteredView).toBe(false); @@ -284,7 +284,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); expect(graph.findingNodes.length).toBe(0); expect(graph.hasNodeDetailsModal).toBe(false); @@ -301,7 +301,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.clickFirstResourceNode(); expect(graph.findingNodes.length).toBeGreaterThan(0); @@ -318,7 +318,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); const initialViewport = graph.viewportTransform; @@ -344,7 +344,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.clickFirstResourceNode(); expect(graph.findingNodes.length).toBeGreaterThan(0); @@ -366,7 +366,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(fixtures.large(20)); await graph.executeQuery(); - await graph.waitForLayoutStable(16); + await graph.waitForGraphStable(16); await graph.clickFirstResourceNode(); expect(graph.findingNodes.length).toBeGreaterThan(0); @@ -388,7 +388,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); expect(graph.hasNodeDetailsModal).toBe(false); expect(graph.hasNodeActionDialog).toBe(false); @@ -406,13 +406,13 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); const fullNodes = graph.nodes.length; await graph.clickFirstFindingNode(); await graph.exitFilteredView(); - await graph.waitForLayoutStable(fullNodes); + await graph.waitForGraphStable(fullNodes); expect(graph.isInFilteredView).toBe(false); }); @@ -420,7 +420,7 @@ describe("exploring the graph", () => { const fixture = fixtures.typical(); const graph = await mountWith(fixture); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); const hoveredNodeId = graph.resourceNodes[0]?.getAttribute("data-id"); expect(hoveredNodeId).toBeTruthy(); @@ -458,7 +458,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.clickFirstResourceNodeWithoutFindings(); @@ -470,7 +470,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.clickEmptyCanvas(); expect(graph.isInFilteredView).toBe(false); @@ -481,7 +481,7 @@ describe("exploring the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); await graph.rapidlyClickFirstFindingNode(2); @@ -493,7 +493,7 @@ describe("exploring the graph", () => { test("double-clicking a node doesn't break state", async ({ mountWith }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.dblClickFirstResourceNode(); expect(graph.nodes.length).toBeGreaterThan(0); @@ -506,7 +506,7 @@ describe("auto-fitting the viewport", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); expect(graph.minimapMaskStrokeWidth).toBeGreaterThan(0); }); @@ -516,7 +516,7 @@ describe("auto-fitting the viewport", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); // Given - zoom into the current overview so newly revealed findings can // sit entirely outside the current frame. The expand auto-fit should then @@ -543,7 +543,7 @@ describe("auto-fitting the viewport", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); const beforeFilter = graph.viewportTransform; @@ -561,7 +561,7 @@ describe("auto-fitting the viewport", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); await graph.clickFirstFindingNode(); expect(graph.isInFilteredView).toBe(true); @@ -569,7 +569,7 @@ describe("auto-fitting the viewport", () => { const filterT = graph.viewportTransform; await graph.exitFilteredView(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.waitForTransition(); expect(graph.viewportTransform).not.toBe(filterT); @@ -582,7 +582,7 @@ describe("exporting the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); expect(graph.toolbar.isExportButtonEnabled).toBe(true); }); @@ -592,7 +592,7 @@ describe("exporting the graph", () => { }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); const png = await graph.captureExportPNG(); @@ -608,13 +608,13 @@ describe("running a different query", () => { test("the previous filtered view is cleared", async ({ mountWith }) => { const graph = await mountWith(); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); await graph.expandAllFindings(); await graph.clickFirstFindingNode(); expect(graph.isInFilteredView).toBe(true); await graph.executeQuery(); - await graph.waitForLayoutStable(3); + await graph.waitForGraphStable(3); expect(graph.isInFilteredView).toBe(false); }); }); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts index 86ba2bf56b..dff9ccdb51 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts @@ -276,13 +276,28 @@ export class AttackPathPageHarness { // --- Sync helpers --- - /** Wait until React Flow has rendered at least `expected` node elements. */ - async waitForLayoutStable(expected = 1, timeoutMs = 3000): Promise { + /** + * Wait until React Flow has rendered at least `expectedNodes` node elements + * and `expectedEdges` edge elements. React Flow renders edges asynchronously + * after nodes are measured via ResizeObserver, so tests that assert on edge + * state must opt in to waiting for them — node-only waits race against the + * edge measurement pass. + */ + async waitForGraphStable( + expectedNodes = 1, + expectedEdges = 0, + timeoutMs = 3000, + ): Promise { await vi.waitFor( () => { - if (this.nodes.length < expected) { + if (this.nodes.length < expectedNodes) { throw new Error( - `expected ${expected} nodes, got ${this.nodes.length}`, + `expected ${expectedNodes} nodes, got ${this.nodes.length}`, + ); + } + if (this.edges.length < expectedEdges) { + throw new Error( + `expected ${expectedEdges} edges, got ${this.edges.length}`, ); } }, @@ -397,7 +412,7 @@ export class AttackPathPageHarness { 10000, ); await this.user.click(button); - await this.waitForLayoutStable(1, 10000); + await this.waitForGraphStable(1, 0, 10000); } async clickNode(nodeId: string): Promise { diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index 995973823e..a67509bed1 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -166,6 +166,7 @@ export default async function Compliance({ > @@ -179,12 +180,13 @@ export default async function Compliance({ const SSRComplianceGrid = async ({ searchParams, + scanId, selectedScan, }: { searchParams: SearchParamsProps; + scanId: string | null; selectedScan?: ScanEntity; }) => { - const scanId = searchParams.scanId?.toString() || ""; const regionFilter = searchParams["filter[region__in]"]?.toString() || ""; // Only fetch compliance data if we have a valid scanId @@ -247,7 +249,7 @@ const SSRComplianceGrid = async ({ diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 57ff5a3e8c..7ff9780292 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -6,7 +6,7 @@ import { getLatestFindingGroups, } from "@/actions/finding-groups"; import { getLatestMetadataInfo, getMetadataInfo } from "@/actions/findings"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; import { getScan, getScans } from "@/actions/scans"; import { SeedFromFindingsButton } from "@/app/(prowler)/alerts/_components"; import { FindingsFilters } from "@/components/findings/findings-filters"; @@ -37,7 +37,7 @@ export default async function Findings({ const { filters, query } = extractFiltersAndQuery(resolvedSearchParams); const [providersData, scansData] = await Promise.all([ - getProviders({ pageSize: 50 }), + getAllProviders(), getScans({ pageSize: 50 }), ]); diff --git a/ui/app/(prowler)/integrations/amazon-s3/page.tsx b/ui/app/(prowler)/integrations/amazon-s3/page.tsx index 48e74f8baf..9db94b034f 100644 --- a/ui/app/(prowler)/integrations/amazon-s3/page.tsx +++ b/ui/app/(prowler)/integrations/amazon-s3/page.tsx @@ -1,7 +1,7 @@ import React from "react"; import { getIntegrations } from "@/actions/integrations"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; import { S3IntegrationsManager } from "@/components/integrations/s3/s3-integrations-manager"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn"; import { ContentLayout } from "@/components/ui"; @@ -47,7 +47,7 @@ export default async function S3Integrations({ const [integrations, providers] = await Promise.all([ getIntegrations(urlSearchParams), - getProviders({ pageSize: 100 }), + getAllProviders(), ]); const s3Integrations = integrations?.data || []; diff --git a/ui/app/(prowler)/integrations/aws-security-hub/page.tsx b/ui/app/(prowler)/integrations/aws-security-hub/page.tsx index 4adb23181f..12050a8eaa 100644 --- a/ui/app/(prowler)/integrations/aws-security-hub/page.tsx +++ b/ui/app/(prowler)/integrations/aws-security-hub/page.tsx @@ -1,7 +1,7 @@ import React from "react"; import { getIntegrations } from "@/actions/integrations"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; import { SecurityHubIntegrationsManager } from "@/components/integrations/security-hub/security-hub-integrations-manager"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn"; import { ContentLayout } from "@/components/ui"; @@ -45,7 +45,7 @@ export default async function SecurityHubIntegrations({ const [integrations, providers] = await Promise.all([ getIntegrations(urlSearchParams), - getProviders({ pageSize: 100 }), + getAllProviders(), ]); const securityHubIntegrations = integrations?.data || []; diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx index 7f475687e3..99f152b4e4 100644 --- a/ui/app/(prowler)/page.tsx +++ b/ui/app/(prowler)/page.tsx @@ -1,11 +1,10 @@ import { Suspense } from "react"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; +import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors"; import { ContentLayout } from "@/components/ui"; import { SearchParamsProps } from "@/types"; -import { AccountsSelector } from "./_overview/_components/accounts-selector"; -import { ProviderTypeSelector } from "./_overview/_components/provider-type-selector"; import { AttackSurfaceSkeleton, AttackSurfaceSSR, @@ -39,13 +38,12 @@ export default async function Home({ searchParams: Promise; }) { const resolvedSearchParams = await searchParams; - const providersData = await getProviders({ page: 1, pageSize: 200 }); + const providersData = await getAllProviders(); return (
    - - +
    diff --git a/ui/app/(prowler)/providers/page.test.ts b/ui/app/(prowler)/providers/page.test.ts index 52e22c9ba8..96612380df 100644 --- a/ui/app/(prowler)/providers/page.test.ts +++ b/ui/app/(prowler)/providers/page.test.ts @@ -34,4 +34,13 @@ describe("providers page", () => { expect(source).toContain("size: 160"); expect(source).toContain("size: 140"); }); + + it("keeps the CLI import banner gated by the Cloud environment", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const pagePath = path.join(currentDir, "page.tsx"); + const source = readFileSync(pagePath, "utf8"); + + expect(source).toContain("NEXT_PUBLIC_IS_CLOUD_ENV"); + expect(source).toContain("{isCloudEnvironment && + {isCloudEnvironment && } ({ getProviders: vi.fn(), + getAllProviders: vi.fn(), })); const organizationsActionsMock = vi.hoisted(() => ({ @@ -619,6 +620,7 @@ describe("loadProvidersAccountsViewData", () => { it("does not call organizations endpoints in OSS", async () => { // Given providersActionsMock.getProviders.mockResolvedValue(providersResponse); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); scansActionsMock.getScans.mockResolvedValue({ data: [] }); // When @@ -662,6 +664,7 @@ describe("loadProvidersAccountsViewData", () => { }, })), }); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({ data: [ { @@ -724,6 +727,7 @@ describe("loadProvidersAccountsViewData", () => { it("falls back to empty cloud grouping data when organizations endpoints fail", async () => { // Given providersActionsMock.getProviders.mockResolvedValue(providersResponse); + providersActionsMock.getAllProviders.mockResolvedValue(providersResponse); organizationsActionsMock.listOrganizationsSafe.mockResolvedValue({ data: [], }); diff --git a/ui/app/(prowler)/providers/providers-page.utils.ts b/ui/app/(prowler)/providers/providers-page.utils.ts index 781fb1fc6f..db79452709 100644 --- a/ui/app/(prowler)/providers/providers-page.utils.ts +++ b/ui/app/(prowler)/providers/providers-page.utils.ts @@ -2,7 +2,7 @@ import { listOrganizationsSafe, listOrganizationUnitsSafe, } from "@/actions/organizations/organizations"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders, getProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; import { extractFiltersAndQuery, @@ -467,7 +467,7 @@ export async function loadProvidersAccountsViewData({ ), // Unfiltered fetch for ProviderTypeSelector — only needs distinct types; // TODO: Replace with a dedicated lightweight endpoint when available. - resolveActionResult(getProviders({ pageSize: 500 })), + resolveActionResult(getAllProviders()), // Fetch active scheduled scans to determine daily schedule per provider resolveActionResult( getScans({ diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx index 426778ddb5..fb7e5ab63d 100644 --- a/ui/app/(prowler)/resources/page.tsx +++ b/ui/app/(prowler)/resources/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from "react"; -import { getProviders } from "@/actions/providers"; +import { getAllProviders } from "@/actions/providers"; import { getLatestMetadataInfo, getLatestResources, @@ -44,7 +44,7 @@ export default async function Resources({ filters: outputFilters, sort: encodedSort, }), - getProviders({ pageSize: 50 }), + getAllProviders(), initialResourceId ? getResourceById(initialResourceId, { include: ["provider"] }) : Promise.resolve(undefined), diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index 55dbf7eb8a..62aba551ae 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -3,28 +3,54 @@ import { Suspense } from "react"; import { getAllProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; import { auth } from "@/auth.config"; -import { MutedFindingsConfigButton } from "@/components/providers"; import { - NoProvidersAdded, - NoProvidersConnected, - ScansFilters, -} from "@/components/scans"; -import { LaunchScanWorkflow } from "@/components/scans/launch-workflow"; + getScanJobsTab, + getScanJobsTabFilters, + getScanJobsUserFilters, +} from "@/components/scans/scans.utils"; +import { ScansPageShell } from "@/components/scans/scans-page-shell"; +import { ScansProvidersEmptyState } from "@/components/scans/scans-providers-empty-state"; import { SkeletonTableScans } from "@/components/scans/table"; -import { ScansTableWithPolling } from "@/components/scans/table/scans"; +import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table"; import { ContentLayout } from "@/components/ui"; -import { CustomBanner } from "@/components/ui/custom/custom-banner"; import { - createProviderDetailsMapping, - extractProviderUIDs, -} from "@/lib/provider-helpers"; -import { - ExpandedScanData, ProviderProps, + SCAN_JOBS_TAB, ScanProps, SearchParamsProps, } from "@/types"; +const ACTIVE_SCAN_COUNT_PAGE_SIZE = 1; + +const getFilterSearchQuery = ( + filters: Record, +): string => { + const value = filters["filter[search]"]; + if (Array.isArray(value)) return value[0] ?? ""; + + return value ?? ""; +}; + +const getActiveScanCount = async ( + searchParams: SearchParamsProps, +): Promise => { + const userFilters = getScanJobsUserFilters(searchParams); + const filters = { + ...userFilters, + ...getScanJobsTabFilters(SCAN_JOBS_TAB.ACTIVE), + }; + + const scansData = await getScans({ + query: getFilterSearchQuery(filters), + page: 1, + pageSize: ACTIVE_SCAN_COUNT_PAGE_SIZE, + filters, + fields: { scans: "state" }, + }); + + return scansData && "meta" in scansData ? scansData.meta.pagination.count : 0; +}; + export default async function Scans({ searchParams, }: { @@ -32,106 +58,47 @@ export default async function Scans({ }) { const session = await auth(); const resolvedSearchParams = await searchParams; - const filteredParams = { ...resolvedSearchParams }; - delete filteredParams.scanId; - const [providersData, completedScansData] = await Promise.all([ - getAllProviders(), - getScans({ - filters: { "filter[state]": "completed" }, - pageSize: 50, - fields: { scans: "name,completed_at,provider" }, - include: "provider", - }), - ]); + const providersData = await getAllProviders(); + const providers = providersData?.data ?? []; - const completedScans: ExpandedScanData[] = (completedScansData?.data ?? []) - .map((scan: ScanProps) => { - const providerId = scan.relationships?.provider?.data?.id; - const providerData = completedScansData?.included?.find( - (item: { type: string; id: string }) => - item.type === "providers" && item.id === providerId, - ); - if (!providerData) return null; - return { - ...scan, - providerInfo: { - provider: providerData.attributes.provider, - uid: providerData.attributes.uid, - alias: providerData.attributes.alias, - }, - }; - }) - .filter(Boolean) as ExpandedScanData[]; - - const providerInfo = - providersData?.data - ?.filter( - (provider: ProviderProps) => - provider.attributes.connection.connected === true, - ) - .map((provider: ProviderProps) => ({ - providerId: provider.id, - alias: provider.attributes.alias, - providerType: provider.attributes.provider, - uid: provider.attributes.uid, - connected: provider.attributes.connection.connected, - })) || []; - - const thereIsNoProviders = - !providersData?.data || providersData.data.length === 0; - - const thereIsNoProvidersConnected = providersData?.data?.every( - (provider: ProviderProps) => !provider.attributes.connection.connected, + const connectedProviders = providers.filter( + (provider: ProviderProps) => + provider.attributes.connection.connected === true, ); + const thereIsNoProviders = providers.length === 0; + const thereIsNoProvidersConnected = + !thereIsNoProviders && connectedProviders.length === 0; - const hasManageScansPermission = session?.user?.permissions?.manage_scans; - - // Extract provider UIDs and create provider details mapping for filtering - const providerUIDs = providersData ? extractProviderUIDs(providersData) : []; - const providerDetails = providersData - ? createProviderDetailsMapping(providerUIDs, providersData) - : []; - - if (thereIsNoProviders) { - return ( - - - - ); - } + const hasManageScansPermission = Boolean( + session?.user?.permissions?.manage_scans, + ); + const activeScanCount = + thereIsNoProviders || thereIsNoProvidersConnected + ? 0 + : await getActiveScanCount(resolvedSearchParams); return ( - - <> - <> - {!hasManageScansPermission ? ( - - ) : thereIsNoProvidersConnected ? ( - <> - - - ) : ( - - )} - -
    - -
    - -
    - }> + + {thereIsNoProviders || thereIsNoProvidersConnected ? ( + + ) : ( + + + } + > -
    - + + )}
    ); } @@ -141,21 +108,27 @@ const SSRDataTableScans = async ({ }: { searchParams: SearchParamsProps; }) => { + const tab = getScanJobsTab(searchParams.tab); + const page = parseInt(searchParams.page?.toString() || "1", 10); const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); const sort = searchParams.sort?.toString(); - // Extract all filter parameters, excluding scanId - const filters = Object.fromEntries( - Object.entries(searchParams).filter( - ([key]) => key.startsWith("filter[") && key !== "scanId", - ), + const userFilters = Object.entries(searchParams).filter(([key]) => + key.startsWith("filter["), ); + const hasUserFilters = userFilters.length > 0; + + const filters = { + ...getScanJobsUserFilters(searchParams), + ...getScanJobsTabFilters( + tab, + searchParams["filter[state__in]"] ?? searchParams["filter[state]"], + ), + }; - // Extract query from filters const query = (filters["filter[search]"] as string) || ""; - // Fetch scans data with provider information included const scansData = await getScans({ query, page, @@ -173,19 +146,12 @@ const SSRDataTableScans = async ({ scans?.map((scan: ScanProps) => { const providerId = scan.relationships?.provider?.data?.id; - if (!providerId) { - return { ...scan, providerInfo: null }; - } - - // Find the provider data in the included array const providerData = included?.find( (item: { type: string; id: string }) => item.type === "providers" && item.id === providerId, ); - if (!providerData) { - return { ...scan, providerInfo: null }; - } + if (!providerData) return scan; return { ...scan, @@ -198,10 +164,11 @@ const SSRDataTableScans = async ({ }) || []; return ( - ); }; diff --git a/ui/app/(prowler)/users/page.tsx b/ui/app/(prowler)/users/page.tsx index 4b26c0baf2..fa1e838872 100644 --- a/ui/app/(prowler)/users/page.tsx +++ b/ui/app/(prowler)/users/page.tsx @@ -109,6 +109,9 @@ const SSRDataTable = async ({ roles, canBeExpelled, currentTenantId: canBeExpelled ? currentTenantId : undefined, + // Users may only delete their own account; gate the delete action so the + // UI matches the backend rule and never offers an action that would fail. + isCurrentUser: user.id === currentUserId, }; }); diff --git a/ui/app/api/health/route.test.ts b/ui/app/api/health/route.test.ts new file mode 100644 index 0000000000..5fdf7f5886 --- /dev/null +++ b/ui/app/api/health/route.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { GET } from "./route"; + +interface HealthResponse { + status: "pass"; + version: string; + releaseId: string; + serviceId: "prowler-ui"; + description: string; +} + +const parseHealthResponse = async (response: Response) => + (await response.json()) as HealthResponse; + +describe("GET /api/health", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + }); + + it("should return an IETF-shaped healthy response when the Next.js route handler responds", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_PROWLER_RELEASE_VERSION", "1.28.0"); + + // When + const response = await GET(); + const body = await parseHealthResponse(response); + + // Then + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe( + "application/health+json", + ); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(body).toEqual({ + status: "pass", + version: "1", + releaseId: "1.28.0", + serviceId: "prowler-ui", + description: "Prowler UI", + }); + }); + + it("should fall back to 'unknown' when the release version env var is missing", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_PROWLER_RELEASE_VERSION", ""); + + // When + const response = await GET(); + const body = await parseHealthResponse(response); + + // Then + expect(response.status).toBe(200); + expect(body.releaseId).toBe("unknown"); + }); + + it("should not call fetch while evaluating UI liveness", async () => { + // Given + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + // When + await GET(); + + // Then + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("should not depend on external health URLs", async () => { + // Given + vi.stubEnv( + "PROWLER_API_HEALTH_URL", + "https://api.example.com/health/ready", + ); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + // When + const response = await GET(); + const body = await parseHealthResponse(response); + + // Then + expect(response.status).toBe(200); + expect(body.status).toBe("pass"); + expect(body.serviceId).toBe("prowler-ui"); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/app/api/health/route.ts b/ui/app/api/health/route.ts new file mode 100644 index 0000000000..f352ffd6a1 --- /dev/null +++ b/ui/app/api/health/route.ts @@ -0,0 +1,19 @@ +export const dynamic = "force-dynamic"; + +export async function GET() { + const body = { + status: "pass", + version: "1", + releaseId: process.env.NEXT_PUBLIC_PROWLER_RELEASE_VERSION || "unknown", + serviceId: "prowler-ui", + description: "Prowler UI", + }; + + return new Response(JSON.stringify(body), { + status: 200, + headers: { + "Content-Type": "application/health+json", + "Cache-Control": "no-store", + }, + }); +} diff --git a/ui/app/api/scans/[scanId]/report/route.test.ts b/ui/app/api/scans/[scanId]/report/route.test.ts new file mode 100644 index 0000000000..ad5e9194ea --- /dev/null +++ b/ui/app/api/scans/[scanId]/report/route.test.ts @@ -0,0 +1,202 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { GET } from "./route"; + +const { getAuthHeadersMock } = vi.hoisted(() => ({ + getAuthHeadersMock: vi.fn(), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +describe("GET /api/scans/[scanId]/report", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("streams the upstream report body without buffering it", async () => { + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.close(); + }, + }); + const fetchMock = vi.fn().mockResolvedValue( + new Response(upstreamBody, { + status: 200, + headers: { + "content-type": "application/zip", + "content-length": "3", + }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + + const response = await GET(new Request("http://localhost/api"), { + params: Promise.resolve({ scanId: "scan-123" }), + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/scans/scan-123/report", + expect.objectContaining({ + headers: { Authorization: "Bearer token" }, + cache: "no-store", + redirect: "manual", + }), + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/zip"); + expect(response.headers.get("content-length")).toBe("3"); + expect(response.headers.get("content-disposition")).toBe( + 'attachment; filename="scan-scan-123-report.zip"', + ); + expect(response.body).toBe(upstreamBody); + }); + + it("checks report readiness without streaming ready report bytes", async () => { + const cancelMock = vi.fn(); + const upstreamBody = new ReadableStream({ + cancel: cancelMock, + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + }, + }); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(upstreamBody, { status: 200 })), + ); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + + const response = await GET( + new Request("http://localhost/api?preflight=1"), + { + params: Promise.resolve({ scanId: "scan-123" }), + }, + ); + + expect(response.status).toBe(204); + expect(response.body).toBeNull(); + expect(cancelMock).toHaveBeenCalledTimes(1); + }); + + it("redirects the browser to the presigned URL for S3-backed reports", async () => { + const presignedUrl = "https://bucket.s3.example.com/report.zip?sig=abc"; + const fetchMock = vi.fn().mockResolvedValue( + new Response(null, { + status: 302, + headers: { location: presignedUrl }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + + const response = await GET(new Request("http://localhost/api"), { + params: Promise.resolve({ scanId: "scan-123" }), + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/scans/scan-123/report", + expect.objectContaining({ redirect: "manual" }), + ); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe(presignedUrl); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.body).toBeNull(); + }); + + it("reports readiness without exposing the presigned URL on preflight", async () => { + const presignedUrl = "https://bucket.s3.example.com/report.zip?sig=abc"; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(null, { + status: 302, + headers: { location: presignedUrl }, + }), + ), + ); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + + const response = await GET( + new Request("http://localhost/api?preflight=1"), + { + params: Promise.resolve({ scanId: "scan-123" }), + }, + ); + + expect(response.status).toBe(204); + expect(response.headers.get("location")).toBeNull(); + expect(response.body).toBeNull(); + }); + + it("preserves pending report responses from the API", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + Response.json({ data: { id: "task-1" } }, { status: 202 }), + ), + ); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + + const response = await GET(new Request("http://localhost/api"), { + params: Promise.resolve({ scanId: "scan-123" }), + }); + + expect(response.status).toBe(202); + await expect(response.json()).resolves.toEqual({ data: { id: "task-1" } }); + }); + + it("continues to the browser-native download when preflight times out", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new DOMException("Timed out", "TimeoutError")), + ); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + + const response = await GET( + new Request("http://localhost/api?preflight=1"), + { + params: Promise.resolve({ scanId: "scan-123" }), + }, + ); + + expect(response.status).toBe(204); + expect(response.body).toBeNull(); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("does not forward upstream HTML error pages for preflight failures", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + "

    504 Gateway Time-out

    ", + { + status: 504, + headers: { "content-type": "text/html" }, + }, + ), + ), + ); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + + const response = await GET( + new Request("http://localhost/api?preflight=1"), + { + params: Promise.resolve({ scanId: "scan-123" }), + }, + ); + + expect(response.status).toBe(504); + expect(response.headers.get("content-type")).toContain("text/plain"); + await expect(response.text()).resolves.toBe( + "Unable to prepare the scan report. Please try again in a few minutes.", + ); + }); +}); diff --git a/ui/app/api/scans/[scanId]/report/route.ts b/ui/app/api/scans/[scanId]/report/route.ts new file mode 100644 index 0000000000..5b891385cb --- /dev/null +++ b/ui/app/api/scans/[scanId]/report/route.ts @@ -0,0 +1,160 @@ +import { NextResponse } from "next/server"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +interface ScanReportRouteContext { + params: Promise<{ + scanId: string; + }>; +} + +const COPY_RESPONSE_HEADERS = [ + "content-length", + "content-type", + "etag", + "last-modified", +] as const; + +const PREFLIGHT_TIMEOUT_MS = 10_000; +const REPORT_PREPARATION_ERROR = + "Unable to prepare the scan report. Please try again in a few minutes."; + +const buildAttachmentFilename = (scanId: string) => + `scan-${scanId.replace(/[^a-zA-Z0-9._-]/g, "-")}-report.zip`; + +const buildDownloadHeaders = (upstreamHeaders: Headers, scanId: string) => { + const headers = new Headers({ + "Cache-Control": "no-store", + "Content-Disposition": `attachment; filename="${buildAttachmentFilename(scanId)}"`, + }); + + COPY_RESPONSE_HEADERS.forEach((headerName) => { + const value = upstreamHeaders.get(headerName); + if (value) headers.set(headerName, value); + }); + + if (!headers.has("content-type")) { + headers.set("content-type", "application/zip"); + } + + return headers; +}; + +const isAbortError = (error: unknown) => + error instanceof DOMException && + (error.name === "AbortError" || error.name === "TimeoutError"); + +const isHtmlResponse = (headers: Headers) => + headers.get("content-type")?.toLowerCase().includes("text/html") ?? false; + +const isRedirect = (status: number) => status >= 300 && status < 400; + +const preflightReadyResponse = () => + new Response(null, { + status: 204, + headers: { "Cache-Control": "no-store" }, + }); + +export async function GET( + request: Request, + { params }: ScanReportRouteContext, +) { + const { scanId } = await params; + const headers = await getAuthHeaders({ contentType: false }); + const upstreamUrl = `${apiBaseUrl}/scans/${encodeURIComponent(scanId)}/report`; + const isPreflight = + new URL(request.url).searchParams.get("preflight") === "1"; + + let upstreamResponse: Response; + + try { + upstreamResponse = await fetch(upstreamUrl, { + headers, + cache: "no-store", + // The API redirects S3-backed reports to a presigned URL; keep that + // redirect instead of following it so the bytes never stream through + // this server. + redirect: "manual", + signal: isPreflight + ? AbortSignal.timeout(PREFLIGHT_TIMEOUT_MS) + : undefined, + }); + } catch (error) { + if (isPreflight && isAbortError(error)) { + return preflightReadyResponse(); + } + + throw error; + } + + if (upstreamResponse.status === 202) { + const body = await upstreamResponse.json().catch(() => ({})); + return NextResponse.json(body, { + status: 202, + headers: { "Cache-Control": "no-store" }, + }); + } + + // S3-backed reports: hand the API's presigned redirect to the browser so it + // downloads straight from S3 without proxying the bytes through this server. + if (isRedirect(upstreamResponse.status)) { + if (isPreflight) { + return preflightReadyResponse(); + } + + const location = upstreamResponse.headers.get("location"); + if (!location) { + return NextResponse.json( + { error: "Report redirect did not include a location." }, + { status: 502, headers: { "Cache-Control": "no-store" } }, + ); + } + + return new Response(null, { + status: 307, + headers: { Location: location, "Cache-Control": "no-store" }, + }); + } + + if (!upstreamResponse.ok) { + const body = + isPreflight && isHtmlResponse(upstreamResponse.headers) + ? REPORT_PREPARATION_ERROR + : await upstreamResponse.text().catch(() => ""); + + return new Response(body, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: { + "Cache-Control": "no-store", + "Content-Type": + isPreflight && isHtmlResponse(upstreamResponse.headers) + ? "text/plain" + : upstreamResponse.headers.get("content-type") || "text/plain", + }, + }); + } + + // Self-hosted without S3: the API returns the bytes directly, so there is no + // presigned URL to redirect to and we stream the response through instead. + if (isPreflight) { + await upstreamResponse.body?.cancel(); + return preflightReadyResponse(); + } + + if (!upstreamResponse.body) { + return NextResponse.json( + { error: "Report response did not include a readable body." }, + { status: 502, headers: { "Cache-Control": "no-store" } }, + ); + } + + return new Response(upstreamResponse.body, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: buildDownloadHeaders(upstreamResponse.headers, scanId), + }); +} diff --git a/ui/components/auth/oss/sign-up-form.tsx b/ui/components/auth/oss/sign-up-form.tsx index 6d971b5dab..e18ded5845 100644 --- a/ui/components/auth/oss/sign-up-form.tsx +++ b/ui/components/auth/oss/sign-up-form.tsx @@ -6,6 +6,11 @@ import { useRouter } from "next/navigation"; import { useForm, useWatch } from "react-hook-form"; import { createNewUser } from "@/actions/auth"; +import { + getInvitationErrorDisplay, + INVITATION_ERROR_FLOW, + isInvitationTokenError, +} from "@/app/(auth)/invitation/_lib/invitation-errors"; import { AuthDivider } from "@/components/auth/oss/auth-divider"; import { AuthFooterLink } from "@/components/auth/oss/auth-footer-link"; import { AuthLayout } from "@/components/auth/oss/auth-layout"; @@ -28,7 +33,10 @@ const AUTH_ERROR_PATHS = { EMAIL: "/data/attributes/email", PASSWORD: "/data/attributes/password", COMPANY_NAME: "/data/attributes/company_name", - INVITATION_TOKEN: "/data", +} as const; + +const FORM_ERROR_TYPE = { + SERVER: "server", } as const; export const SignUpForm = ({ @@ -86,31 +94,47 @@ export const SignUpForm = ({ router.push("/sign-in"); } } else { + const invitationTokenError = newUser.errors.find((error: ApiError) => + isInvitationTokenError(error), + ); + + if (invitationToken && invitationTokenError) { + const { message } = getInvitationErrorDisplay( + { status: newUser.status, errors: [invitationTokenError] }, + INVITATION_ERROR_FLOW.SIGNUP, + ); + form.setError("invitationToken", { + type: FORM_ERROR_TYPE.SERVER, + message, + }); + return; + } + newUser.errors.forEach((error: ApiError) => { const errorMessage = error.detail; const pointer = error.source?.pointer; switch (pointer) { case AUTH_ERROR_PATHS.NAME: - form.setError("name", { type: "server", message: errorMessage }); + form.setError("name", { + type: FORM_ERROR_TYPE.SERVER, + message: errorMessage, + }); break; case AUTH_ERROR_PATHS.EMAIL: - form.setError("email", { type: "server", message: errorMessage }); + form.setError("email", { + type: FORM_ERROR_TYPE.SERVER, + message: errorMessage, + }); break; case AUTH_ERROR_PATHS.COMPANY_NAME: form.setError("company", { - type: "server", + type: FORM_ERROR_TYPE.SERVER, message: errorMessage, }); break; case AUTH_ERROR_PATHS.PASSWORD: form.setError("password", { - type: "server", - message: errorMessage, - }); - break; - case AUTH_ERROR_PATHS.INVITATION_TOKEN: - form.setError("invitationToken", { - type: "server", + type: FORM_ERROR_TYPE.SERVER, message: errorMessage, }); break; diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx index 5239ba9dd5..4a0ba6b198 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -32,10 +32,12 @@ export const ClientAccordionContent = ({ const [expandedFindings, setExpandedFindings] = useState([]); const searchParams = useSearchParams(); const pageNumber = searchParams.get("page") || "1"; + const pageSize = searchParams.get("pageSize") || "10"; const complianceId = searchParams.get("complianceId"); const openFindingId = searchParams.get("id"); const sort = searchParams.get("sort") || FINDINGS_DEFAULT_SORT; const loadedPageRef = useRef(null); + const loadedPageSizeRef = useRef(null); const loadedSortRef = useRef(null); const loadedMutedRef = useRef(null); const isExpandedRef = useRef(false); @@ -52,11 +54,13 @@ export const ClientAccordionContent = ({ requirement.check_ids?.length > 0 && requirement.status !== "No findings" && (loadedPageRef.current !== pageNumber || + loadedPageSizeRef.current !== pageSize || loadedSortRef.current !== sort || loadedMutedRef.current !== mutedFilter || !isExpandedRef.current) ) { loadedPageRef.current = pageNumber; + loadedPageSizeRef.current = pageSize; loadedSortRef.current = sort; loadedMutedRef.current = mutedFilter; isExpandedRef.current = true; @@ -72,6 +76,7 @@ export const ClientAccordionContent = ({ ...(region && { "filter[region__in]": region }), }, page: parseInt(pageNumber, 10), + pageSize: parseInt(pageSize, 10), sort: encodedSort, }); @@ -111,6 +116,7 @@ export const ClientAccordionContent = ({ requirement, scanId, pageNumber, + pageSize, sort, region, mutedFilter, diff --git a/ui/components/compliance/compliance-custom-details/cis-details.tsx b/ui/components/compliance/compliance-custom-details/cis-details.tsx index be880fd522..c46f061188 100644 --- a/ui/components/compliance/compliance-custom-details/cis-details.tsx +++ b/ui/components/compliance/compliance-custom-details/cis-details.tsx @@ -81,9 +81,7 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => { {requirement.remediation_procedure && typeof requirement.remediation_procedure === "string" && ( - {/* Prettier -> "plugins": ["prettier-plugin-tailwindcss"] is not ready yet to "prose": */} - {/* eslint-disable-next-line */} -
    +
    {requirement.remediation_procedure}
    @@ -92,8 +90,7 @@ export const CISCustomDetails = ({ requirement }: CISDetailsProps) => { {requirement.audit_procedure && typeof requirement.audit_procedure === "string" && ( - {/* eslint-disable-next-line */} -
    +
    {requirement.audit_procedure}
    diff --git a/ui/components/compliance/compliance-custom-details/dora-details.tsx b/ui/components/compliance/compliance-custom-details/dora-details.tsx new file mode 100644 index 0000000000..3d84e891ba --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/dora-details.tsx @@ -0,0 +1,49 @@ +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBadge, + ComplianceBadgeContainer, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, +} from "./shared-components"; + +interface DORADetailsProps { + requirement: Requirement; +} + +export const DORACustomDetails = ({ requirement }: DORADetailsProps) => { + return ( + + {requirement.description && ( + + {requirement.description} + + )} + + + {requirement.pillar && ( + + )} + {requirement.article && ( + + )} + {requirement.article_title && ( + + )} + + + ); +}; diff --git a/ui/components/compliance/compliance-download-container.test.tsx b/ui/components/compliance/compliance-download-container.test.tsx index 81774b686f..70ff11c1c2 100644 --- a/ui/components/compliance/compliance-download-container.test.tsx +++ b/ui/components/compliance/compliance-download-container.test.tsx @@ -6,15 +6,19 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { downloadComplianceCsvMock, downloadCompliancePdfMock } = vi.hoisted( - () => ({ - downloadComplianceCsvMock: vi.fn(), - downloadCompliancePdfMock: vi.fn(), - }), -); +const { + downloadComplianceCsvMock, + downloadComplianceOcsfMock, + downloadCompliancePdfMock, +} = vi.hoisted(() => ({ + downloadComplianceCsvMock: vi.fn(), + downloadComplianceOcsfMock: vi.fn(), + downloadCompliancePdfMock: vi.fn(), +})); vi.mock("@/lib/helper", () => ({ downloadComplianceCsv: downloadComplianceCsvMock, + downloadComplianceOcsf: downloadComplianceOcsfMock, downloadCompliancePdf: downloadCompliancePdfMock, })); @@ -131,4 +135,51 @@ describe("ComplianceDownloadContainer", () => { {}, ); }); + + it("should hide the OCSF action for frameworks without OCSF support", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: "Open compliance export actions" }), + ); + + expect(screen.queryByText("Download OCSF report")).not.toBeInTheDocument(); + }); + + it("should surface and trigger the OCSF download for universal frameworks", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: "Open compliance export actions" }), + ); + expect(screen.getByText("Download OCSF report")).toBeInTheDocument(); + + await user.click( + screen.getByRole("menuitem", { name: /Download OCSF report/i }), + ); + + expect(downloadComplianceOcsfMock).toHaveBeenCalledWith( + "scan-1", + "dora", + {}, + ); + }); }); diff --git a/ui/components/compliance/compliance-download-container.tsx b/ui/components/compliance/compliance-download-container.tsx index 6d2e0ddfee..6e29acc213 100644 --- a/ui/components/compliance/compliance-download-container.tsx +++ b/ui/components/compliance/compliance-download-container.tsx @@ -1,6 +1,6 @@ "use client"; -import { DownloadIcon, FileTextIcon } from "lucide-react"; +import { DownloadIcon, FileJsonIcon, FileTextIcon } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/shadcn/button/button"; @@ -14,8 +14,15 @@ import { TooltipTrigger, } from "@/components/shadcn/tooltip"; import { toast } from "@/components/ui"; -import type { ComplianceReportType } from "@/lib/compliance/compliance-report-types"; -import { downloadComplianceCsv, downloadCompliancePdf } from "@/lib/helper"; +import { + type ComplianceReportType, + isOcsfSupported, +} from "@/lib/compliance/compliance-report-types"; +import { + downloadComplianceCsv, + downloadComplianceOcsf, + downloadCompliancePdf, +} from "@/lib/helper"; import { cn } from "@/lib/utils"; interface ComplianceDownloadContainerProps { @@ -40,9 +47,14 @@ export const ComplianceDownloadContainer = ({ presentation = "buttons", }: ComplianceDownloadContainerProps) => { const [isDownloadingCsv, setIsDownloadingCsv] = useState(false); + const [isDownloadingOcsf, setIsDownloadingOcsf] = useState(false); const [isDownloadingPdf, setIsDownloadingPdf] = useState(false); const isIconWidth = buttonWidth === "icon"; const isDropdown = presentation === "dropdown"; + // Only universal frameworks declaring an ``outputs`` block expose a + // per-framework OCSF artifact (today: DORA, CSA CCM 4.0). Hide the + // action everywhere else so the user never hits a guaranteed 404. + const ocsfAvailable = isOcsfSupported(complianceId); const handleDownloadCsv = async () => { if (isDownloadingCsv) return; @@ -54,6 +66,16 @@ export const ComplianceDownloadContainer = ({ } }; + const handleDownloadOcsf = async () => { + if (!ocsfAvailable || isDownloadingOcsf) return; + setIsDownloadingOcsf(true); + try { + await downloadComplianceOcsf(scanId, complianceId, toast); + } finally { + setIsDownloadingOcsf(false); + } + }; + const handleDownloadPdf = async () => { if (!reportType || isDownloadingPdf) return; setIsDownloadingPdf(true); @@ -105,6 +127,18 @@ export const ComplianceDownloadContainer = ({ onSelect={handleDownloadCsv} disabled={disabled || isDownloadingCsv} /> + {ocsfAvailable && ( + + } + label="Download OCSF report" + onSelect={handleDownloadOcsf} + disabled={disabled || isDownloadingOcsf} + /> + )} {reportType && ( Download CSV report )} + {ocsfAvailable && ( + + + + + {showTooltip && ( + Download OCSF report + )} + + )} {reportType && ( diff --git a/ui/components/compliance/compliance-header/compliance-scan-info.tsx b/ui/components/compliance/compliance-header/compliance-scan-info.tsx index 76bf8e46be..da1caa5ee9 100644 --- a/ui/components/compliance/compliance-header/compliance-scan-info.tsx +++ b/ui/components/compliance/compliance-header/compliance-scan-info.tsx @@ -13,7 +13,7 @@ interface ComplianceScanInfoProps { }; attributes: { name?: string; - completed_at: string; + completed_at: string | null; }; }; } diff --git a/ui/components/filters/provider-account-selectors.test.tsx b/ui/components/filters/provider-account-selectors.test.tsx new file mode 100644 index 0000000000..7d576251ac --- /dev/null +++ b/ui/components/filters/provider-account-selectors.test.tsx @@ -0,0 +1,247 @@ +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ProviderProps } from "@/types/providers"; + +import { ProviderAccountSelectors } from "./provider-account-selectors"; + +const { selectorProps, navigateWithParamsMock, currentSearchParams } = + vi.hoisted(() => ({ + selectorProps: { + providerType: undefined as + | { + providers: ProviderProps[]; + onBatchChange: (filterKey: string, values: string[]) => void; + selectedValues: string[]; + } + | undefined, + accounts: undefined as + | { + providers: ProviderProps[]; + filterKey?: string; + onBatchChange: (filterKey: string, values: string[]) => void; + selectedValues: string[]; + } + | undefined, + }, + navigateWithParamsMock: vi.fn(), + currentSearchParams: { value: "" }, + })); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(currentSearchParams.value), +})); + +vi.mock("@/hooks/use-url-filters", () => ({ + useUrlFilters: () => ({ + navigateWithParams: navigateWithParamsMock, + }), +})); + +vi.mock("@/app/(prowler)/_overview/_components/provider-type-selector", () => ({ + ProviderTypeSelector: (props: { + providers: ProviderProps[]; + onBatchChange: (filterKey: string, values: string[]) => void; + selectedValues: string[]; + }) => { + selectorProps.providerType = props; + return
    Provider type selector
    ; + }, +})); + +vi.mock("@/app/(prowler)/_overview/_components/accounts-selector", () => ({ + AccountsSelector: (props: { + providers: ProviderProps[]; + filterKey?: string; + onBatchChange: (filterKey: string, values: string[]) => void; + selectedValues: string[]; + }) => { + selectorProps.accounts = props; + return
    Accounts selector
    ; + }, +})); + +const makeProvider = ({ + id, + provider, + uid, + alias, +}: { + id: string; + provider: ProviderProps["attributes"]["provider"]; + uid: string; + alias: string; +}): ProviderProps => ({ + id, + type: "providers", + attributes: { + provider, + uid, + alias, + status: "completed", + 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: [], + }, + }, +}); + +const providers = [ + makeProvider({ + id: "aws-provider", + provider: "aws", + uid: "123456789012", + alias: "Production AWS", + }), + makeProvider({ + id: "gcp-provider", + provider: "gcp", + uid: "prowler-project", + alias: "Production GCP", + }), +]; + +const applyLastNavigation = () => { + const modifier = navigateWithParamsMock.mock.calls.at(-1)?.[0] as + | ((params: URLSearchParams) => void) + | undefined; + const params = new URLSearchParams(currentSearchParams.value); + + if (!modifier) throw new Error("Expected navigateWithParams to be called"); + + modifier(params); + + return params; +}; + +describe("ProviderAccountSelectors", () => { + beforeEach(() => { + currentSearchParams.value = ""; + selectorProps.providerType = undefined; + selectorProps.accounts = undefined; + navigateWithParamsMock.mockClear(); + }); + + it("filters account options by selected provider types in instant mode", () => { + currentSearchParams.value = "filter%5Bprovider_type__in%5D=aws"; + + render(); + + expect(selectorProps.accounts?.providers).toEqual([providers[0]]); + }); + + it("cleans incompatible selected accounts in the same instant navigation", () => { + currentSearchParams.value = + "filter%5Bprovider_type__in%5D=aws&filter%5Bprovider_id__in%5D=aws-provider"; + + render(); + + selectorProps.providerType?.onBatchChange("provider_type__in", ["gcp"]); + + const params = applyLastNavigation(); + + expect(params.get("filter[provider_type__in]")).toBe("gcp"); + expect(params.get("filter[provider_id__in]")).toBeNull(); + }); + + it("cleans incompatible UID accounts in the same instant navigation", () => { + currentSearchParams.value = + "filter%5Bprovider_type__in%5D=aws&filter%5Bprovider_uid__in%5D=123456789012&page=2&scanId=scan-1"; + + render( + , + ); + + selectorProps.providerType?.onBatchChange("provider_type__in", ["gcp"]); + + const params = applyLastNavigation(); + + expect(selectorProps.accounts?.filterKey).toBe("provider_uid__in"); + expect(params.get("filter[provider_type__in]")).toBe("gcp"); + expect(params.get("filter[provider_uid__in]")).toBeNull(); + expect(params.get("page")).toBeNull(); + expect(params.get("scanId")).toBeNull(); + }); + + it("filters account options by selected provider types in batch mode", () => { + render( + , + ); + + expect(selectorProps.accounts?.providers).toEqual([providers[0]]); + }); + + it("cleans incompatible selected accounts in batch mode", () => { + const onBatchChange = vi.fn(); + + render( + , + ); + + selectorProps.providerType?.onBatchChange("provider_type__in", ["gcp"]); + + expect(onBatchChange).toHaveBeenCalledWith("provider_type__in", ["gcp"]); + expect(onBatchChange).toHaveBeenCalledWith("provider_id__in", [ + "gcp-provider", + ]); + }); + + it("uses provider UID values when accountValue is uid", () => { + const onBatchChange = vi.fn(); + + render( + , + ); + + selectorProps.providerType?.onBatchChange("provider_type__in", ["gcp"]); + + expect(selectorProps.accounts?.filterKey).toBe("provider_uid__in"); + expect(onBatchChange).toHaveBeenCalledWith("provider_uid__in", [ + "prowler-project", + ]); + }); +}); diff --git a/ui/components/filters/provider-account-selectors.tsx b/ui/components/filters/provider-account-selectors.tsx new file mode 100644 index 0000000000..060413b19c --- /dev/null +++ b/ui/components/filters/provider-account-selectors.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; + +import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; +import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector"; +import { useUrlFilters } from "@/hooks/use-url-filters"; +import type { ProviderProps } from "@/types/providers"; + +const ACCOUNT_FILTER_KEY = { + PROVIDER_ID: "provider_id__in", + PROVIDER_UID: "provider_uid__in", +} as const; + +const ACCOUNT_VALUE = { + ID: "id", + UID: "uid", +} as const; + +type AccountFilterKey = + (typeof ACCOUNT_FILTER_KEY)[keyof typeof ACCOUNT_FILTER_KEY]; +type AccountValue = (typeof ACCOUNT_VALUE)[keyof typeof ACCOUNT_VALUE]; + +interface ProviderAccountSelectorsBaseProps { + providers: ProviderProps[]; + accountFilterKey?: AccountFilterKey; + accountValue?: AccountValue; + providerSelectorClassName?: string; + accountSelectorClassName?: string; + paramsToDeleteOnChange?: string[]; +} + +interface ProviderAccountSelectorsInstantProps + extends ProviderAccountSelectorsBaseProps { + mode?: "instant"; + selectedProviderTypes?: never; + selectedAccounts?: never; + onBatchChange?: never; +} + +interface ProviderAccountSelectorsBatchProps + extends ProviderAccountSelectorsBaseProps { + mode: "batch"; + selectedProviderTypes: string[]; + selectedAccounts: string[]; + onBatchChange: (filterKey: string, values: string[]) => void; +} + +type ProviderAccountSelectorsProps = + | ProviderAccountSelectorsInstantProps + | ProviderAccountSelectorsBatchProps; + +const toFilterKey = (filterKey: string) => `filter[${filterKey}]`; + +const getAccountValue = ( + provider: ProviderProps, + accountValue: AccountValue, +): string => + accountValue === ACCOUNT_VALUE.UID ? provider.attributes.uid : provider.id; + +const getCsvValues = (value: string | null): string[] => + value ? value.split(",").filter(Boolean) : []; + +const getFilteredProviders = ( + providers: ProviderProps[], + selectedProviderTypes: string[], +): ProviderProps[] => { + if (selectedProviderTypes.length === 0) return providers; + + return providers.filter((provider) => + selectedProviderTypes.includes(provider.attributes.provider), + ); +}; + +const getCompatibleAccounts = ({ + providers, + selectedAccounts, + selectedProviderTypes, + accountValue, +}: { + providers: ProviderProps[]; + selectedAccounts: string[]; + selectedProviderTypes: string[]; + accountValue: AccountValue; +}): string[] => { + if (selectedAccounts.length === 0) return []; + if (selectedProviderTypes.length === 0) return selectedAccounts; + + const compatibleValues = new Set( + getFilteredProviders(providers, selectedProviderTypes).map((provider) => + getAccountValue(provider, accountValue), + ), + ); + + return selectedAccounts.filter((account) => compatibleValues.has(account)); +}; + +export function ProviderAccountSelectors({ + providers, + accountFilterKey = ACCOUNT_FILTER_KEY.PROVIDER_ID, + accountValue = ACCOUNT_VALUE.ID, + providerSelectorClassName, + accountSelectorClassName, + paramsToDeleteOnChange = [], + ...props +}: ProviderAccountSelectorsProps) { + const searchParams = useSearchParams(); + const { navigateWithParams } = useUrlFilters(); + const isBatchMode = props.mode === "batch"; + const selectedProviderTypes = isBatchMode + ? props.selectedProviderTypes + : getCsvValues(searchParams.get(toFilterKey("provider_type__in"))); + const selectedAccounts = isBatchMode + ? props.selectedAccounts + : getCsvValues(searchParams.get(toFilterKey(accountFilterKey))); + const filteredProviders = getFilteredProviders( + providers, + selectedProviderTypes, + ); + + const handleProviderTypeChange = ( + filterKey: string, + values: string[], + ): void => { + const compatibleAccounts = getCompatibleAccounts({ + providers, + selectedAccounts, + selectedProviderTypes: values, + accountValue, + }); + + if (isBatchMode) { + props.onBatchChange(filterKey, values); + + if (compatibleAccounts.length !== selectedAccounts.length) { + props.onBatchChange(accountFilterKey, compatibleAccounts); + } + + return; + } + + navigateWithParams((params) => { + const providerFilterKey = toFilterKey(filterKey); + const accountUrlFilterKey = toFilterKey(accountFilterKey); + + if (values.length > 0) { + params.set(providerFilterKey, values.join(",")); + } else { + params.delete(providerFilterKey); + } + + if (compatibleAccounts.length > 0) { + params.set(accountUrlFilterKey, compatibleAccounts.join(",")); + } else { + params.delete(accountUrlFilterKey); + } + + paramsToDeleteOnChange.forEach((key) => params.delete(key)); + }); + }; + + const handleAccountChange = (filterKey: string, values: string[]): void => { + if (isBatchMode) { + props.onBatchChange(filterKey, values); + return; + } + + navigateWithParams((params) => { + const accountUrlFilterKey = toFilterKey(filterKey); + + if (values.length > 0) { + params.set(accountUrlFilterKey, values.join(",")); + } else { + params.delete(accountUrlFilterKey); + } + + paramsToDeleteOnChange.forEach((key) => params.delete(key)); + }); + }; + + return ( + <> +
    + +
    +
    + +
    + + ); +} diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index e498fb9eed..02a3aec62d 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -4,8 +4,6 @@ import { ChevronDown } from "lucide-react"; import type { ReactNode } from "react"; import { useState } from "react"; -import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; -import { ProviderTypeSelector } from "@/app/(prowler)/_overview/_components/provider-type-selector"; import { ApplyFiltersButton } from "@/components/filters/apply-filters-button"; import { BatchFiltersLayout } from "@/components/filters/batch-filters-layout"; import { ClearFiltersButton } from "@/components/filters/clear-filters-button"; @@ -15,6 +13,7 @@ import { FilterChip, FilterSummaryStrip, } from "@/components/filters/filter-summary-strip"; +import { ProviderAccountSelectors } from "@/components/filters/provider-account-selectors"; import { Button } from "@/components/shadcn"; import { ExpandableSection } from "@/components/ui/expandable-section"; import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom"; @@ -30,7 +29,7 @@ import { } from "./findings-filters.utils"; interface FindingsFiltersProps { - /** Provider data for ProviderTypeSelector and AccountsSelector */ + /** Provider data for provider/account filter controls. */ providers: ProviderProps[]; completedScanIds: string[]; scanDetails: { [key: string]: ScanEntity }[]; @@ -96,7 +95,7 @@ export const FindingsFilterBatchControls = ({ const [isExpanded, setIsExpanded] = useState(false); const isAlertsEdit = variant === "alerts-edit"; - // Custom filters for the expandable section (removed Provider - now using AccountsSelector) + // Custom filters for the expandable section. const customFilters = [ ...filterFindings .filter((filter) => !isAlertsEdit || filter.key !== FilterType.STATUS) @@ -203,37 +202,23 @@ export const FindingsFilterBatchControls = ({ ? pendingDateValues[0] : undefined; - const providerTypeControl = (className: string) => ( -
    - -
    - ); - - const accountsControl = (className: string) => ( -
    - -
    + const providerAccountControls = (className: string) => ( + ); const alertEditFilterGrid = hasCustomFilters ? ( - {providerTypeControl(FILTER_GRID_ITEM_CLASS)} - {accountsControl(FILTER_GRID_ITEM_CLASS)} - - } + prependElement={providerAccountControls(FILTER_GRID_ITEM_CLASS)} hideClearButton mode={DATA_TABLE_FILTER_MODE.BATCH} onBatchChange={setPending} @@ -303,8 +288,7 @@ export const FindingsFilterBatchControls = ({ alertEditFilterGrid ) : ( <> - {providerTypeControl(FILTER_CONTROL_COLUMN_CLASS)} - {accountsControl(FILTER_CONTROL_COLUMN_CLASS)} + {providerAccountControls(FILTER_CONTROL_COLUMN_CLASS)} {hasCustomFilters && ( +
    + View Resource - - - + +
    ) : undefined } />
    -
    - - {/* Row 2: Last detected, First seen, Failing for, Service, Region */} -
    - - + + + {resourceService} + - - - - - {getFailingForLabel(firstSeenAt) || "-"} - - - {resourceService} - - - + + {getRegionFlag(resourceRegionLabel) && ( - + {getRegionFlag(resourceRegionLabel)} )} - {resourceRegionLabel} + {resourceRegionLabel} + + +
    + + {/* Row 2: Last detected, First seen, Failing for */} +
    + + + + + + + + + {getFailingForLabel(firstSeenAt) || "-"}
    @@ -767,6 +817,19 @@ export function ResourceDetailDrawerContent({ label="Send to Jira" onSelect={() => setIsJiraModalOpen(true)} /> + {externalResourceTarget && ( + } + label={externalResourceTarget.label} + onSelect={() => + window.open( + externalResourceTarget.url, + "_blank", + "noopener,noreferrer", + ) + } + /> + )} ) : ( @@ -807,17 +870,31 @@ export function ResourceDetailDrawerContent({ >
    - Finding Overview - Remediation - - Findings for this resource + + Overview + + + Remediation + + + Evidence + + + Other findings + + + Scans + + + Events - Scans - Events
    - {/* Finding Overview — check-level data from checkMeta (always stable) */} + {/* Overview — check-level data from checkMeta (always stable) */} References: -
      - {checkMeta.additionalUrls.map((link, idx) => ( -
    • - + {checkMeta.additionalUrls.map((link) => ( +
    • + + ))} -
    +
    )} @@ -946,26 +1030,39 @@ export function ResourceDetailDrawerContent({ {(checkMeta.remediation.recommendation.text || recommendationLink) && (
    - - Remediation: - -
    +
    + + Remediation: + + {recommendationLink && ( + + )} +
    +
    {checkMeta.remediation.recommendation.text && ( -
    +
    {checkMeta.remediation.recommendation.text}
    )} - {recommendationLink && ( - - {recommendationLink.label} - - )}
    )} @@ -1036,7 +1133,22 @@ export function ResourceDetailDrawerContent({ )} - {/* Findings for this resource */} + {/* Metadata */} + + {isNavigating ? ( + + ) : ( + + )} + + + {/* Other findings — findings affecting this same resource */}
    + ); +} + function OtherFindingRow({ finding, isOptimisticallyMuted, diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx index 5b6360a7ea..938b4bc35a 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-skeleton.tsx @@ -9,19 +9,23 @@ export function ResourceDetailSkeleton() { return (
    - {/* Row 1: Provider, Resource */} -
    - - + {/* Row 1: Provider, Resource, Service, Region */} +
    +
    + +
    +
    + +
    + +
    - {/* Row 2: Last detected, First seen, Failing for, Service, Region */} -
    + {/* Row 2: Last detected, First seen, Failing for */} +
    - -
    diff --git a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts index 9c5a731a51..b7d6347adc 100644 --- a/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts +++ b/ui/components/findings/table/resource-detail-drawer/use-resource-detail-drawer.test.ts @@ -86,6 +86,8 @@ function makeDrawerFinding( resourceRegion: "us-east-1", resourceType: "bucket", resourceGroup: "default", + resourceDetails: null, + resourceMetadata: null, providerType: "aws", providerAlias: "prod", providerUid: "123", diff --git a/ui/components/icons/compliance/IconCompliance.tsx b/ui/components/icons/compliance/IconCompliance.tsx index d9e7d0ce1c..fe493e2096 100644 --- a/ui/components/icons/compliance/IconCompliance.tsx +++ b/ui/components/icons/compliance/IconCompliance.tsx @@ -6,6 +6,7 @@ import CCCLogo from "./ccc.svg"; import CISLogo from "./cis.svg"; import CISALogo from "./cisa.svg"; import CSALogo from "./csa.svg"; +import DORALogo from "./dora.svg"; import ENSLogo from "./ens.png"; import FedRAMPLogo from "./fedramp.svg"; import FFIECLogo from "./ffiec.svg"; @@ -67,6 +68,9 @@ const COMPLIANCE_LOGOS = [ ["c5", C5Logo], ["ccc", CCCLogo], ["csa", CSALogo], + // DORA — universal framework (`prowler/compliance/dora.json`). The + // compliance_id is just `dora`, no provider suffix. + ["dora", DORALogo], ["secnumcloud", ANSSILogo], ["aws", AWSLogo], ] as const; diff --git a/ui/components/icons/compliance/dora.svg b/ui/components/icons/compliance/dora.svg new file mode 100644 index 0000000000..8ba02e218f --- /dev/null +++ b/ui/components/icons/compliance/dora.svg @@ -0,0 +1,13 @@ + + + + + + + + + + DORA + EU 2022/2554 + + diff --git a/ui/components/icons/providers-badge/index.ts b/ui/components/icons/providers-badge/index.ts index 729eaf4779..7c107ee76a 100644 --- a/ui/components/icons/providers-badge/index.ts +++ b/ui/components/icons/providers-badge/index.ts @@ -14,6 +14,7 @@ import { ImageProviderBadge } from "./image-provider-badge"; import { KS8ProviderBadge } from "./ks8-provider-badge"; import { M365ProviderBadge } from "./m365-provider-badge"; import { MongoDBAtlasProviderBadge } from "./mongodbatlas-provider-badge"; +import { OktaProviderBadge } from "./okta-provider-badge"; import { OpenStackProviderBadge } from "./openstack-provider-badge"; import { OracleCloudProviderBadge } from "./oraclecloud-provider-badge"; import { VercelProviderBadge } from "./vercel-provider-badge"; @@ -31,6 +32,7 @@ export { KS8ProviderBadge, M365ProviderBadge, MongoDBAtlasProviderBadge, + OktaProviderBadge, OpenStackProviderBadge, OracleCloudProviderBadge, VercelProviderBadge, @@ -53,4 +55,5 @@ export const PROVIDER_BADGE_BY_NAME: Record> = { Cloudflare: CloudflareProviderBadge, OpenStack: OpenStackProviderBadge, Vercel: VercelProviderBadge, + Okta: OktaProviderBadge, }; diff --git a/ui/components/icons/providers-badge/okta-provider-badge.tsx b/ui/components/icons/providers-badge/okta-provider-badge.tsx new file mode 100644 index 0000000000..0c49b89398 --- /dev/null +++ b/ui/components/icons/providers-badge/okta-provider-badge.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; + +import { IconSvgProps } from "@/types"; + +const WHEEL_PATH = + "M34.6,0.4l-1.3,16c-0.6-0.1-1.2-0.1-1.9-0.1c-0.8,0-1.6,0.1-2.3,0.2l-0.7-7.7c0-0.2,0.2-0.5,0.4-0.5h1.3l-0.6-7.8c0-0.2,0.2-0.5,0.4-0.5h4.3C34.5,0,34.7,0.2,34.6,0.4L34.6,0.4L34.6,0.4z M23.8,1.2c-0.1-0.2-0.3-0.4-0.5-0.3l-4,1.5C19,2.5,18.9,2.8,19,3l3.3,7.1l-1.2,0.5c-0.2,0.1-0.3,0.3-0.2,0.6l3.3,7c1.2-0.7,2.5-1.2,3.9-1.5L23.8,1.2L23.8,1.2z M14,5.7l9.3,13.1c-1.2,0.8-2.2,1.7-3.1,2.7L14.5,16c-0.2-0.2-0.2-0.5,0-0.6l1-0.8L10,9c-0.2-0.2-0.2-0.5,0-0.6l3.3-2.7C13.5,5.4,13.8,5.5,14,5.7L14,5.7z M6.2,13.2c-0.2-0.1-0.5-0.1-0.6,0.1l-2.1,3.7c-0.1,0.2,0,0.5,0.2,0.6l7.1,3.4l-0.7,1.1c-0.1,0.2,0,0.5,0.2,0.6l7.1,3.2c0.5-1.3,1.2-2.5,2-3.6L6.2,13.2z M0.9,23.3c0-0.2,0.3-0.4,0.5-0.3l15.5,4c-0.4,1.3-0.6,2.7-0.7,4.1l-7.8-0.6c-0.2,0-0.4-0.2-0.4-0.5l0.2-1.3L0.6,28c-0.2,0-0.4-0.2-0.4-0.5L0.9,23.3L0.9,23.3L0.9,23.3z M0.4,33.8C0.1,33.8,0,34,0,34.3l0.8,4.2c0,0.2,0.3,0.4,0.5,0.3l7.6-2l0.2,1.3c0,0.2,0.3,0.4,0.5,0.3l7.5-2.1c-0.4-1.3-0.7-2.7-0.8-4.1L0.4,33.8L0.4,33.8z M2.9,44.9c-0.1-0.2,0-0.5,0.2-0.6l14.5-6.9c0.5,1.3,1.3,2.5,2.2,3.6l-6.3,4.5c-0.2,0.1-0.5,0.1-0.6-0.1L12,44.3l-6.5,4.5c-0.2,0.1-0.5,0.1-0.6-0.1L2.9,44.9L2.9,44.9z M20.4,41.9L9.1,53.3c-0.2,0.2-0.2,0.5,0,0.6l3.3,2.7c0.2,0.2,0.5,0.1,0.6-0.1l4.6-6.4l1,0.9c0.2,0.2,0.5,0.1,0.6-0.1l4.4-6.4C22.4,43.8,21.3,42.9,20.4,41.9L20.4,41.9z M18.2,60.1c-0.2-0.1-0.3-0.3-0.2-0.6L24.6,45c1.2,0.6,2.6,1.1,3.9,1.4l-2,7.5c-0.1,0.2-0.3,0.4-0.5,0.3l-1.2-0.5l-2.1,7.6c-0.1,0.2-0.3,0.4-0.5,0.3L18.2,60.1L18.2,60.1L18.2,60.1z M29.6,46.6l-1.3,16c0,0.2,0.2,0.5,0.4,0.5H33c0.2,0,0.4-0.2,0.4-0.5l-0.6-7.8h1.3c0.2,0,0.4-0.2,0.4-0.5l-0.7-7.7c-0.8,0.1-1.5,0.2-2.3,0.2C30.9,46.7,30.2,46.7,29.6,46.6L29.6,46.6z M45.1,3.4c0.1-0.2,0-0.5-0.2-0.6l-4-1.5c-0.2-0.1-0.5,0.1-0.5,0.3l-2.1,7.6l-1.2-0.5c-0.2-0.1-0.5,0.1-0.5,0.3l-2,7.5c1.4,0.3,2.7,0.8,3.9,1.4L45.1,3.4L45.1,3.4z M53.9,9.7L42.6,21.1c-0.9-1-2-1.9-3.2-2.6l4.4-6.4c0.1-0.2,0.4-0.2,0.6-0.1l1,0.9l4.6-6.4c0.1-0.2,0.4-0.2,0.6-0.1l3.3,2.7C54,9.3,54,9.6,53.9,9.7L53.9,9.7z M59.9,18.7c0.2-0.1,0.3-0.4,0.2-0.6L58,14.4c-0.1-0.2-0.4-0.3-0.6-0.1l-6.5,4.5l-0.7-1.1c-0.1-0.2-0.4-0.3-0.6-0.1L43.3,22c0.9,1.1,1.6,2.3,2.2,3.6L59.9,18.7L59.9,18.7z M62.2,24.5l0.7,4.2c0,0.2-0.1,0.5-0.4,0.5l-15.9,1.5c-0.1-1.4-0.4-2.8-0.8-4.1l7.5-2.1c0.2-0.1,0.5,0.1,0.5,0.3l0.2,1.3l7.6-2C61.9,24.1,62.1,24.3,62.2,24.5L62.2,24.5L62.2,24.5z M61.5,40c0.2,0.1,0.5-0.1,0.5-0.3l0.7-4.2c0-0.2-0.1-0.5-0.4-0.5l-7.8-0.7l0.2-1.3c0-0.2-0.1-0.5-0.4-0.5l-7.8-0.6c0,1.4-0.3,2.8-0.7,4.1L61.5,40L61.5,40L61.5,40z M57.4,49.6c-0.1,0.2-0.4,0.3-0.6,0.1l-13.2-9.1c0.8-1.1,1.5-2.3,2-3.6l7.1,3.2c0.2,0.1,0.3,0.4,0.2,0.6L52.2,42l7.1,3.4c0.2,0.1,0.3,0.4,0.2,0.6L57.4,49.6C57.4,49.6,57.4,49.6,57.4,49.6z M39.7,44.2L49,57.3c0.1,0.2,0.4,0.2,0.6,0.1l3.3-2.7c0.2-0.2,0.2-0.4,0-0.6l-5.5-5.6l1-0.8c0.2-0.2,0.2-0.4,0-0.6l-5.5-5.5C42,42.6,40.9,43.5,39.7,44.2L39.7,44.2L39.7,44.2z M39.7,62c-0.2,0.1-0.5-0.1-0.5-0.3l-4.2-15.4c1.4-0.3,2.7-0.8,3.9-1.5l3.3,7c0.1,0.2,0,0.5-0.2,0.6l-1.2,0.5l3.3,7.1c0.1,0.2,0,0.5-0.2,0.6L39.7,62L39.7,62L39.7,62z"; + +export const OktaProviderBadge: React.FC = ({ + size, + width, + height, + ...props +}) => { + const { resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const w = size || width; + const h = size || height; + + if (!mounted) { + return ( +