diff --git a/.github/workflows/api-build-lint-push-containers.yml b/.github/workflows/api-build-lint-push-containers.yml index 5fee1a2d8d..0fe6d4a0b9 100644 --- a/.github/workflows/api-build-lint-push-containers.yml +++ b/.github/workflows/api-build-lint-push-containers.yml @@ -6,6 +6,7 @@ on: - "master" paths: - "api/**" + - "prowler/**" - ".github/workflows/api-build-lint-push-containers.yml" # Uncomment the code below to test this action on PRs diff --git a/.github/workflows/prowler-release-preparation.yml b/.github/workflows/prowler-release-preparation.yml new file mode 100644 index 0000000000..941f081747 --- /dev/null +++ b/.github/workflows/prowler-release-preparation.yml @@ -0,0 +1,257 @@ +name: Prowler Release Preparation + +run-name: Prowler Release Preparation for ${{ inputs.prowler_version }} + +on: + workflow_dispatch: + inputs: + prowler_version: + description: 'Prowler version to release (e.g., 5.9.0)' + required: true + type: string + +env: + PROWLER_VERSION: ${{ github.event.inputs.prowler_version }} + +jobs: + prepare-release: + if: github.repository == 'prowler-cloud/prowler' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + + - name: Install Poetry + run: | + python3 -m pip install --user poetry + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Parse version and determine branch + run: | + # Validate version format (reusing pattern from sdk-bump-version.yml) + 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]} + + # Export version components to environment + echo "MAJOR_VERSION=${MAJOR_VERSION}" >> "${GITHUB_ENV}" + echo "MINOR_VERSION=${MINOR_VERSION}" >> "${GITHUB_ENV}" + echo "PATCH_VERSION=${PATCH_VERSION}" >> "${GITHUB_ENV}" + + # Determine branch name (format: v5.9) + BRANCH_NAME="v${MAJOR_VERSION}.${MINOR_VERSION}" + echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_ENV}" + + # Calculate UI version (1.X.X format - matches Prowler minor version) + UI_VERSION="1.${MINOR_VERSION}.${PATCH_VERSION}" + echo "UI_VERSION=${UI_VERSION}" >> "${GITHUB_ENV}" + + # Calculate API version (1.X.X format - one minor version ahead) + API_MINOR_VERSION=$((MINOR_VERSION + 1)) + API_VERSION="1.${API_MINOR_VERSION}.${PATCH_VERSION}" + echo "API_VERSION=${API_VERSION}" >> "${GITHUB_ENV}" + + echo "Prowler version: $PROWLER_VERSION" + echo "Branch name: $BRANCH_NAME" + echo "UI version: $UI_VERSION" + echo "API version: $API_VERSION" + echo "Is minor release: $([ $PATCH_VERSION -eq 0 ] && echo 'true' || echo 'false')" + else + echo "Invalid version syntax: '$PROWLER_VERSION' (must be N.N.N)" >&2 + exit 1 + fi + + - name: Checkout existing branch for patch release + if: ${{ env.PATCH_VERSION != '0' }} + run: | + echo "Patch release detected, checking out existing branch $BRANCH_NAME..." + if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then + echo "Branch $BRANCH_NAME exists locally, checking out..." + git checkout "$BRANCH_NAME" + elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME"; then + echo "Branch $BRANCH_NAME exists remotely, checking out..." + git checkout -b "$BRANCH_NAME" "origin/$BRANCH_NAME" + else + echo "ERROR: Branch $BRANCH_NAME should exist for patch release $PROWLER_VERSION" + exit 1 + fi + + - name: Verify version in pyproject.toml + run: | + CURRENT_VERSION=$(grep '^version = ' pyproject.toml | sed -E 's/version = "([^"]+)"/\1/' | tr -d '[:space:]') + PROWLER_VERSION_TRIMMED=$(echo "$PROWLER_VERSION" | tr -d '[:space:]') + if [ "$CURRENT_VERSION" != "$PROWLER_VERSION_TRIMMED" ]; then + echo "ERROR: Version mismatch in pyproject.toml (expected: '$PROWLER_VERSION_TRIMMED', found: '$CURRENT_VERSION')" + exit 1 + fi + echo "✓ pyproject.toml version: $CURRENT_VERSION" + + - name: Verify version in prowler/config/config.py + run: | + CURRENT_VERSION=$(grep '^prowler_version = ' prowler/config/config.py | sed -E 's/prowler_version = "([^"]+)"/\1/' | tr -d '[:space:]') + PROWLER_VERSION_TRIMMED=$(echo "$PROWLER_VERSION" | tr -d '[:space:]') + if [ "$CURRENT_VERSION" != "$PROWLER_VERSION_TRIMMED" ]; then + echo "ERROR: Version mismatch in prowler/config/config.py (expected: '$PROWLER_VERSION_TRIMMED', found: '$CURRENT_VERSION')" + exit 1 + fi + echo "✓ prowler/config/config.py version: $CURRENT_VERSION" + + - name: Verify version in api/pyproject.toml + run: | + CURRENT_API_VERSION=$(grep '^version = ' api/pyproject.toml | sed -E 's/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 api/pyproject.toml (expected: '$API_VERSION_TRIMMED', found: '$CURRENT_API_VERSION')" + exit 1 + fi + echo "✓ api/pyproject.toml version: $CURRENT_API_VERSION" + + - name: Verify prowler dependency in api/pyproject.toml + if: ${{ env.PATCH_VERSION != '0' }} + run: | + CURRENT_PROWLER_REF=$(grep 'prowler @ git+https://github.com/prowler-cloud/prowler.git@' api/pyproject.toml | sed -E 's/.*@([^"]+)".*/\1/' | tr -d '[:space:]') + BRANCH_NAME_TRIMMED=$(echo "$BRANCH_NAME" | tr -d '[:space:]') + if [ "$CURRENT_PROWLER_REF" != "$BRANCH_NAME_TRIMMED" ]; then + echo "ERROR: Prowler dependency mismatch in api/pyproject.toml (expected: '$BRANCH_NAME_TRIMMED', found: '$CURRENT_PROWLER_REF')" + exit 1 + fi + echo "✓ api/pyproject.toml prowler dependency: $CURRENT_PROWLER_REF" + + - name: Verify version in api/src/backend/api/v1/views.py + 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: Create release branch for minor release + if: ${{ env.PATCH_VERSION == '0' }} + run: | + echo "Minor release detected (patch = 0), creating new branch $BRANCH_NAME..." + if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME" || git show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME"; then + echo "ERROR: Branch $BRANCH_NAME already exists for minor release $PROWLER_VERSION" + exit 1 + fi + git checkout -b "$BRANCH_NAME" + + - name: Update prowler dependency in api/pyproject.toml + if: ${{ env.PATCH_VERSION == '0' }} + run: | + CURRENT_PROWLER_REF=$(grep 'prowler @ git+https://github.com/prowler-cloud/prowler.git@' api/pyproject.toml | sed -E 's/.*@([^"]+)".*/\1/' | tr -d '[:space:]') + BRANCH_NAME_TRIMMED=$(echo "$BRANCH_NAME" | tr -d '[:space:]') + + # Minor release: update the dependency to use the new branch + echo "Minor release detected - updating prowler dependency from '$CURRENT_PROWLER_REF' to '$BRANCH_NAME_TRIMMED'" + sed -i "s|prowler @ git+https://github.com/prowler-cloud/prowler.git@[^\"]*\"|prowler @ git+https://github.com/prowler-cloud/prowler.git@$BRANCH_NAME_TRIMMED\"|" api/pyproject.toml + + # Verify the change was made + UPDATED_PROWLER_REF=$(grep 'prowler @ git+https://github.com/prowler-cloud/prowler.git@' api/pyproject.toml | sed -E 's/.*@([^"]+)".*/\1/' | tr -d '[:space:]') + if [ "$UPDATED_PROWLER_REF" != "$BRANCH_NAME_TRIMMED" ]; then + echo "ERROR: Failed to update prowler dependency in api/pyproject.toml" + exit 1 + fi + + # Update poetry lock file + echo "Updating poetry.lock file..." + cd api + poetry lock --no-update + cd .. + + # Commit and push the changes + git add api/pyproject.toml api/poetry.lock + git commit -m "chore(api): update prowler dependency to $BRANCH_NAME_TRIMMED for release $PROWLER_VERSION" + git push origin "$BRANCH_NAME" + + echo "✓ api/pyproject.toml prowler dependency updated to: $UPDATED_PROWLER_REF" + + - name: Extract changelog entries + run: | + set -e + + # Function to extract changelog for a specific version + extract_changelog() { + local file="$1" + local version="$2" + local output_file="$3" + + if [ ! -f "$file" ]; then + echo "Warning: $file not found, skipping..." + touch "$output_file" + return + fi + + # Extract changelog section for this version + awk -v version="$version" ' + /^## \[v?'"$version"'\]/ { found=1; next } + found && /^## \[v?[0-9]+\.[0-9]+\.[0-9]+\]/ { found=0 } + found && !/^## \[v?'"$version"'\]/ { print } + ' "$file" > "$output_file" + + # Remove --- separators + sed -i '/^---$/d' "$output_file" + + # Remove trailing empty lines + sed -i '/^$/d' "$output_file" + } + + # Extract changelogs + echo "Extracting changelog entries..." + extract_changelog "prowler/CHANGELOG.md" "$PROWLER_VERSION" "prowler_changelog.md" + extract_changelog "api/CHANGELOG.md" "$API_VERSION" "api_changelog.md" + extract_changelog "ui/CHANGELOG.md" "$UI_VERSION" "ui_changelog.md" + + # Combine changelogs in order: UI, API, SDK + > combined_changelog.md + + if [ -s "ui_changelog.md" ]; then + echo "## UI" >> combined_changelog.md + echo "" >> combined_changelog.md + cat ui_changelog.md >> combined_changelog.md + echo "" >> combined_changelog.md + fi + + if [ -s "api_changelog.md" ]; then + echo "## API" >> combined_changelog.md + echo "" >> combined_changelog.md + cat api_changelog.md >> combined_changelog.md + echo "" >> combined_changelog.md + fi + + if [ -s "prowler_changelog.md" ]; then + echo "## SDK" >> combined_changelog.md + echo "" >> combined_changelog.md + cat prowler_changelog.md >> combined_changelog.md + echo "" >> combined_changelog.md + fi + + echo "Combined changelog preview:" + cat combined_changelog.md + + - name: Create draft release + uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 + with: + tag_name: ${{ env.PROWLER_VERSION }} + name: Prowler ${{ env.PROWLER_VERSION }} + body_path: combined_changelog.md + draft: true + target_commitish: ${{ env.PATCH_VERSION == '0' && 'master' || env.BRANCH_NAME }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Clean up temporary files + run: | + rm -f prowler_changelog.md api_changelog.md ui_changelog.md combined_changelog.md diff --git a/.github/workflows/sdk-pull-request.yml b/.github/workflows/sdk-pull-request.yml index 646b72966e..f6d9df6cd5 100644 --- a/.github/workflows/sdk-pull-request.yml +++ b/.github/workflows/sdk-pull-request.yml @@ -102,8 +102,15 @@ jobs: run: | poetry run vulture --exclude "contrib,api,ui" --min-confidence 100 . + - name: Dockerfile - Check if Dockerfile has changed + id: dockerfile-changed-files + uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c # v46.0.5 + with: + files: | + Dockerfile + - name: Hadolint - if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true' + if: steps.dockerfile-changed-files.outputs.any_changed == 'true' run: | /tmp/hadolint Dockerfile --ignore=DL3013 diff --git a/README.md b/README.md index 84c661835c..ea249d473d 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,14 @@ If your workstation's architecture is incompatible, you can resolve this by: > Once configured, access the Prowler App at http://localhost:3000. Sign up using your email and password to get started. +### Common Issues with Docker Pull Installation + +> [!Note] + If you want to use AWS role assumption (e.g., with the "Connect assuming IAM Role" option), you may need to mount your local `.aws` directory into the container as a volume (e.g., `- "${HOME}/.aws:/home/prowler/.aws:ro"`). There are several ways to configure credentials for Docker containers. See the [Troubleshooting](./docs/troubleshooting.md) section for more details and examples. + +You can find more information in the [Troubleshooting](./docs/troubleshooting.md) section. + + ### From GitHub **Requirements** diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 59998e9fba..87d82df6f5 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -2,15 +2,33 @@ All notable changes to the **Prowler API** are documented in this file. -## [v1.10.0] (Prowler UNRELEASED) +## [v1.10.1] (Prowler v5.9.1) + +### Fixed +- Calculate failed findings during scans to prevent heavy database queries [(#8322)](https://github.com/prowler-cloud/prowler/pull/8322) + +--- + +## [v1.10.0] (Prowler v5.9.0) ### Added - - SSO with SAML support [(#8175)](https://github.com/prowler-cloud/prowler/pull/8175) +- `GET /resources/metadata`, `GET /resources/metadata/latest` and `GET /resources/latest` to expose resource metadata and latest scan results [(#8112)](https://github.com/prowler-cloud/prowler/pull/8112) + +### Changed - `/processors` endpoints to post-process findings. Currently, only the Mutelist processor is supported to allow to mute findings. +- Optimized the underlying queries for resources endpoints [(#8112)](https://github.com/prowler-cloud/prowler/pull/8112) +- Optimized include parameters for resources view [(#8229)](https://github.com/prowler-cloud/prowler/pull/8229) +- Optimized overview background tasks [(#8300)](https://github.com/prowler-cloud/prowler/pull/8300) + +### Fixed +- Search filter for findings and resources [(#8112)](https://github.com/prowler-cloud/prowler/pull/8112) +- RBAC is now applied to `GET /overviews/providers` [(#8277)](https://github.com/prowler-cloud/prowler/pull/8277) + +### Changed +- `POST /schedules/daily` returns a `409 CONFLICT` if already created [(#8258)](https://github.com/prowler-cloud/prowler/pull/8258) ### Security - - Enhanced password validation to enforce 12+ character passwords with special characters, uppercase, lowercase, and numbers [(#8225)](https://github.com/prowler-cloud/prowler/pull/8225) --- diff --git a/api/poetry.lock b/api/poetry.lock index 07ecc9ac73..771a036594 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "about-time" @@ -26,98 +26,103 @@ files = [ [[package]] name = "aiohttp" -version = "3.11.18" +version = "3.12.14" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "aiohttp-3.11.18-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:96264854fedbea933a9ca4b7e0c745728f01380691687b7365d18d9e977179c4"}, - {file = "aiohttp-3.11.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9602044ff047043430452bc3a2089743fa85da829e6fc9ee0025351d66c332b6"}, - {file = "aiohttp-3.11.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5691dc38750fcb96a33ceef89642f139aa315c8a193bbd42a0c33476fd4a1609"}, - {file = "aiohttp-3.11.18-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554c918ec43f8480b47a5ca758e10e793bd7410b83701676a4782672d670da55"}, - {file = "aiohttp-3.11.18-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a4076a2b3ba5b004b8cffca6afe18a3b2c5c9ef679b4d1e9859cf76295f8d4f"}, - {file = "aiohttp-3.11.18-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:767a97e6900edd11c762be96d82d13a1d7c4fc4b329f054e88b57cdc21fded94"}, - {file = "aiohttp-3.11.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0ddc9337a0fb0e727785ad4f41163cc314376e82b31846d3835673786420ef1"}, - {file = "aiohttp-3.11.18-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f414f37b244f2a97e79b98d48c5ff0789a0b4b4609b17d64fa81771ad780e415"}, - {file = "aiohttp-3.11.18-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fdb239f47328581e2ec7744ab5911f97afb10752332a6dd3d98e14e429e1a9e7"}, - {file = "aiohttp-3.11.18-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2c50bad73ed629cc326cc0f75aed8ecfb013f88c5af116f33df556ed47143eb"}, - {file = "aiohttp-3.11.18-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a8d8f20c39d3fa84d1c28cdb97f3111387e48209e224408e75f29c6f8e0861d"}, - {file = "aiohttp-3.11.18-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:106032eaf9e62fd6bc6578c8b9e6dc4f5ed9a5c1c7fb2231010a1b4304393421"}, - {file = "aiohttp-3.11.18-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:b491e42183e8fcc9901d8dcd8ae644ff785590f1727f76ca86e731c61bfe6643"}, - {file = "aiohttp-3.11.18-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ad8c745ff9460a16b710e58e06a9dec11ebc0d8f4dd82091cefb579844d69868"}, - {file = "aiohttp-3.11.18-cp310-cp310-win32.whl", hash = "sha256:8e57da93e24303a883146510a434f0faf2f1e7e659f3041abc4e3fb3f6702a9f"}, - {file = "aiohttp-3.11.18-cp310-cp310-win_amd64.whl", hash = "sha256:cc93a4121d87d9f12739fc8fab0a95f78444e571ed63e40bfc78cd5abe700ac9"}, - {file = "aiohttp-3.11.18-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:427fdc56ccb6901ff8088544bde47084845ea81591deb16f957897f0f0ba1be9"}, - {file = "aiohttp-3.11.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c828b6d23b984255b85b9b04a5b963a74278b7356a7de84fda5e3b76866597b"}, - {file = "aiohttp-3.11.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5c2eaa145bb36b33af1ff2860820ba0589e165be4ab63a49aebfd0981c173b66"}, - {file = "aiohttp-3.11.18-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d518ce32179f7e2096bf4e3e8438cf445f05fedd597f252de9f54c728574756"}, - {file = "aiohttp-3.11.18-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700055a6e05c2f4711011a44364020d7a10fbbcd02fbf3e30e8f7e7fddc8717"}, - {file = "aiohttp-3.11.18-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bd1cde83e4684324e6ee19adfc25fd649d04078179890be7b29f76b501de8e4"}, - {file = "aiohttp-3.11.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73b8870fe1c9a201b8c0d12c94fe781b918664766728783241a79e0468427e4f"}, - {file = "aiohttp-3.11.18-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25557982dd36b9e32c0a3357f30804e80790ec2c4d20ac6bcc598533e04c6361"}, - {file = "aiohttp-3.11.18-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e889c9df381a2433802991288a61e5a19ceb4f61bd14f5c9fa165655dcb1fd1"}, - {file = "aiohttp-3.11.18-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9ea345fda05bae217b6cce2acf3682ce3b13d0d16dd47d0de7080e5e21362421"}, - {file = "aiohttp-3.11.18-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9f26545b9940c4b46f0a9388fd04ee3ad7064c4017b5a334dd450f616396590e"}, - {file = "aiohttp-3.11.18-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3a621d85e85dccabd700294494d7179ed1590b6d07a35709bb9bd608c7f5dd1d"}, - {file = "aiohttp-3.11.18-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9c23fd8d08eb9c2af3faeedc8c56e134acdaf36e2117ee059d7defa655130e5f"}, - {file = "aiohttp-3.11.18-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9e6b0e519067caa4fd7fb72e3e8002d16a68e84e62e7291092a5433763dc0dd"}, - {file = "aiohttp-3.11.18-cp311-cp311-win32.whl", hash = "sha256:122f3e739f6607e5e4c6a2f8562a6f476192a682a52bda8b4c6d4254e1138f4d"}, - {file = "aiohttp-3.11.18-cp311-cp311-win_amd64.whl", hash = "sha256:e6f3c0a3a1e73e88af384b2e8a0b9f4fb73245afd47589df2afcab6b638fa0e6"}, - {file = "aiohttp-3.11.18-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:63d71eceb9cad35d47d71f78edac41fcd01ff10cacaa64e473d1aec13fa02df2"}, - {file = "aiohttp-3.11.18-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d1929da615840969929e8878d7951b31afe0bac883d84418f92e5755d7b49508"}, - {file = "aiohttp-3.11.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d0aebeb2392f19b184e3fdd9e651b0e39cd0f195cdb93328bd124a1d455cd0e"}, - {file = "aiohttp-3.11.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3849ead845e8444f7331c284132ab314b4dac43bfae1e3cf350906d4fff4620f"}, - {file = "aiohttp-3.11.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e8452ad6b2863709f8b3d615955aa0807bc093c34b8e25b3b52097fe421cb7f"}, - {file = "aiohttp-3.11.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b8d2b42073611c860a37f718b3d61ae8b4c2b124b2e776e2c10619d920350ec"}, - {file = "aiohttp-3.11.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40fbf91f6a0ac317c0a07eb328a1384941872f6761f2e6f7208b63c4cc0a7ff6"}, - {file = "aiohttp-3.11.18-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ff5625413fec55216da5eaa011cf6b0a2ed67a565914a212a51aa3755b0009"}, - {file = "aiohttp-3.11.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7f33a92a2fde08e8c6b0c61815521324fc1612f397abf96eed86b8e31618fdb4"}, - {file = "aiohttp-3.11.18-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:11d5391946605f445ddafda5eab11caf310f90cdda1fd99865564e3164f5cff9"}, - {file = "aiohttp-3.11.18-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3cc314245deb311364884e44242e00c18b5896e4fe6d5f942e7ad7e4cb640adb"}, - {file = "aiohttp-3.11.18-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0f421843b0f70740772228b9e8093289924359d306530bcd3926f39acbe1adda"}, - {file = "aiohttp-3.11.18-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e220e7562467dc8d589e31c1acd13438d82c03d7f385c9cd41a3f6d1d15807c1"}, - {file = "aiohttp-3.11.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ab2ef72f8605046115bc9aa8e9d14fd49086d405855f40b79ed9e5c1f9f4faea"}, - {file = "aiohttp-3.11.18-cp312-cp312-win32.whl", hash = "sha256:12a62691eb5aac58d65200c7ae94d73e8a65c331c3a86a2e9670927e94339ee8"}, - {file = "aiohttp-3.11.18-cp312-cp312-win_amd64.whl", hash = "sha256:364329f319c499128fd5cd2d1c31c44f234c58f9b96cc57f743d16ec4f3238c8"}, - {file = "aiohttp-3.11.18-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:474215ec618974054cf5dc465497ae9708543cbfc312c65212325d4212525811"}, - {file = "aiohttp-3.11.18-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ced70adf03920d4e67c373fd692123e34d3ac81dfa1c27e45904a628567d804"}, - {file = "aiohttp-3.11.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d9f6c0152f8d71361905aaf9ed979259537981f47ad099c8b3d81e0319814bd"}, - {file = "aiohttp-3.11.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a35197013ed929c0aed5c9096de1fc5a9d336914d73ab3f9df14741668c0616c"}, - {file = "aiohttp-3.11.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:540b8a1f3a424f1af63e0af2d2853a759242a1769f9f1ab053996a392bd70118"}, - {file = "aiohttp-3.11.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9e6710ebebfce2ba21cee6d91e7452d1125100f41b906fb5af3da8c78b764c1"}, - {file = "aiohttp-3.11.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8af2ef3b4b652ff109f98087242e2ab974b2b2b496304063585e3d78de0b000"}, - {file = "aiohttp-3.11.18-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28c3f975e5ae3dbcbe95b7e3dcd30e51da561a0a0f2cfbcdea30fc1308d72137"}, - {file = "aiohttp-3.11.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c28875e316c7b4c3e745172d882d8a5c835b11018e33432d281211af35794a93"}, - {file = "aiohttp-3.11.18-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:13cd38515568ae230e1ef6919e2e33da5d0f46862943fcda74e7e915096815f3"}, - {file = "aiohttp-3.11.18-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0e2a92101efb9f4c2942252c69c63ddb26d20f46f540c239ccfa5af865197bb8"}, - {file = "aiohttp-3.11.18-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e6d3e32b8753c8d45ac550b11a1090dd66d110d4ef805ffe60fa61495360b3b2"}, - {file = "aiohttp-3.11.18-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ea4cf2488156e0f281f93cc2fd365025efcba3e2d217cbe3df2840f8c73db261"}, - {file = "aiohttp-3.11.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d4df95ad522c53f2b9ebc07f12ccd2cb15550941e11a5bbc5ddca2ca56316d7"}, - {file = "aiohttp-3.11.18-cp313-cp313-win32.whl", hash = "sha256:cdd1bbaf1e61f0d94aced116d6e95fe25942f7a5f42382195fd9501089db5d78"}, - {file = "aiohttp-3.11.18-cp313-cp313-win_amd64.whl", hash = "sha256:bdd619c27e44382cf642223f11cfd4d795161362a5a1fc1fa3940397bc89db01"}, - {file = "aiohttp-3.11.18-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:469ac32375d9a716da49817cd26f1916ec787fc82b151c1c832f58420e6d3533"}, - {file = "aiohttp-3.11.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3cec21dd68924179258ae14af9f5418c1ebdbba60b98c667815891293902e5e0"}, - {file = "aiohttp-3.11.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b426495fb9140e75719b3ae70a5e8dd3a79def0ae3c6c27e012fc59f16544a4a"}, - {file = "aiohttp-3.11.18-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad2f41203e2808616292db5d7170cccf0c9f9c982d02544443c7eb0296e8b0c7"}, - {file = "aiohttp-3.11.18-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bc0ae0a5e9939e423e065a3e5b00b24b8379f1db46046d7ab71753dfc7dd0e1"}, - {file = "aiohttp-3.11.18-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe7cdd3f7d1df43200e1c80f1aed86bb36033bf65e3c7cf46a2b97a253ef8798"}, - {file = "aiohttp-3.11.18-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5199be2a2f01ffdfa8c3a6f5981205242986b9e63eb8ae03fd18f736e4840721"}, - {file = "aiohttp-3.11.18-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ccec9e72660b10f8e283e91aa0295975c7bd85c204011d9f5eb69310555cf30"}, - {file = "aiohttp-3.11.18-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1596ebf17e42e293cbacc7a24c3e0dc0f8f755b40aff0402cb74c1ff6baec1d3"}, - {file = "aiohttp-3.11.18-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:eab7b040a8a873020113ba814b7db7fa935235e4cbaf8f3da17671baa1024863"}, - {file = "aiohttp-3.11.18-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5d61df4a05476ff891cff0030329fee4088d40e4dc9b013fac01bc3c745542c2"}, - {file = "aiohttp-3.11.18-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:46533e6792e1410f9801d09fd40cbbff3f3518d1b501d6c3c5b218f427f6ff08"}, - {file = "aiohttp-3.11.18-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c1b90407ced992331dd6d4f1355819ea1c274cc1ee4d5b7046c6761f9ec11829"}, - {file = "aiohttp-3.11.18-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a2fd04ae4971b914e54fe459dd7edbbd3f2ba875d69e057d5e3c8e8cac094935"}, - {file = "aiohttp-3.11.18-cp39-cp39-win32.whl", hash = "sha256:b2f317d1678002eee6fe85670039fb34a757972284614638f82b903a03feacdc"}, - {file = "aiohttp-3.11.18-cp39-cp39-win_amd64.whl", hash = "sha256:5e7007b8d1d09bce37b54111f593d173691c530b80f27c6493b928dabed9e6ef"}, - {file = "aiohttp-3.11.18.tar.gz", hash = "sha256:ae856e1138612b7e412db63b7708735cff4d38d0399f6a5435d3dac2669f558a"}, + {file = "aiohttp-3.12.14-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:906d5075b5ba0dd1c66fcaaf60eb09926a9fef3ca92d912d2a0bbdbecf8b1248"}, + {file = "aiohttp-3.12.14-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c875bf6fc2fd1a572aba0e02ef4e7a63694778c5646cdbda346ee24e630d30fb"}, + {file = "aiohttp-3.12.14-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fbb284d15c6a45fab030740049d03c0ecd60edad9cd23b211d7e11d3be8d56fd"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38e360381e02e1a05d36b223ecab7bc4a6e7b5ab15760022dc92589ee1d4238c"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aaf90137b5e5d84a53632ad95ebee5c9e3e7468f0aab92ba3f608adcb914fa95"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e532a25e4a0a2685fa295a31acf65e027fbe2bea7a4b02cdfbbba8a064577663"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eab9762c4d1b08ae04a6c77474e6136da722e34fdc0e6d6eab5ee93ac29f35d1"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abe53c3812b2899889a7fca763cdfaeee725f5be68ea89905e4275476ffd7e61"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5760909b7080aa2ec1d320baee90d03b21745573780a072b66ce633eb77a8656"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:02fcd3f69051467bbaa7f84d7ec3267478c7df18d68b2e28279116e29d18d4f3"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4dcd1172cd6794884c33e504d3da3c35648b8be9bfa946942d353b939d5f1288"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:224d0da41355b942b43ad08101b1b41ce633a654128ee07e36d75133443adcda"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e387668724f4d734e865c1776d841ed75b300ee61059aca0b05bce67061dcacc"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:dec9cde5b5a24171e0b0a4ca064b1414950904053fb77c707efd876a2da525d8"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bbad68a2af4877cc103cd94af9160e45676fc6f0c14abb88e6e092b945c2c8e3"}, + {file = "aiohttp-3.12.14-cp310-cp310-win32.whl", hash = "sha256:ee580cb7c00bd857b3039ebca03c4448e84700dc1322f860cf7a500a6f62630c"}, + {file = "aiohttp-3.12.14-cp310-cp310-win_amd64.whl", hash = "sha256:cf4f05b8cea571e2ccc3ca744e35ead24992d90a72ca2cf7ab7a2efbac6716db"}, + {file = "aiohttp-3.12.14-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f4552ff7b18bcec18b60a90c6982049cdb9dac1dba48cf00b97934a06ce2e597"}, + {file = "aiohttp-3.12.14-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8283f42181ff6ccbcf25acaae4e8ab2ff7e92b3ca4a4ced73b2c12d8cd971393"}, + {file = "aiohttp-3.12.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:040afa180ea514495aaff7ad34ec3d27826eaa5d19812730fe9e529b04bb2179"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b413c12f14c1149f0ffd890f4141a7471ba4b41234fe4fd4a0ff82b1dc299dbb"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d6f607ce2e1a93315414e3d448b831238f1874b9968e1195b06efaa5c87e245"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:565e70d03e924333004ed101599902bba09ebb14843c8ea39d657f037115201b"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4699979560728b168d5ab63c668a093c9570af2c7a78ea24ca5212c6cdc2b641"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad5fdf6af93ec6c99bf800eba3af9a43d8bfd66dce920ac905c817ef4a712afe"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ac76627c0b7ee0e80e871bde0d376a057916cb008a8f3ffc889570a838f5cc7"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:798204af1180885651b77bf03adc903743a86a39c7392c472891649610844635"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4f1205f97de92c37dd71cf2d5bcfb65fdaed3c255d246172cce729a8d849b4da"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:76ae6f1dd041f85065d9df77c6bc9c9703da9b5c018479d20262acc3df97d419"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a194ace7bc43ce765338ca2dfb5661489317db216ea7ea700b0332878b392cab"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:16260e8e03744a6fe3fcb05259eeab8e08342c4c33decf96a9dad9f1187275d0"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c779e5ebbf0e2e15334ea404fcce54009dc069210164a244d2eac8352a44b28"}, + {file = "aiohttp-3.12.14-cp311-cp311-win32.whl", hash = "sha256:a289f50bf1bd5be227376c067927f78079a7bdeccf8daa6a9e65c38bae14324b"}, + {file = "aiohttp-3.12.14-cp311-cp311-win_amd64.whl", hash = "sha256:0b8a69acaf06b17e9c54151a6c956339cf46db4ff72b3ac28516d0f7068f4ced"}, + {file = "aiohttp-3.12.14-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a0ecbb32fc3e69bc25efcda7d28d38e987d007096cbbeed04f14a6662d0eee22"}, + {file = "aiohttp-3.12.14-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0400f0ca9bb3e0b02f6466421f253797f6384e9845820c8b05e976398ac1d81a"}, + {file = "aiohttp-3.12.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a56809fed4c8a830b5cae18454b7464e1529dbf66f71c4772e3cfa9cbec0a1ff"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f2e373276e4755691a963e5d11756d093e346119f0627c2d6518208483fb6d"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ca39e433630e9a16281125ef57ece6817afd1d54c9f1bf32e901f38f16035869"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c748b3f8b14c77720132b2510a7d9907a03c20ba80f469e58d5dfd90c079a1c"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a568abe1b15ce69d4cc37e23020720423f0728e3cb1f9bcd3f53420ec3bfe7"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9888e60c2c54eaf56704b17feb558c7ed6b7439bca1e07d4818ab878f2083660"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3006a1dc579b9156de01e7916d38c63dc1ea0679b14627a37edf6151bc530088"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa8ec5c15ab80e5501a26719eb48a55f3c567da45c6ea5bb78c52c036b2655c7"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:39b94e50959aa07844c7fe2206b9f75d63cc3ad1c648aaa755aa257f6f2498a9"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:04c11907492f416dad9885d503fbfc5dcb6768d90cad8639a771922d584609d3"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:88167bd9ab69bb46cee91bd9761db6dfd45b6e76a0438c7e884c3f8160ff21eb"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:791504763f25e8f9f251e4688195e8b455f8820274320204f7eafc467e609425"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785b112346e435dd3a1a67f67713a3fe692d288542f1347ad255683f066d8e0"}, + {file = "aiohttp-3.12.14-cp312-cp312-win32.whl", hash = "sha256:15f5f4792c9c999a31d8decf444e79fcfd98497bf98e94284bf390a7bb8c1729"}, + {file = "aiohttp-3.12.14-cp312-cp312-win_amd64.whl", hash = "sha256:3b66e1a182879f579b105a80d5c4bd448b91a57e8933564bf41665064796a338"}, + {file = "aiohttp-3.12.14-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3143a7893d94dc82bc409f7308bc10d60285a3cd831a68faf1aa0836c5c3c767"}, + {file = "aiohttp-3.12.14-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3d62ac3d506cef54b355bd34c2a7c230eb693880001dfcda0bf88b38f5d7af7e"}, + {file = "aiohttp-3.12.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48e43e075c6a438937c4de48ec30fa8ad8e6dfef122a038847456bfe7b947b63"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:077b4488411a9724cecc436cbc8c133e0d61e694995b8de51aaf351c7578949d"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d8c35632575653f297dcbc9546305b2c1133391089ab925a6a3706dfa775ccab"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b8ce87963f0035c6834b28f061df90cf525ff7c9b6283a8ac23acee6502afd4"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a2cf66e32a2563bb0766eb24eae7e9a269ac0dc48db0aae90b575dc9583026"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdea089caf6d5cde975084a884c72d901e36ef9c2fd972c9f51efbbc64e96fbd"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7865f27db67d49e81d463da64a59365ebd6b826e0e4847aa111056dcb9dc88"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0ab5b38a6a39781d77713ad930cb5e7feea6f253de656a5f9f281a8f5931b086"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b3b15acee5c17e8848d90a4ebc27853f37077ba6aec4d8cb4dbbea56d156933"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e4c972b0bdaac167c1e53e16a16101b17c6d0ed7eac178e653a07b9f7fad7151"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7442488b0039257a3bdbc55f7209587911f143fca11df9869578db6c26feeeb8"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f68d3067eecb64c5e9bab4a26aa11bd676f4c70eea9ef6536b0a4e490639add3"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f88d3704c8b3d598a08ad17d06006cb1ca52a1182291f04979e305c8be6c9758"}, + {file = "aiohttp-3.12.14-cp313-cp313-win32.whl", hash = "sha256:a3c99ab19c7bf375c4ae3debd91ca5d394b98b6089a03231d4c580ef3c2ae4c5"}, + {file = "aiohttp-3.12.14-cp313-cp313-win_amd64.whl", hash = "sha256:3f8aad695e12edc9d571f878c62bedc91adf30c760c8632f09663e5f564f4baa"}, + {file = "aiohttp-3.12.14-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b8cc6b05e94d837bcd71c6531e2344e1ff0fb87abe4ad78a9261d67ef5d83eae"}, + {file = "aiohttp-3.12.14-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1dcb015ac6a3b8facd3677597edd5ff39d11d937456702f0bb2b762e390a21b"}, + {file = "aiohttp-3.12.14-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3779ed96105cd70ee5e85ca4f457adbce3d9ff33ec3d0ebcdf6c5727f26b21b3"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:717a0680729b4ebd7569c1dcd718c46b09b360745fd8eb12317abc74b14d14d0"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b5dd3a2ef7c7e968dbbac8f5574ebeac4d2b813b247e8cec28174a2ba3627170"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4710f77598c0092239bc12c1fcc278a444e16c7032d91babf5abbf7166463f7b"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f3e9f75ae842a6c22a195d4a127263dbf87cbab729829e0bd7857fb1672400b2"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f9c8d55d6802086edd188e3a7d85a77787e50d56ce3eb4757a3205fa4657922"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79b29053ff3ad307880d94562cca80693c62062a098a5776ea8ef5ef4b28d140"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:23e1332fff36bebd3183db0c7a547a1da9d3b4091509f6d818e098855f2f27d3"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:a564188ce831fd110ea76bcc97085dd6c625b427db3f1dbb14ca4baa1447dcbc"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a7a1b4302f70bb3ec40ca86de82def532c97a80db49cac6a6700af0de41af5ee"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:1b07ccef62950a2519f9bfc1e5b294de5dd84329f444ca0b329605ea787a3de5"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:938bd3ca6259e7e48b38d84f753d548bd863e0c222ed6ee6ace3fd6752768a84"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8bc784302b6b9f163b54c4e93d7a6f09563bd01ff2b841b29ed3ac126e5040bf"}, + {file = "aiohttp-3.12.14-cp39-cp39-win32.whl", hash = "sha256:a3416f95961dd7d5393ecff99e3f41dc990fb72eda86c11f2a60308ac6dcd7a0"}, + {file = "aiohttp-3.12.14-cp39-cp39-win_amd64.whl", hash = "sha256:196858b8820d7f60578f8b47e5669b3195c21d8ab261e39b1d705346458f445f"}, + {file = "aiohttp-3.12.14.tar.gz", hash = "sha256:6e06e120e34d93100de448fd941522e11dafa78ef1a893c179901b7d66aa29f2"}, ] [package.dependencies] -aiohappyeyeballs = ">=2.3.0" -aiosignal = ">=1.1.2" +aiohappyeyeballs = ">=2.5.0" +aiosignal = ">=1.4.0" attrs = ">=17.3.0" frozenlist = ">=1.1.1" multidict = ">=4.5,<7.0" @@ -125,22 +130,23 @@ propcache = ">=0.2.0" yarl = ">=1.17.0,<2.0" [package.extras] -speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.2.0) ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "brotlicffi ; platform_python_implementation != \"CPython\""] [[package]] name = "aiosignal" -version = "1.3.2" +version = "1.4.0" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5"}, - {file = "aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54"}, + {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 = "alive-progress" @@ -4988,6 +4994,7 @@ files = [ {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"}, @@ -4996,6 +5003,7 @@ files = [ {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"}, @@ -5004,6 +5012,7 @@ files = [ {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"}, @@ -5012,6 +5021,7 @@ files = [ {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"}, @@ -5020,6 +5030,7 @@ files = [ {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"}, diff --git a/api/pyproject.toml b/api/pyproject.toml index b01e2cda14..f7cd0d1808 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -38,7 +38,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.9.0" +version = "1.10.1" [project.scripts] celery = "src.backend.config.settings.celery" diff --git a/api/src/backend/api/adapters.py b/api/src/backend/api/adapters.py index bf3fb54655..e09dc972b4 100644 --- a/api/src/backend/api/adapters.py +++ b/api/src/backend/api/adapters.py @@ -65,5 +65,7 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): role=role, tenant_id=tenant.id, ) + else: + request.session["saml_user_created"] = str(user.id) return user diff --git a/api/src/backend/api/db_utils.py b/api/src/backend/api/db_utils.py index c2da9ca32f..d7ec718f11 100644 --- a/api/src/backend/api/db_utils.py +++ b/api/src/backend/api/db_utils.py @@ -175,6 +175,29 @@ def create_objects_in_batches( model.objects.bulk_create(chunk, batch_size) +def update_objects_in_batches( + tenant_id: str, model, objects: list, fields: list, batch_size: int = 500 +): + """ + Bulk-update model instances in repeated, per-tenant RLS transactions. + + All chunks execute in their own transaction, so no single transaction + grows too large. + + Args: + tenant_id (str): UUID string of the tenant under which to set RLS. + model: Django model class whose `.objects.bulk_update()` will be called. + objects (list): List of model instances (saved) to bulk-update. + fields (list): List of field names to update. + batch_size (int): Maximum number of objects per bulk_update call. + """ + total = len(objects) + for start in range(0, total, batch_size): + chunk = objects[start : start + batch_size] + with rls_transaction(value=tenant_id, parameter=POSTGRES_TENANT_VAR): + model.objects.bulk_update(chunk, fields, batch_size) + + # Postgres Enums diff --git a/api/src/backend/api/exceptions.py b/api/src/backend/api/exceptions.py index a6034de806..b622118674 100644 --- a/api/src/backend/api/exceptions.py +++ b/api/src/backend/api/exceptions.py @@ -78,3 +78,21 @@ def custom_exception_handler(exc, context): message_item["message"] for message_item in exc.detail["messages"] ] return exception_handler(exc, context) + + +class ConflictException(APIException): + status_code = status.HTTP_409_CONFLICT + default_detail = "A conflict occurred. The resource already exists." + default_code = "conflict" + + def __init__(self, detail=None, code=None, pointer=None): + error_detail = { + "detail": detail or self.default_detail, + "status": self.status_code, + "code": self.default_code, + } + + if pointer: + error_detail["source"] = {"pointer": pointer} + + super().__init__(detail=[error_detail]) diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index ee69359000..7418d2daf9 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -1,5 +1,6 @@ from datetime import date, datetime, timedelta, timezone +from dateutil.parser import parse from django.conf import settings from django.db.models import Q from django_filters.rest_framework import ( @@ -339,6 +340,8 @@ class ResourceFilter(ProviderRelationshipFilterSet): tags = CharFilter(method="filter_tag") inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") updated_at = DateFilter(field_name="updated_at", lookup_expr="date") + scan = UUIDFilter(field_name="provider__scan", lookup_expr="exact") + scan__in = UUIDInFilter(field_name="provider__scan", lookup_expr="in") class Meta: model = Resource @@ -353,6 +356,82 @@ class ResourceFilter(ProviderRelationshipFilterSet): "updated_at": ["gte", "lte"], } + def filter_queryset(self, queryset): + if not (self.data.get("scan") or self.data.get("scan__in")) and not ( + self.data.get("updated_at") + or self.data.get("updated_at__date") + or self.data.get("updated_at__gte") + or self.data.get("updated_at__lte") + ): + raise ValidationError( + [ + { + "detail": "At least one date filter is required: filter[updated_at], filter[updated_at.gte], " + "or filter[updated_at.lte].", + "status": 400, + "source": {"pointer": "/data/attributes/updated_at"}, + "code": "required", + } + ] + ) + + gte_date = ( + parse(self.data.get("updated_at__gte")).date() + if self.data.get("updated_at__gte") + else datetime.now(timezone.utc).date() + ) + lte_date = ( + parse(self.data.get("updated_at__lte")).date() + if self.data.get("updated_at__lte") + else datetime.now(timezone.utc).date() + ) + + if abs(lte_date - gte_date) > timedelta( + days=settings.FINDINGS_MAX_DAYS_IN_RANGE + ): + raise ValidationError( + [ + { + "detail": f"The date range cannot exceed {settings.FINDINGS_MAX_DAYS_IN_RANGE} days.", + "status": 400, + "source": {"pointer": "/data/attributes/updated_at"}, + "code": "invalid", + } + ] + ) + + return super().filter_queryset(queryset) + + def filter_tag_key(self, queryset, name, value): + return queryset.filter(Q(tags__key=value) | Q(tags__key__icontains=value)) + + def filter_tag_value(self, queryset, name, value): + return queryset.filter(Q(tags__value=value) | Q(tags__value__icontains=value)) + + def filter_tag(self, queryset, name, value): + # We won't know what the user wants to filter on just based on the value, + # and we don't want to build special filtering logic for every possible + # provider tag spec, so we'll just do a full text search + return queryset.filter(tags__text_search=value) + + +class LatestResourceFilter(ProviderRelationshipFilterSet): + tag_key = CharFilter(method="filter_tag_key") + tag_value = CharFilter(method="filter_tag_value") + tag = CharFilter(method="filter_tag") + tags = CharFilter(method="filter_tag") + + class Meta: + model = Resource + fields = { + "provider": ["exact", "in"], + "uid": ["exact", "icontains"], + "name": ["exact", "icontains"], + "region": ["exact", "icontains", "in"], + "service": ["exact", "icontains", "in"], + "type": ["exact", "icontains", "in"], + } + def filter_tag_key(self, queryset, name, value): return queryset.filter(Q(tags__key=value) | Q(tags__key__icontains=value)) diff --git a/api/src/backend/api/migrations/0036_rfm_tenant_finding_index_partitions.py b/api/src/backend/api/migrations/0036_rfm_tenant_finding_index_partitions.py new file mode 100644 index 0000000000..cd360eb7e4 --- /dev/null +++ b/api/src/backend/api/migrations/0036_rfm_tenant_finding_index_partitions.py @@ -0,0 +1,30 @@ +from functools import partial + +from django.db import migrations + +from api.db_utils import create_index_on_partitions, drop_index_on_partitions + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("api", "0035_finding_muted_reason"), + ] + + operations = [ + migrations.RunPython( + partial( + create_index_on_partitions, + parent_table="resource_finding_mappings", + index_name="rfm_tenant_finding_idx", + columns="tenant_id, finding_id", + method="BTREE", + ), + reverse_code=partial( + drop_index_on_partitions, + parent_table="resource_finding_mappings", + index_name="rfm_tenant_finding_idx", + ), + ), + ] diff --git a/api/src/backend/api/migrations/0037_rfm_tenant_finding_index_parent.py b/api/src/backend/api/migrations/0037_rfm_tenant_finding_index_parent.py new file mode 100644 index 0000000000..178cf900f6 --- /dev/null +++ b/api/src/backend/api/migrations/0037_rfm_tenant_finding_index_parent.py @@ -0,0 +1,17 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0036_rfm_tenant_finding_index_partitions"), + ] + + operations = [ + migrations.AddIndex( + model_name="resourcefindingmapping", + index=models.Index( + fields=["tenant_id", "finding_id"], + name="rfm_tenant_finding_idx", + ), + ), + ] diff --git a/api/src/backend/api/migrations/0038_resource_failed_findings_count.py b/api/src/backend/api/migrations/0038_resource_failed_findings_count.py new file mode 100644 index 0000000000..7bf9b4c856 --- /dev/null +++ b/api/src/backend/api/migrations/0038_resource_failed_findings_count.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0037_rfm_tenant_finding_index_parent"), + ] + + operations = [ + migrations.AddField( + model_name="resource", + name="failed_findings_count", + field=models.IntegerField(default=0), + ) + ] diff --git a/api/src/backend/api/migrations/0039_resource_resources_failed_findings_idx.py b/api/src/backend/api/migrations/0039_resource_resources_failed_findings_idx.py new file mode 100644 index 0000000000..2c2ac3ce31 --- /dev/null +++ b/api/src/backend/api/migrations/0039_resource_resources_failed_findings_idx.py @@ -0,0 +1,20 @@ +from django.contrib.postgres.operations import AddIndexConcurrently +from django.db import migrations, models + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("api", "0038_resource_failed_findings_count"), + ] + + operations = [ + AddIndexConcurrently( + model_name="resource", + index=models.Index( + fields=["tenant_id", "-failed_findings_count", "id"], + name="resources_failed_findings_idx", + ), + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 0d486afed3..f81ebafaf3 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -561,6 +561,8 @@ class Resource(RowLevelSecurityProtectedModel): details = models.TextField(blank=True, null=True) partition = models.TextField(blank=True, null=True) + failed_findings_count = models.IntegerField(default=0) + # Relationships tags = models.ManyToManyField( ResourceTag, @@ -607,6 +609,10 @@ class Resource(RowLevelSecurityProtectedModel): fields=["tenant_id", "provider_id"], name="resources_tenant_provider_idx", ), + models.Index( + fields=["tenant_id", "-failed_findings_count", "id"], + name="resources_failed_findings_idx", + ), ] constraints = [ @@ -849,6 +855,12 @@ class ResourceFindingMapping(PostgresPartitionedModel, RowLevelSecurityProtected # - tenant_id # - id + indexes = [ + models.Index( + fields=["tenant_id", "finding_id"], + name="rfm_tenant_finding_idx", + ), + ] constraints = [ models.UniqueConstraint( fields=("tenant_id", "resource_id", "finding_id"), diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 0164b63b7c..3b500422dc 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.9.0 + version: 1.10.1 description: |- Prowler API specification. @@ -4677,6 +4677,7 @@ paths: - tags - provider - findings + - failed_findings_count - url - type description: endpoint return only specific fields in the response on a per-type @@ -4809,6 +4810,365 @@ paths: description: Multiple values may be separated by commas. explode: false style: form + - in: query + name: filter[scan] + schema: + type: string + format: uuid + - in: query + name: filter[scan__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: filter[service] + schema: + type: string + - in: query + name: filter[service__icontains] + schema: + type: string + - in: query + name: filter[service__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[tag] + schema: + type: string + - in: query + name: filter[tag_key] + schema: + type: string + - in: query + name: filter[tag_value] + schema: + type: string + - in: query + name: filter[tags] + schema: + type: string + - in: query + name: filter[type] + schema: + type: string + - in: query + name: filter[type__icontains] + schema: + type: string + - in: query + name: filter[type__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[uid] + schema: + type: string + - in: query + name: filter[uid__icontains] + schema: + type: string + - in: query + name: filter[updated_at] + schema: + type: string + format: date + description: At least one of the variations of the `filter[updated_at]` filter + must be provided. + required: true + - in: query + name: filter[updated_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[updated_at__lte] + schema: + type: string + format: date-time + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - findings + - provider + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false + - name: page[number] + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page[size] + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - provider_uid + - -provider_uid + - uid + - -uid + - name + - -name + - region + - -region + - service + - -service + - type + - -type + - inserted_at + - -inserted_at + - updated_at + - -updated_at + explode: false + tags: + - Resource + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedResourceList' + description: '' + /api/v1/resources/{id}: + get: + operationId: resources_retrieve + description: Fetch detailed information about a specific resource by their ID. + A Resource is an object that is discovered by Prowler. It can be anything + from a single host to a whole VPC. + summary: Retrieve data for a resource + parameters: + - in: query + name: fields[resources] + schema: + type: array + items: + type: string + enum: + - inserted_at + - updated_at + - uid + - name + - region + - service + - tags + - provider + - findings + - failed_findings_count + - url + - type + 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 resource. + required: true + - in: query + name: include + schema: + type: array + items: + type: string + enum: + - findings + - provider + description: include query parameter to allow the client to customize which + related resources should be returned. + explode: false + tags: + - Resource + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ResourceResponse' + description: '' + /api/v1/resources/latest: + get: + operationId: resources_latest_retrieve + description: Retrieve a list of the latest resources from the latest scans for + each provider with options for filtering by various criteria. + summary: List the latest resources + parameters: + - in: query + name: fields[resources] + schema: + type: array + items: + type: string + enum: + - inserted_at + - updated_at + - uid + - name + - region + - service + - tags + - provider + - findings + - failed_findings_count + - url + - type + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[name] + schema: + type: string + - in: query + name: filter[name__icontains] + schema: + type: string + - in: query + name: filter[provider] + schema: + type: string + format: uuid + - in: query + name: filter[provider__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_alias] + schema: + type: string + - in: query + name: filter[provider_alias__icontains] + schema: + type: string + - in: query + name: filter[provider_alias__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + enum: + - aws + - azure + - gcp + - kubernetes + - m365 + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + enum: + - aws + - azure + - gcp + - kubernetes + - m365 + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + explode: false + style: form + - in: query + name: filter[provider_uid] + schema: + type: string + - in: query + name: filter[provider_uid__icontains] + schema: + type: string + - in: query + name: filter[provider_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form - name: filter[search] required: false in: query @@ -4873,21 +5233,6 @@ paths: name: filter[uid__icontains] schema: type: string - - in: query - name: filter[updated_at] - schema: - type: string - format: date - - in: query - name: filter[updated_at__gte] - schema: - type: string - format: date-time - - in: query - name: filter[updated_at__lte] - schema: - type: string - format: date-time - in: query name: include schema: @@ -4900,18 +5245,6 @@ paths: description: include query parameter to allow the client to customize which related resources should be returned. explode: false - - name: page[number] - required: false - in: query - description: A page number within the paginated result set. - schema: - type: integer - - name: page[size] - required: false - in: query - description: Number of results to return per page. - schema: - type: integer - name: sort required: false in: query @@ -4947,55 +5280,277 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/PaginatedResourceList' + $ref: '#/components/schemas/ResourceResponse' description: '' - /api/v1/resources/{id}: + /api/v1/resources/metadata: get: - operationId: resources_retrieve - description: Fetch detailed information about a specific resource by their ID. - A Resource is an object that is discovered by Prowler. It can be anything - from a single host to a whole VPC. - summary: Retrieve data for a resource + operationId: resources_metadata_retrieve + description: Fetch unique metadata values from a set of resources. This is useful + for dynamic filtering. + summary: Retrieve metadata values from resources parameters: - in: query - name: fields[resources] + name: fields[resources-metadata] schema: type: array items: type: string enum: - - inserted_at - - updated_at - - uid - - name - - region - - service - - tags - - provider - - findings - - url - - type + - services + - regions + - types description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false - - in: path - name: id + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[inserted_at__lte] + schema: + type: string + format: date-time + - in: query + name: filter[name] + schema: + type: string + - in: query + name: filter[name__icontains] + schema: + type: string + - in: query + name: filter[provider] schema: type: string format: uuid - description: A UUID string identifying this resource. - required: true - in: query - name: include + name: filter[provider__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_alias] + schema: + type: string + - in: query + name: filter[provider_alias__icontains] + schema: + type: string + - in: query + name: filter[provider_alias__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + enum: + - aws + - azure + - gcp + - kubernetes + - m365 + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + - in: query + name: filter[provider_type__in] schema: type: array items: type: string enum: - - findings - - provider - description: include query parameter to allow the client to customize which - related resources should be returned. + - aws + - azure + - gcp + - kubernetes + - m365 + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + explode: false + style: form + - in: query + name: filter[provider_uid] + schema: + type: string + - in: query + name: filter[provider_uid__icontains] + schema: + type: string + - in: query + name: filter[provider_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[scan] + schema: + type: string + format: uuid + - in: query + name: filter[scan__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: filter[service] + schema: + type: string + - in: query + name: filter[service__icontains] + schema: + type: string + - in: query + name: filter[service__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[tag] + schema: + type: string + - in: query + name: filter[tag_key] + schema: + type: string + - in: query + name: filter[tag_value] + schema: + type: string + - in: query + name: filter[tags] + schema: + type: string + - in: query + name: filter[type] + schema: + type: string + - in: query + name: filter[type__icontains] + schema: + type: string + - in: query + name: filter[type__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[uid] + schema: + type: string + - in: query + name: filter[uid__icontains] + schema: + type: string + - in: query + name: filter[updated_at] + schema: + type: string + format: date + description: At least one of the variations of the `filter[updated_at]` filter + must be provided. + required: true + - in: query + name: filter[updated_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[updated_at__lte] + schema: + type: string + format: date-time + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - provider_uid + - -provider_uid + - uid + - -uid + - name + - -name + - region + - -region + - service + - -service + - type + - -type + - inserted_at + - -inserted_at + - updated_at + - -updated_at explode: false tags: - Resource @@ -5006,7 +5561,240 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/ResourceResponse' + $ref: '#/components/schemas/ResourceMetadataResponse' + description: '' + /api/v1/resources/metadata/latest: + get: + operationId: resources_metadata_latest_retrieve + description: Fetch unique metadata values from a set of resources from the latest + scans for each provider. This is useful for dynamic filtering. + summary: Retrieve metadata values from the latest resources + parameters: + - in: query + name: fields[resources-metadata] + schema: + type: array + items: + type: string + enum: + - services + - regions + - types + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[name] + schema: + type: string + - in: query + name: filter[name__icontains] + schema: + type: string + - in: query + name: filter[provider] + schema: + type: string + format: uuid + - in: query + name: filter[provider__in] + schema: + type: array + items: + type: string + format: uuid + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_alias] + schema: + type: string + - in: query + name: filter[provider_alias__icontains] + schema: + type: string + - in: query + name: filter[provider_alias__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[provider_type] + schema: + type: string + enum: + - aws + - azure + - gcp + - kubernetes + - m365 + description: |- + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + - in: query + name: filter[provider_type__in] + schema: + type: array + items: + type: string + enum: + - aws + - azure + - gcp + - kubernetes + - m365 + description: |- + Multiple values may be separated by commas. + + * `aws` - AWS + * `azure` - Azure + * `gcp` - GCP + * `kubernetes` - Kubernetes + * `m365` - M365 + explode: false + style: form + - in: query + name: filter[provider_uid] + schema: + type: string + - in: query + name: filter[provider_uid__icontains] + schema: + type: string + - in: query + name: filter[provider_uid__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: filter[service] + schema: + type: string + - in: query + name: filter[service__icontains] + schema: + type: string + - in: query + name: filter[service__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[tag] + schema: + type: string + - in: query + name: filter[tag_key] + schema: + type: string + - in: query + name: filter[tag_value] + schema: + type: string + - in: query + name: filter[tags] + schema: + type: string + - in: query + name: filter[type] + schema: + type: string + - in: query + name: filter[type__icontains] + schema: + type: string + - in: query + name: filter[type__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[uid] + schema: + type: string + - in: query + name: filter[uid__icontains] + schema: + type: string + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - provider_uid + - -provider_uid + - uid + - -uid + - name + - -name + - region + - -region + - service + - -service + - type + - -type + - inserted_at + - -inserted_at + - updated_at + - -updated_at + explode: false + tags: + - Resource + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ResourceMetadataResponse' description: '' /api/v1/roles: get: @@ -12601,6 +13389,9 @@ components: env: prod owner: johndoe readOnly: true + failed_findings_count: + type: integer + readOnly: true type: type: string readOnly: true @@ -12640,33 +13431,72 @@ components: type: object properties: data: - type: array - items: - type: object - properties: - id: - type: string - format: uuid - title: Resource Identifier - description: The identifier of the related object. - type: - type: string - enum: - - findings - title: Resource Type Name - description: The [type](https://jsonapi.org/format/#document-resource-object-identification) - member is used to describe resource objects that share common - attributes and relationships. - required: - - id - - type + type: object + properties: + id: + type: string + type: + type: string + enum: + - findings + title: Resource Type Name + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common + attributes and relationships. + required: + - id + - type required: - data - description: A related resource object from type findings - title: findings + description: The identifier of the related object. + title: Resource Identifier readOnly: true required: - provider + ResourceMetadata: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/ResourceMetadataTypeEnum' + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + id: {} + attributes: + type: object + properties: + services: + type: array + items: + type: string + regions: + type: array + items: + type: string + types: + type: array + items: + type: string + required: + - services + - regions + - types + ResourceMetadataResponse: + type: object + properties: + data: + $ref: '#/components/schemas/ResourceMetadata' + required: + - data + ResourceMetadataTypeEnum: + type: string + enum: + - resources-metadata ResourceResponse: type: object properties: diff --git a/api/src/backend/api/tests/test_adapters.py b/api/src/backend/api/tests/test_adapters.py index 4351e3de3c..22b44b3506 100644 --- a/api/src/backend/api/tests/test_adapters.py +++ b/api/src/backend/api/tests/test_adapters.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from allauth.socialaccount.models import SocialLogin @@ -54,3 +54,24 @@ class TestProwlerSocialAccountAdapter: adapter.pre_social_login(rf.get("/"), sociallogin) sociallogin.connect.assert_not_called() + + def test_save_user_saml_sets_session_flag(self, rf): + adapter = ProwlerSocialAccountAdapter() + request = rf.get("/") + request.session = {} + + sociallogin = MagicMock(spec=SocialLogin) + sociallogin.provider = MagicMock() + sociallogin.provider.id = "saml" + sociallogin.account = MagicMock() + sociallogin.account.extra_data = {} + + mock_user = MagicMock() + mock_user.id = 123 + + with patch("api.adapters.super") as mock_super: + with patch("api.adapters.transaction"): + with patch("api.adapters.MainRouter"): + mock_super.return_value.save_user.return_value = mock_user + adapter.save_user(request, sociallogin) + assert request.session["saml_user_created"] == "123" diff --git a/api/src/backend/api/tests/test_db_utils.py b/api/src/backend/api/tests/test_db_utils.py index 3373dafed0..f4b8bb88af 100644 --- a/api/src/backend/api/tests/test_db_utils.py +++ b/api/src/backend/api/tests/test_db_utils.py @@ -13,6 +13,7 @@ from api.db_utils import ( enum_to_choices, generate_random_token, one_week_from_now, + update_objects_in_batches, ) from api.models import Provider @@ -227,3 +228,88 @@ class TestCreateObjectsInBatches: qs = Provider.objects.filter(tenant=tenant) assert qs.count() == total + + +@pytest.mark.django_db +class TestUpdateObjectsInBatches: + @pytest.fixture + def tenant(self, tenants_fixture): + return tenants_fixture[0] + + def make_provider_instances(self, tenant, count): + """ + Return a list of `count` unsaved Provider instances for the given tenant. + """ + base_uid = 2000 + return [ + Provider( + tenant=tenant, + uid=str(base_uid + i), + provider=Provider.ProviderChoices.AWS, + ) + for i in range(count) + ] + + def test_exact_multiple_of_batch(self, tenant): + total = 6 + batch_size = 3 + objs = self.make_provider_instances(tenant, total) + create_objects_in_batches(str(tenant.id), Provider, objs, batch_size=batch_size) + + # Fetch them back, mutate the `uid` field, then update in batches + providers = list(Provider.objects.filter(tenant=tenant)) + for p in providers: + p.uid = f"{p.uid}_upd" + + update_objects_in_batches( + tenant_id=str(tenant.id), + model=Provider, + objects=providers, + fields=["uid"], + batch_size=batch_size, + ) + + qs = Provider.objects.filter(tenant=tenant, uid__endswith="_upd") + assert qs.count() == total + + def test_non_multiple_of_batch(self, tenant): + total = 7 + batch_size = 3 + objs = self.make_provider_instances(tenant, total) + create_objects_in_batches(str(tenant.id), Provider, objs, batch_size=batch_size) + + providers = list(Provider.objects.filter(tenant=tenant)) + for p in providers: + p.uid = f"{p.uid}_upd" + + update_objects_in_batches( + tenant_id=str(tenant.id), + model=Provider, + objects=providers, + fields=["uid"], + batch_size=batch_size, + ) + + qs = Provider.objects.filter(tenant=tenant, uid__endswith="_upd") + assert qs.count() == total + + def test_batch_size_default(self, tenant): + default_size = settings.DJANGO_DELETION_BATCH_SIZE + total = default_size + 2 + objs = self.make_provider_instances(tenant, total) + create_objects_in_batches(str(tenant.id), Provider, objs) + + providers = list(Provider.objects.filter(tenant=tenant)) + for p in providers: + p.uid = f"{p.uid}_upd" + + # Update without specifying batch_size (uses default) + update_objects_in_batches( + tenant_id=str(tenant.id), + model=Provider, + objects=providers, + fields=["uid"], + ) + + qs = Provider.objects.filter(tenant=tenant, uid__endswith="_upd") + assert qs.count() == total diff --git a/api/src/backend/api/tests/test_rbac.py b/api/src/backend/api/tests/test_rbac.py index 19ea256723..12271d81b9 100644 --- a/api/src/backend/api/tests/test_rbac.py +++ b/api/src/backend/api/tests/test_rbac.py @@ -1,6 +1,7 @@ from unittest.mock import ANY, Mock, patch import pytest +from conftest import TODAY from django.urls import reverse from rest_framework import status @@ -409,3 +410,87 @@ class TestLimitedVisibility: assert ( response.json()["data"]["relationships"]["providers"]["meta"]["count"] == 1 ) + + def test_overviews_providers( + self, + authenticated_client_rbac_limited, + scan_summaries_fixture, + providers_fixture, + ): + # By default, the associated provider is the one which has the overview data + response = authenticated_client_rbac_limited.get(reverse("overview-providers")) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) > 0 + + # Changing the provider visibility, no data should be returned + # Only the associated provider to that group is changed + new_provider = providers_fixture[1] + ProviderGroupMembership.objects.all().update(provider=new_provider) + + response = authenticated_client_rbac_limited.get(reverse("overview-providers")) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 0 + + @pytest.mark.parametrize( + "endpoint_name", + [ + "findings", + "findings_severity", + ], + ) + def test_overviews_findings( + self, + endpoint_name, + authenticated_client_rbac_limited, + scan_summaries_fixture, + providers_fixture, + ): + # By default, the associated provider is the one which has the overview data + response = authenticated_client_rbac_limited.get( + reverse(f"overview-{endpoint_name}") + ) + + assert response.status_code == status.HTTP_200_OK + values = response.json()["data"]["attributes"].values() + assert any(value > 0 for value in values) + + # Changing the provider visibility, no data should be returned + # Only the associated provider to that group is changed + new_provider = providers_fixture[1] + ProviderGroupMembership.objects.all().update(provider=new_provider) + + response = authenticated_client_rbac_limited.get( + reverse(f"overview-{endpoint_name}") + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"]["attributes"].values() + assert all(value == 0 for value in data) + + def test_overviews_services( + self, + authenticated_client_rbac_limited, + scan_summaries_fixture, + providers_fixture, + ): + # By default, the associated provider is the one which has the overview data + response = authenticated_client_rbac_limited.get( + reverse("overview-services"), {"filter[inserted_at]": TODAY} + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) > 0 + + # Changing the provider visibility, no data should be returned + # Only the associated provider to that group is changed + new_provider = providers_fixture[1] + ProviderGroupMembership.objects.all().update(provider=new_provider) + + response = authenticated_client_rbac_limited.get( + reverse("overview-services"), {"filter[inserted_at]": TODAY} + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 0 diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index b064ee352f..3071127228 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -14,7 +14,13 @@ import jwt import pytest from allauth.socialaccount.models import SocialAccount, SocialApp from botocore.exceptions import ClientError, NoCredentialsError -from conftest import API_JSON_CONTENT_TYPE, TEST_PASSWORD, TEST_USER +from conftest import ( + API_JSON_CONTENT_TYPE, + TEST_PASSWORD, + TEST_USER, + TODAY, + today_after_n_days, +) from django.conf import settings from django.http import JsonResponse from django.test import RequestFactory @@ -47,14 +53,6 @@ from api.models import ( from api.rls import Tenant from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView -TODAY = str(datetime.today().date()) - - -def today_after_n_days(n_days: int) -> str: - return datetime.strftime( - datetime.today().date() + timedelta(days=n_days), "%Y-%m-%d" - ) - class TestViewSet: def test_security_headers(self, client): @@ -2966,12 +2964,21 @@ class TestTaskViewSet: @pytest.mark.django_db class TestResourceViewSet: def test_resources_list_none(self, authenticated_client): - response = authenticated_client.get(reverse("resource-list")) + response = authenticated_client.get( + reverse("resource-list"), {"filter[updated_at]": TODAY} + ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == 0 - def test_resources_list(self, authenticated_client, resources_fixture): + def test_resources_list_no_date_filter(self, authenticated_client): response = authenticated_client.get(reverse("resource-list")) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["errors"][0]["code"] == "required" + + def test_resources_list(self, authenticated_client, resources_fixture): + response = authenticated_client.get( + reverse("resource-list"), {"filter[updated_at]": TODAY} + ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == len(resources_fixture) @@ -2992,7 +2999,8 @@ class TestResourceViewSet: findings_fixture, ): response = authenticated_client.get( - reverse("resource-list"), {"include": include_values} + reverse("resource-list"), + {"include": include_values, "filter[updated_at]": TODAY}, ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == len(resources_fixture) @@ -3020,8 +3028,9 @@ class TestResourceViewSet: ("region.icontains", "west", 1), ("service", "ec2", 2), ("service.icontains", "ec", 2), - ("inserted_at.gte", "2024-01-01 00:00:00", 3), - ("updated_at.lte", "2024-01-01 00:00:00", 0), + ("inserted_at.gte", today_after_n_days(-1), 3), + ("updated_at.gte", today_after_n_days(-1), 3), + ("updated_at.lte", today_after_n_days(1), 3), ("type.icontains", "prowler", 2), # provider filters ("provider_type", "aws", 3), @@ -3041,7 +3050,8 @@ class TestResourceViewSet: ("tags", "multi word", 1), # full text search on resource ("search", "arn", 3), - ("search", "def1", 1), + # To improve search efficiency, full text search is not fully applicable + # ("search", "def1", 1), # full text search on resource tags ("search", "multi word", 1), ("search", "key2", 2), @@ -3056,14 +3066,42 @@ class TestResourceViewSet: filter_value, expected_count, ): + filters = {f"filter[{filter_name}]": filter_value} + if "updated_at" not in filter_name: + filters["filter[updated_at]"] = TODAY response = authenticated_client.get( reverse("resource-list"), - {f"filter[{filter_name}]": filter_value}, + filters, ) assert response.status_code == status.HTTP_200_OK assert len(response.json()["data"]) == expected_count + def test_resource_filter_by_scan_id( + self, authenticated_client, resources_fixture, scans_fixture + ): + response = authenticated_client.get( + reverse("resource-list"), + {"filter[scan]": scans_fixture[0].id}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + + def test_resource_filter_by_scan_id_in( + self, authenticated_client, resources_fixture, scans_fixture + ): + response = authenticated_client.get( + reverse("resource-list"), + { + "filter[scan.in]": [ + scans_fixture[0].id, + scans_fixture[1].id, + ] + }, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 2 + def test_resource_filter_by_provider_id_in( self, authenticated_client, resources_fixture ): @@ -3073,7 +3111,8 @@ class TestResourceViewSet: "filter[provider.in]": [ resources_fixture[0].provider.id, resources_fixture[1].provider.id, - ] + ], + "filter[updated_at]": TODAY, }, ) assert response.status_code == status.HTTP_200_OK @@ -3110,13 +3149,13 @@ class TestResourceViewSet: ) def test_resources_sort(self, authenticated_client, sort_field): response = authenticated_client.get( - reverse("resource-list"), {"sort": sort_field} + reverse("resource-list"), {"filter[updated_at]": TODAY, "sort": sort_field} ) assert response.status_code == status.HTTP_200_OK def test_resources_sort_invalid(self, authenticated_client): response = authenticated_client.get( - reverse("resource-list"), {"sort": "invalid"} + reverse("resource-list"), {"filter[updated_at]": TODAY, "sort": "invalid"} ) assert response.status_code == status.HTTP_400_BAD_REQUEST assert response.json()["errors"][0]["code"] == "invalid" @@ -3149,6 +3188,100 @@ class TestResourceViewSet: ) assert response.status_code == status.HTTP_404_NOT_FOUND + def test_resources_metadata_retrieve( + self, authenticated_client, resources_fixture, backfill_scan_metadata_fixture + ): + resource_1, *_ = resources_fixture + response = authenticated_client.get( + reverse("resource-metadata"), + {"filter[updated_at]": resource_1.updated_at.strftime("%Y-%m-%d")}, + ) + data = response.json() + + expected_services = {"ec2", "s3"} + expected_regions = {"us-east-1", "eu-west-1"} + expected_resource_types = {"prowler-test"} + + assert data["data"]["type"] == "resources-metadata" + assert data["data"]["id"] is None + assert set(data["data"]["attributes"]["services"]) == expected_services + assert set(data["data"]["attributes"]["regions"]) == expected_regions + assert set(data["data"]["attributes"]["types"]) == expected_resource_types + + def test_resources_metadata_resource_filter_retrieve( + self, authenticated_client, resources_fixture, backfill_scan_metadata_fixture + ): + resource_1, *_ = resources_fixture + response = authenticated_client.get( + reverse("resource-metadata"), + { + "filter[region]": "eu-west-1", + "filter[updated_at]": resource_1.updated_at.strftime("%Y-%m-%d"), + }, + ) + data = response.json() + + expected_services = {"s3"} + expected_regions = {"eu-west-1"} + expected_resource_types = {"prowler-test"} + + assert data["data"]["type"] == "resources-metadata" + assert data["data"]["id"] is None + assert set(data["data"]["attributes"]["services"]) == expected_services + assert set(data["data"]["attributes"]["regions"]) == expected_regions + assert set(data["data"]["attributes"]["types"]) == expected_resource_types + + def test_resources_metadata_future_date(self, authenticated_client): + response = authenticated_client.get( + reverse("resource-metadata"), + {"filter[updated_at]": "2048-01-01"}, + ) + data = response.json() + assert data["data"]["type"] == "resources-metadata" + assert data["data"]["id"] is None + assert data["data"]["attributes"]["services"] == [] + assert data["data"]["attributes"]["regions"] == [] + assert data["data"]["attributes"]["types"] == [] + + def test_resources_metadata_invalid_date(self, authenticated_client): + response = authenticated_client.get( + reverse("resource-metadata"), + {"filter[updated_at]": "2048-01-011"}, + ) + assert response.json() == { + "errors": [ + { + "detail": "Enter a valid date.", + "status": "400", + "source": {"pointer": "/data/attributes/updated_at"}, + "code": "invalid", + } + ] + } + + def test_resources_latest(self, authenticated_client, latest_scan_resource): + response = authenticated_client.get( + reverse("resource-latest"), + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert ( + response.json()["data"][0]["attributes"]["uid"] == latest_scan_resource.uid + ) + + def test_resources_metadata_latest( + self, authenticated_client, latest_scan_resource + ): + response = authenticated_client.get( + reverse("resource-metadata_latest"), + ) + assert response.status_code == status.HTTP_200_OK + attributes = response.json()["data"]["attributes"] + + assert attributes["services"] == [latest_scan_resource.service] + assert attributes["regions"] == [latest_scan_resource.region] + assert attributes["types"] == [latest_scan_resource.type] + @pytest.mark.django_db class TestFindingViewSet: @@ -3247,7 +3380,7 @@ class TestFindingViewSet: ("search", "dev-qa", 1), ("search", "orange juice", 1), # full text search on resource - ("search", "ec2", 2), + ("search", "ec2", 1), # full text search on finding tags (disabled for now) # ("search", "value2", 2), # Temporary disabled until we implement tag filtering in the UI @@ -5055,6 +5188,8 @@ class TestComplianceOverviewViewSet: assert "description" in attributes assert "status" in attributes + # TODO: This test may fail randomly because requirements are not ordered + @pytest.mark.xfail def test_compliance_overview_requirements_manual( self, authenticated_client, compliance_requirements_overviews_fixture ): @@ -5361,6 +5496,30 @@ class TestScheduleViewSet: ) assert response.status_code == status.HTTP_404_NOT_FOUND + @patch("api.v1.views.Task.objects.get") + def test_schedule_daily_already_scheduled( + self, + mock_task_get, + authenticated_client, + providers_fixture, + tasks_fixture, + ): + provider, *_ = providers_fixture + prowler_task = tasks_fixture[0] + mock_task_get.return_value = prowler_task + json_payload = { + "provider_id": str(provider.id), + } + response = authenticated_client.post( + reverse("schedule-daily"), data=json_payload, format="json" + ) + assert response.status_code == status.HTTP_202_ACCEPTED + + response = authenticated_client.post( + reverse("schedule-daily"), data=json_payload, format="json" + ) + assert response.status_code == status.HTTP_409_CONFLICT + @pytest.mark.django_db class TestIntegrationViewSet: @@ -5984,6 +6143,7 @@ class TestTenantFinishACSView: reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) ) request.user = type("Anonymous", (), {"is_authenticated": False})() + request.session = {} with patch( "allauth.socialaccount.providers.saml.views.get_app_or_404" @@ -6006,6 +6166,7 @@ class TestTenantFinishACSView: reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) ) request.user = users_fixture[0] + request.session = {} with patch( "allauth.socialaccount.providers.saml.views.get_app_or_404" @@ -6047,6 +6208,7 @@ class TestTenantFinishACSView: reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) ) request.user = user + request.session = {} with ( patch( @@ -6113,6 +6275,44 @@ class TestTenantFinishACSView: user.company_name = original_company user.save() + def test_rollback_saml_user_when_error_occurs(self, users_fixture, monkeypatch): + """Test that a user is properly deleted when created during SAML flow and an error occurs""" + monkeypatch.setenv("AUTH_URL", "http://localhost") + + # Create a test user to simulate one created during SAML flow + test_user = User.objects.using(MainRouter.admin_db).create( + email="testuser@example.com", name="Test User" + ) + + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + ) + request.user = users_fixture[0] + request.session = {"saml_user_created": test_user.id} + + # Force an exception to trigger rollback + with patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app: + mock_get_app.side_effect = Exception("Test error") + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug="testtenant") + + # Verify the user was deleted + assert ( + not User.objects.using(MainRouter.admin_db) + .filter(id=test_user.id) + .exists() + ) + + # Verify session was cleaned up + assert "saml_user_created" not in request.session + + # Verify proper redirect + assert response.status_code == 302 + assert "sso_saml_failed=true" in response.url + @pytest.mark.django_db class TestLighthouseConfigViewSet: diff --git a/api/src/backend/api/v1/mixins.py b/api/src/backend/api/v1/mixins.py index fde14a23c5..1cef76a84c 100644 --- a/api/src/backend/api/v1/mixins.py +++ b/api/src/backend/api/v1/mixins.py @@ -24,20 +24,32 @@ class PaginateByPkMixin: request, # noqa: F841 base_queryset, manager, - select_related: list[str] | None = None, - prefetch_related: list[str] | None = None, + select_related: list | None = None, + prefetch_related: list | None = None, ) -> Response: + """ + Paginate a queryset by primary key. + + This method is useful when you want to paginate a queryset that has been + filtered or annotated in a way that would be lost if you used the default + pagination method. + """ pk_list = base_queryset.values_list("id", flat=True) page = self.paginate_queryset(pk_list) if page is None: return Response(self.get_serializer(base_queryset, many=True).data) queryset = manager.filter(id__in=page) + if select_related: queryset = queryset.select_related(*select_related) if prefetch_related: queryset = queryset.prefetch_related(*prefetch_related) + # Optimize tags loading, if applicable + if hasattr(self, "_optimize_tags_loading"): + queryset = self._optimize_tags_loading(queryset) + queryset = sorted(queryset, key=lambda obj: page.index(obj.id)) serialized = self.get_serializer(queryset, many=True).data diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index ff64eb6890..b83cb76bd5 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -9,6 +9,7 @@ from drf_spectacular.utils import extend_schema_field from jwt.exceptions import InvalidKeyError from rest_framework.validators import UniqueTogetherValidator from rest_framework_json_api import serializers +from rest_framework_json_api.relations import SerializerMethodResourceRelatedField from rest_framework_json_api.serializers import ValidationError from rest_framework_simplejwt.exceptions import TokenError from rest_framework_simplejwt.serializers import TokenObtainPairSerializer @@ -999,8 +1000,12 @@ class ResourceSerializer(RLSSerializer): tags = serializers.SerializerMethodField() type_ = serializers.CharField(read_only=True) + failed_findings_count = serializers.IntegerField(read_only=True) - findings = serializers.ResourceRelatedField(many=True, read_only=True) + findings = SerializerMethodResourceRelatedField( + many=True, + read_only=True, + ) class Meta: model = Resource @@ -1016,6 +1021,7 @@ class ResourceSerializer(RLSSerializer): "tags", "provider", "findings", + "failed_findings_count", "url", ] extra_kwargs = { @@ -1037,6 +1043,10 @@ class ResourceSerializer(RLSSerializer): } ) def get_tags(self, obj): + # Use prefetched tags if available to avoid N+1 queries + if hasattr(obj, "prefetched_tags"): + return {tag.key: tag.value for tag in obj.prefetched_tags} + # Fallback to the original method if prefetch is not available return obj.get_tags(self.context.get("tenant_id")) def get_fields(self): @@ -1046,6 +1056,13 @@ class ResourceSerializer(RLSSerializer): fields["type"] = type_ return fields + def get_findings(self, obj): + return ( + obj.latest_findings + if hasattr(obj, "latest_findings") + else obj.findings.all() + ) + class ResourceIncludeSerializer(RLSSerializer): """ @@ -1082,6 +1099,10 @@ class ResourceIncludeSerializer(RLSSerializer): } ) def get_tags(self, obj): + # Use prefetched tags if available to avoid N+1 queries + if hasattr(obj, "prefetched_tags"): + return {tag.key: tag.value for tag in obj.prefetched_tags} + # Fallback to the original method if prefetch is not available return obj.get_tags(self.context.get("tenant_id")) def get_fields(self): @@ -1092,6 +1113,17 @@ class ResourceIncludeSerializer(RLSSerializer): return fields +class ResourceMetadataSerializer(serializers.Serializer): + services = serializers.ListField(child=serializers.CharField(), allow_empty=True) + regions = serializers.ListField(child=serializers.CharField(), allow_empty=True) + types = serializers.ListField(child=serializers.CharField(), allow_empty=True) + # Temporarily disabled until we implement tag filtering in the UI + # tags = serializers.JSONField(help_text="Tags are described as key-value pairs.") + + class Meta: + resource_name = "resources-metadata" + + class FindingSerializer(RLSSerializer): """ Serializer for the Finding model. diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 81d566de13..dc144ddb5d 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -293,7 +293,7 @@ class SchemaView(SpectacularAPIView): def get(self, request, *args, **kwargs): spectacular_settings.TITLE = "Prowler API" - spectacular_settings.VERSION = "1.10.0" + spectacular_settings.VERSION = "1.10.1" spectacular_settings.DESCRIPTION = ( "Prowler API specification.\n\nThis file is auto-generated." ) diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index 6024fb3474..ebfa6178ec 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -29,6 +29,7 @@ from api.models import ( ProviderSecret, Resource, ResourceTag, + ResourceTagMapping, Role, SAMLConfiguration, SAMLDomainIndex, @@ -45,12 +46,19 @@ from api.v1.serializers import TokenSerializer from prowler.lib.check.models import Severity from prowler.lib.outputs.finding import Status +TODAY = str(datetime.today().date()) API_JSON_CONTENT_TYPE = "application/vnd.api+json" NO_TENANT_HTTP_STATUS = status.HTTP_401_UNAUTHORIZED TEST_USER = "dev@prowler.com" TEST_PASSWORD = "testing_psswd" +def today_after_n_days(n_days: int) -> str: + return datetime.strftime( + datetime.today().date() + timedelta(days=n_days), "%Y-%m-%d" + ) + + @pytest.fixture(scope="module") def enforce_test_user_db_connection(django_db_setup, django_db_blocker): """Ensure tests use the test user for database connections.""" @@ -654,6 +662,7 @@ def findings_fixture(scans_fixture, resources_fixture): check_metadata={ "CheckId": "test_check_id", "Description": "test description apple sauce", + "servicename": "ec2", }, first_seen_at="2024-01-02T00:00:00Z", ) @@ -680,6 +689,7 @@ def findings_fixture(scans_fixture, resources_fixture): check_metadata={ "CheckId": "test_check_id", "Description": "test description orange juice", + "servicename": "s3", }, first_seen_at="2024-01-02T00:00:00Z", muted=True, @@ -1135,6 +1145,69 @@ def latest_scan_finding(authenticated_client, providers_fixture, resources_fixtu return finding +@pytest.fixture(scope="function") +def latest_scan_resource(authenticated_client, providers_fixture): + provider = providers_fixture[0] + tenant_id = str(providers_fixture[0].tenant_id) + scan = Scan.objects.create( + name="latest completed scan for resource", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.COMPLETED, + tenant_id=tenant_id, + ) + resource = Resource.objects.create( + tenant_id=tenant_id, + provider=provider, + uid="latest_resource_uid", + name="Latest Resource", + region="us-east-1", + service="ec2", + type="instance", + metadata='{"test": "metadata"}', + details='{"test": "details"}', + ) + + resource_tag = ResourceTag.objects.create( + tenant_id=tenant_id, + key="environment", + value="test", + ) + ResourceTagMapping.objects.create( + tenant_id=tenant_id, + resource=resource, + tag=resource_tag, + ) + + finding = Finding.objects.create( + tenant_id=tenant_id, + uid="test_finding_uid_latest", + scan=scan, + delta="new", + status=Status.FAIL, + status_extended="test status extended ", + impact=Severity.critical, + impact_extended="test impact extended", + severity=Severity.critical, + raw_result={ + "status": Status.FAIL, + "impact": Severity.critical, + "severity": Severity.critical, + }, + tags={"test": "latest"}, + check_id="test_check_id_latest", + check_metadata={ + "CheckId": "test_check_id_latest", + "Description": "test description latest", + }, + first_seen_at="2024-01-02T00:00:00Z", + ) + finding.add_resources([resource]) + + backfill_resource_scan_summaries(tenant_id, str(scan.id)) + return resource + + @pytest.fixture def saml_setup(tenants_fixture): tenant_id = tenants_fixture[0].id diff --git a/api/src/backend/tasks/beat.py b/api/src/backend/tasks/beat.py index 6cd8d7a9ce..a7795e6909 100644 --- a/api/src/backend/tasks/beat.py +++ b/api/src/backend/tasks/beat.py @@ -2,10 +2,10 @@ import json from datetime import datetime, timedelta, timezone from django_celery_beat.models import IntervalSchedule, PeriodicTask -from rest_framework_json_api.serializers import ValidationError from tasks.tasks import perform_scheduled_scan_task from api.db_utils import rls_transaction +from api.exceptions import ConflictException from api.models import Provider, Scan, StateChoices @@ -24,15 +24,9 @@ def schedule_provider_scan(provider_instance: Provider): if PeriodicTask.objects.filter( interval=schedule, name=task_name, task="scan-perform-scheduled" ).exists(): - raise ValidationError( - [ - { - "detail": "There is already a scheduled scan for this provider.", - "status": 400, - "source": {"pointer": "/data/attributes/provider_id"}, - "code": "invalid", - } - ] + raise ConflictException( + detail="There is already a scheduled scan for this provider.", + pointer="/data/attributes/provider_id", ) with rls_transaction(tenant_id): diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 118d62693c..311b1478c3 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -1,19 +1,24 @@ import json import time +from collections import defaultdict from copy import deepcopy from datetime import datetime, timezone from celery.utils.log import get_task_logger from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS from django.db import IntegrityError, OperationalError -from django.db.models import Case, Count, IntegerField, Sum, When +from django.db.models import Case, Count, IntegerField, Prefetch, Sum, When from tasks.utils import CustomEncoder from api.compliance import ( PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, generate_scan_compliance, ) -from api.db_utils import create_objects_in_batches, rls_transaction +from api.db_utils import ( + create_objects_in_batches, + rls_transaction, + update_objects_in_batches, +) from api.exceptions import ProviderConnectionError from api.models import ( ComplianceRequirementOverview, @@ -103,7 +108,10 @@ def _store_resources( def perform_prowler_scan( - tenant_id: str, scan_id: str, provider_id: str, checks_to_execute: list[str] = None + tenant_id: str, + scan_id: str, + provider_id: str, + checks_to_execute: list[str] | None = None, ): """ Perform a scan using Prowler and store the findings and resources in the database. @@ -175,6 +183,7 @@ def perform_prowler_scan( resource_cache = {} tag_cache = {} last_status_cache = {} + resource_failed_findings_cache = defaultdict(int) for progress, findings in prowler_scan.scan(): for finding in findings: @@ -200,6 +209,9 @@ def perform_prowler_scan( }, ) resource_cache[resource_uid] = resource_instance + + # Initialize all processed resources in the cache + resource_failed_findings_cache[resource_uid] = 0 else: resource_instance = resource_cache[resource_uid] @@ -313,6 +325,11 @@ def perform_prowler_scan( ) finding_instance.add_resources([resource_instance]) + # Increment failed_findings_count cache if the finding status is FAIL and not muted + if status == FindingStatus.FAIL and not finding.muted: + resource_uid = finding.resource_uid + resource_failed_findings_cache[resource_uid] += 1 + # Update scan resource summaries scan_resource_cache.add( ( @@ -330,6 +347,24 @@ def perform_prowler_scan( scan_instance.state = StateChoices.COMPLETED + # Update failed_findings_count for all resources in batches if scan completed successfully + if resource_failed_findings_cache: + resources_to_update = [] + for resource_uid, failed_count in resource_failed_findings_cache.items(): + if resource_uid in resource_cache: + resource_instance = resource_cache[resource_uid] + resource_instance.failed_findings_count = failed_count + 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, + ) + except Exception as e: logger.error(f"Error performing scan {scan_id}: {e}") exception = e @@ -382,6 +417,9 @@ def aggregate_findings(tenant_id: str, scan_id: str): changed, unchanged). The results are grouped by `check_id`, `service`, `severity`, and `region`. These aggregated metrics are then stored in the `ScanSummary` table. + Additionally, it updates the failed_findings_count field for each resource based on the most + recent findings for each finding.uid. + Args: tenant_id (str): The ID of the tenant to which the scan belongs. scan_id (str): The ID of the scan for which findings need to be aggregated. @@ -550,18 +588,27 @@ def create_compliance_requirements(tenant_id: str, scan_id: str): prowler_provider = return_prowler_provider(provider_instance) # Get check status data by region from findings + findings = ( + Finding.all_objects.filter(scan_id=scan_id, muted=False) + .only("id", "check_id", "status") + .prefetch_related( + Prefetch( + "resources", + queryset=Resource.objects.only("id", "region"), + to_attr="small_resources", + ) + ) + .iterator(chunk_size=1000) + ) + check_status_by_region = {} with rls_transaction(tenant_id): - findings = Finding.objects.filter(scan_id=scan_id, muted=False) for finding in findings: - # Get region from resources - for resource in finding.resources.all(): + for resource in finding.small_resources: region = resource.region - region_dict = check_status_by_region.setdefault(region, {}) - current_status = region_dict.get(finding.check_id) - if current_status == "FAIL": - continue - region_dict[finding.check_id] = finding.status + current_status = check_status_by_region.setdefault(region, {}) + if current_status.get(finding.check_id) != "FAIL": + current_status[finding.check_id] = finding.status try: # Try to get regions from provider diff --git a/api/src/backend/tasks/tests/test_beat.py b/api/src/backend/tasks/tests/test_beat.py index 01ce0249e6..7a1656553f 100644 --- a/api/src/backend/tasks/tests/test_beat.py +++ b/api/src/backend/tasks/tests/test_beat.py @@ -3,9 +3,9 @@ from unittest.mock import patch import pytest from django_celery_beat.models import IntervalSchedule, PeriodicTask -from rest_framework_json_api.serializers import ValidationError from tasks.beat import schedule_provider_scan +from api.exceptions import ConflictException from api.models import Scan @@ -48,8 +48,8 @@ class TestScheduleProviderScan: with patch("tasks.tasks.perform_scheduled_scan_task.apply_async"): schedule_provider_scan(provider_instance) - # Now, try scheduling again, should raise ValidationError - with pytest.raises(ValidationError) as exc_info: + # Now, try scheduling again, should raise ConflictException + with pytest.raises(ConflictException) as exc_info: schedule_provider_scan(provider_instance) assert "There is already a scheduled scan for this provider." in str( diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 474543a82b..dcbec12956 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -13,15 +13,8 @@ from tasks.jobs.scan import ( from tasks.utils import CustomEncoder from api.exceptions import ProviderConnectionError -from api.models import ( - ComplianceRequirementOverview, - Finding, - Provider, - Resource, - Severity, - StateChoices, - StatusChoices, -) +from api.models import Finding, Provider, Resource, Scan, StateChoices, StatusChoices +from prowler.lib.check.models import Severity @pytest.mark.django_db @@ -181,6 +174,9 @@ class TestPerformScan: assert tag_keys == set(finding.resource_tags.keys()) assert tag_values == set(finding.resource_tags.values()) + # Assert that failed_findings_count is 0 (finding is PASS and muted) + assert scan_resource.failed_findings_count == 0 + @patch("tasks.jobs.scan.ProwlerScan") @patch( "tasks.jobs.scan.initialize_prowler_provider", @@ -385,6 +381,359 @@ class TestPerformScan: assert resource == resource_instance assert resource_uid_tuple == (resource_instance.uid, resource_instance.region) + def test_perform_prowler_scan_with_failed_findings( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """Test that failed findings increment the failed_findings_count""" + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE", + new_callable=dict, + ), + patch("api.compliance.PROWLER_CHECKS", new_callable=dict), + ): + # Ensure the database is empty + assert Finding.objects.count() == 0 + assert Resource.objects.count() == 0 + + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + # Ensure the provider type is 'aws' + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + provider_id = str(provider.id) + + # Mock a FAIL finding that is not muted + fail_finding = MagicMock() + fail_finding.uid = "fail_finding_uid" + fail_finding.status = StatusChoices.FAIL + fail_finding.status_extended = "test fail status" + fail_finding.severity = Severity.high + fail_finding.check_id = "fail_check" + fail_finding.get_metadata.return_value = {"key": "value"} + fail_finding.resource_uid = "resource_uid_fail" + fail_finding.resource_name = "fail_resource" + fail_finding.region = "us-east-1" + fail_finding.service_name = "ec2" + fail_finding.resource_type = "instance" + fail_finding.resource_tags = {"env": "test"} + fail_finding.muted = False + fail_finding.raw = {} + fail_finding.resource_metadata = {"test": "metadata"} + fail_finding.resource_details = {"details": "test"} + fail_finding.partition = "aws" + fail_finding.compliance = {"compliance1": "FAIL"} + + # Mock the ProwlerScan instance + mock_prowler_scan_instance = MagicMock() + mock_prowler_scan_instance.scan.return_value = [(100, [fail_finding])] + mock_prowler_scan_class.return_value = mock_prowler_scan_instance + + # Mock prowler_provider + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + # Call the function under test + perform_prowler_scan(tenant_id, scan_id, provider_id, []) + + # Refresh instances from the database + scan.refresh_from_db() + scan_resource = Resource.objects.get(provider=provider) + + # Assert that failed_findings_count is 1 (one FAIL finding not muted) + assert scan_resource.failed_findings_count == 1 + + def test_perform_prowler_scan_multiple_findings_same_resource( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """Test that multiple FAIL findings on the same resource increment the counter correctly""" + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE", + new_callable=dict, + ), + patch("api.compliance.PROWLER_CHECKS", new_callable=dict), + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + provider_id = str(provider.id) + + # Create multiple findings for the same resource + # Two FAIL findings (not muted) and one PASS finding + resource_uid = "shared_resource_uid" + + fail_finding_1 = MagicMock() + fail_finding_1.uid = "fail_finding_1" + fail_finding_1.status = StatusChoices.FAIL + fail_finding_1.status_extended = "fail 1" + fail_finding_1.severity = Severity.high + fail_finding_1.check_id = "fail_check_1" + fail_finding_1.get_metadata.return_value = {"key": "value1"} + fail_finding_1.resource_uid = resource_uid + fail_finding_1.resource_name = "shared_resource" + fail_finding_1.region = "us-east-1" + fail_finding_1.service_name = "ec2" + fail_finding_1.resource_type = "instance" + fail_finding_1.resource_tags = {} + fail_finding_1.muted = False + fail_finding_1.raw = {} + fail_finding_1.resource_metadata = {} + fail_finding_1.resource_details = {} + fail_finding_1.partition = "aws" + fail_finding_1.compliance = {} + + fail_finding_2 = MagicMock() + fail_finding_2.uid = "fail_finding_2" + fail_finding_2.status = StatusChoices.FAIL + fail_finding_2.status_extended = "fail 2" + fail_finding_2.severity = Severity.medium + fail_finding_2.check_id = "fail_check_2" + fail_finding_2.get_metadata.return_value = {"key": "value2"} + fail_finding_2.resource_uid = resource_uid + fail_finding_2.resource_name = "shared_resource" + fail_finding_2.region = "us-east-1" + fail_finding_2.service_name = "ec2" + fail_finding_2.resource_type = "instance" + fail_finding_2.resource_tags = {} + fail_finding_2.muted = False + fail_finding_2.raw = {} + fail_finding_2.resource_metadata = {} + fail_finding_2.resource_details = {} + fail_finding_2.partition = "aws" + fail_finding_2.compliance = {} + + pass_finding = MagicMock() + pass_finding.uid = "pass_finding" + pass_finding.status = StatusChoices.PASS + pass_finding.status_extended = "pass" + pass_finding.severity = Severity.low + pass_finding.check_id = "pass_check" + pass_finding.get_metadata.return_value = {"key": "value3"} + pass_finding.resource_uid = resource_uid + pass_finding.resource_name = "shared_resource" + pass_finding.region = "us-east-1" + pass_finding.service_name = "ec2" + pass_finding.resource_type = "instance" + pass_finding.resource_tags = {} + pass_finding.muted = False + pass_finding.raw = {} + pass_finding.resource_metadata = {} + pass_finding.resource_details = {} + pass_finding.partition = "aws" + pass_finding.compliance = {} + + # Mock the ProwlerScan instance + mock_prowler_scan_instance = MagicMock() + mock_prowler_scan_instance.scan.return_value = [ + (100, [fail_finding_1, fail_finding_2, pass_finding]) + ] + mock_prowler_scan_class.return_value = mock_prowler_scan_instance + + # Mock prowler_provider + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + # Call the function under test + perform_prowler_scan(tenant_id, scan_id, provider_id, []) + + # Refresh instances from the database + scan_resource = Resource.objects.get(provider=provider, uid=resource_uid) + + # Assert that failed_findings_count is 2 (two FAIL findings, one PASS) + assert scan_resource.failed_findings_count == 2 + + def test_perform_prowler_scan_with_muted_findings( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + """Test that muted FAIL findings do not increment the failed_findings_count""" + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE", + new_callable=dict, + ), + patch("api.compliance.PROWLER_CHECKS", new_callable=dict), + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + provider_id = str(provider.id) + + # Mock a FAIL finding that is muted + muted_fail_finding = MagicMock() + muted_fail_finding.uid = "muted_fail_finding" + muted_fail_finding.status = StatusChoices.FAIL + muted_fail_finding.status_extended = "muted fail" + muted_fail_finding.severity = Severity.high + muted_fail_finding.check_id = "muted_fail_check" + muted_fail_finding.get_metadata.return_value = {"key": "value"} + muted_fail_finding.resource_uid = "muted_resource_uid" + muted_fail_finding.resource_name = "muted_resource" + muted_fail_finding.region = "us-east-1" + muted_fail_finding.service_name = "ec2" + muted_fail_finding.resource_type = "instance" + muted_fail_finding.resource_tags = {} + muted_fail_finding.muted = True + muted_fail_finding.raw = {} + muted_fail_finding.resource_metadata = {} + muted_fail_finding.resource_details = {} + muted_fail_finding.partition = "aws" + muted_fail_finding.compliance = {} + + # Mock the ProwlerScan instance + mock_prowler_scan_instance = MagicMock() + mock_prowler_scan_instance.scan.return_value = [(100, [muted_fail_finding])] + mock_prowler_scan_class.return_value = mock_prowler_scan_instance + + # Mock prowler_provider + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + # Call the function under test + perform_prowler_scan(tenant_id, scan_id, provider_id, []) + + # Refresh instances from the database + scan_resource = Resource.objects.get(provider=provider) + + # Assert that failed_findings_count is 0 (FAIL finding is muted) + assert scan_resource.failed_findings_count == 0 + + def test_perform_prowler_scan_reset_failed_findings_count( + self, + tenants_fixture, + providers_fixture, + resources_fixture, + ): + """Test that failed_findings_count is reset to 0 at the beginning of each scan""" + # Use existing resource from fixture and set initial failed_findings_count + tenant = tenants_fixture[0] + provider = providers_fixture[0] + resource = resources_fixture[0] + + # Set a non-zero failed_findings_count initially + resource.failed_findings_count = 5 + resource.save() + + # Create a new scan + scan = Scan.objects.create( + name="Reset Test Scan", + provider=provider, + trigger=Scan.TriggerChoices.MANUAL, + state=StateChoices.AVAILABLE, + tenant_id=tenant.id, + ) + + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE", + new_callable=dict, + ), + patch("api.compliance.PROWLER_CHECKS", new_callable=dict), + ): + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + provider_id = str(provider.id) + + # Mock a PASS finding for the existing resource + pass_finding = MagicMock() + pass_finding.uid = "reset_test_finding" + pass_finding.status = StatusChoices.PASS + pass_finding.status_extended = "reset test pass" + pass_finding.severity = Severity.low + pass_finding.check_id = "reset_test_check" + pass_finding.get_metadata.return_value = {"key": "value"} + pass_finding.resource_uid = resource.uid + pass_finding.resource_name = resource.name + pass_finding.region = resource.region + pass_finding.service_name = resource.service + pass_finding.resource_type = resource.type + pass_finding.resource_tags = {} + pass_finding.muted = False + pass_finding.raw = {} + pass_finding.resource_metadata = {} + pass_finding.resource_details = {} + pass_finding.partition = "aws" + pass_finding.compliance = {} + + # Mock the ProwlerScan instance + mock_prowler_scan_instance = MagicMock() + mock_prowler_scan_instance.scan.return_value = [(100, [pass_finding])] + mock_prowler_scan_class.return_value = mock_prowler_scan_instance + + # Mock prowler_provider + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = [resource.region] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + # Call the function under test + perform_prowler_scan(tenant_id, scan_id, provider_id, []) + + # Refresh resource from the database + resource.refresh_from_db() + + # Assert that failed_findings_count was reset to 0 during the scan + assert resource.failed_findings_count == 0 + # TODO Add tests for aggregations @@ -400,34 +749,13 @@ class TestCreateComplianceRequirements: resources_fixture, ): with ( - patch("api.db_utils.rls_transaction"), - patch("tasks.jobs.scan.return_prowler_provider") as mock_prowler_provider, patch( "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" ) as mock_compliance_template, patch("tasks.jobs.scan.generate_scan_compliance"), - patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects, - patch("api.models.Finding.objects.filter") as mock_findings_filter, ): - tenant = tenants_fixture[0] - scan = scans_fixture[0] - provider = providers_fixture[0] - - provider.provider = Provider.ProviderChoices.AWS - provider.save() - - scan.provider = provider - scan.save() - - tenant_id = str(tenant.id) - scan_id = str(scan.id) - - mock_prowler_provider_instance = MagicMock() - mock_prowler_provider_instance.get_regions.return_value = [ - "us-east-1", - "us-west-2", - ] - mock_prowler_provider.return_value = mock_prowler_provider_instance + tenant_id = str(tenants_fixture[0].id) + scan_id = str(scans_fixture[0].id) mock_compliance_template.__getitem__.return_value = { "cis_1.4_aws": { @@ -456,104 +784,29 @@ class TestCreateComplianceRequirements: }, }, }, - "aws_account_security_onboarding_aws": { - "framework": "AWS Account Security Onboarding", - "version": "1.0", - "requirements": { - "requirement1": { - "description": "Basic security requirement", - "checks_status": { - "pass": 1, - "fail": 0, - "manual": 0, - "total": 1, - }, - "status": "PASS", - }, - }, - }, } - mock_findings_filter.return_value = [] - result = create_compliance_requirements(tenant_id, scan_id) assert "requirements_created" in result assert "regions_processed" in result assert "compliance_frameworks" in result - assert result["regions_processed"] == ["us-east-1", "us-west-2"] - assert result["requirements_created"] == 6 - assert len(result["compliance_frameworks"]) == 2 - - mock_create_objects.assert_called_once() - call_args = mock_create_objects.call_args[0] - assert call_args[0] == tenant_id - assert call_args[1] == ComplianceRequirementOverview - assert len(call_args[2]) == 6 - - compliance_objects = call_args[2] - for obj in compliance_objects: - assert isinstance(obj, ComplianceRequirementOverview) - assert obj.tenant.id == tenant.id - assert obj.scan == scan - assert obj.region in ["us-east-1", "us-west-2"] - assert obj.compliance_id in [ - "cis_1.4_aws", - "aws_account_security_onboarding_aws", - ] def test_create_compliance_requirements_with_findings( self, tenants_fixture, scans_fixture, providers_fixture, + findings_fixture, ): with ( - patch("api.db_utils.rls_transaction"), - patch("tasks.jobs.scan.return_prowler_provider") as mock_prowler_provider, patch( "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" ) as mock_compliance_template, - patch( - "tasks.jobs.scan.generate_scan_compliance" - ) as mock_generate_compliance, - patch("tasks.jobs.scan.create_objects_in_batches"), - patch("api.models.Finding.objects.filter") as mock_findings_filter, + patch("tasks.jobs.scan.generate_scan_compliance"), ): - tenant = tenants_fixture[0] - scan = scans_fixture[0] - provider = providers_fixture[0] - - provider.provider = Provider.ProviderChoices.AWS - provider.save() - scan.provider = provider - scan.save() - - tenant_id = str(tenant.id) - scan_id = str(scan.id) - - mock_finding1 = MagicMock() - mock_finding1.check_id = "check1" - mock_finding1.status = "PASS" - mock_resource1 = MagicMock() - mock_resource1.region = "us-east-1" - mock_finding1.resources.all.return_value = [mock_resource1] - - mock_finding2 = MagicMock() - mock_finding2.check_id = "check2" - mock_finding2.status = "FAIL" - mock_resource2 = MagicMock() - mock_resource2.region = "us-west-2" - mock_finding2.resources.all.return_value = [mock_resource2] - - mock_findings_filter.return_value = [mock_finding1, mock_finding2] - - mock_prowler_provider_instance = MagicMock() - mock_prowler_provider_instance.get_regions.return_value = [ - "us-east-1", - "us-west-2", - ] - mock_prowler_provider.return_value = mock_prowler_provider_instance + tenant_id = str(tenants_fixture[0].id) + scan_id = str(scans_fixture[0].id) mock_compliance_template.__getitem__.return_value = { "test_compliance": { @@ -562,7 +815,6 @@ class TestCreateComplianceRequirements: "requirements": { "req_1": { "description": "Test Requirement 1", - "checks": {"check_1": None}, "checks_status": { "pass": 2, "fail": 1, @@ -571,43 +823,26 @@ class TestCreateComplianceRequirements: }, "status": "FAIL", }, - "req_2": { - "description": "Test Requirement 2", - "checks": {"check_2": None}, - "checks_status": { - "pass": 2, - "fail": 0, - "manual": 0, - "total": 2, - }, - "status": "PASS", - }, }, } } result = create_compliance_requirements(tenant_id, scan_id) - mock_findings_filter.assert_called_once_with(scan_id=scan_id, muted=False) - assert mock_generate_compliance.call_count == 2 - assert result["requirements_created"] == 4 - assert set(result["regions_processed"]) == {"us-east-1", "us-west-2"} + assert "requirements_created" in result - def test_create_compliance_requirements_no_provider_regions( + def test_create_compliance_requirements_kubernetes_provider( self, tenants_fixture, scans_fixture, providers_fixture, + findings_fixture, ): with ( - patch("api.db_utils.rls_transaction"), - patch("tasks.jobs.scan.return_prowler_provider") as mock_prowler_provider, patch( "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" ) as mock_compliance_template, patch("tasks.jobs.scan.generate_scan_compliance"), - patch("tasks.jobs.scan.create_objects_in_batches"), - patch("api.models.Finding.objects.filter") as mock_findings_filter, ): tenant = tenants_fixture[0] scan = scans_fixture[0] @@ -621,20 +856,6 @@ class TestCreateComplianceRequirements: tenant_id = str(tenant.id) scan_id = str(scan.id) - mock_finding = MagicMock() - mock_finding.check_id = "check1" - mock_finding.status = "PASS" - mock_resource = MagicMock() - mock_resource.region = "default" - mock_finding.resources.all.return_value = [mock_resource] - mock_findings_filter.return_value = [mock_finding] - - mock_prowler_provider_instance = MagicMock() - mock_prowler_provider_instance.get_regions.side_effect = AttributeError( - "No get_regions method" - ) - mock_prowler_provider.return_value = mock_prowler_provider_instance - mock_compliance_template.__getitem__.return_value = { "kubernetes_cis": { "framework": "CIS Kubernetes Benchmark", @@ -656,92 +877,40 @@ class TestCreateComplianceRequirements: result = create_compliance_requirements(tenant_id, scan_id) - assert result["regions_processed"] == ["default"] + assert "regions_processed" in result - def test_create_compliance_requirements_empty_findings( + def test_create_compliance_requirements_empty_template( self, tenants_fixture, scans_fixture, providers_fixture, + findings_fixture, ): with ( - patch("api.db_utils.rls_transaction"), - patch("tasks.jobs.scan.return_prowler_provider") as mock_prowler_provider, patch( "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" ) as mock_compliance_template, - patch( - "tasks.jobs.scan.generate_scan_compliance" - ) as mock_generate_compliance, - patch("tasks.jobs.scan.create_objects_in_batches"), - patch("api.models.Finding.objects.filter") as mock_findings_filter, + patch("tasks.jobs.scan.generate_scan_compliance"), ): - tenant = tenants_fixture[0] - scan = scans_fixture[0] - provider = providers_fixture[0] + tenant_id = str(tenants_fixture[0].id) + scan_id = str(scans_fixture[0].id) - provider.provider = Provider.ProviderChoices.AWS - provider.save() - scan.provider = provider - scan.save() - - tenant_id = str(tenant.id) - scan_id = str(scan.id) - - mock_findings_filter.return_value = [] - - mock_prowler_provider_instance = MagicMock() - mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] - mock_prowler_provider.return_value = mock_prowler_provider_instance - - mock_compliance_template.__getitem__.return_value = { - "cis_1.4_aws": { - "framework": "CIS AWS Foundations Benchmark", - "version": "1.4.0", - "requirements": { - "1.1": { - "description": "Test requirement", - "checks_status": { - "pass": 0, - "fail": 0, - "manual": 0, - "total": 1, - }, - "status": "PASS", - }, - }, - }, - } - - mock_findings_filter.return_value = [] + mock_compliance_template.__getitem__.return_value = {} result = create_compliance_requirements(tenant_id, scan_id) - assert result["regions_processed"] == ["us-east-1"] - assert result["requirements_created"] == 1 - mock_generate_compliance.assert_not_called() + assert result["requirements_created"] == 0 def test_create_compliance_requirements_error_handling( self, tenants_fixture, scans_fixture, providers_fixture, + findings_fixture, ): - with ( - patch("api.db_utils.rls_transaction"), - patch("tasks.jobs.scan.return_prowler_provider") as mock_prowler_provider, - ): - tenant = tenants_fixture[0] - scan = scans_fixture[0] - provider = providers_fixture[0] - - provider.provider = Provider.ProviderChoices.AWS - provider.save() - scan.provider = provider - scan.save() - - tenant_id = str(tenant.id) - scan_id = str(scan.id) + with patch("tasks.jobs.scan.return_prowler_provider") as mock_prowler_provider: + tenant_id = str(tenants_fixture[0].id) + scan_id = str(scans_fixture[0].id) mock_prowler_provider.side_effect = Exception( "Provider initialization failed" @@ -750,99 +919,19 @@ class TestCreateComplianceRequirements: with pytest.raises(Exception, match="Provider initialization failed"): create_compliance_requirements(tenant_id, scan_id) - def test_create_compliance_requirements_muted_findings_excluded( - self, - tenants_fixture, - scans_fixture, - providers_fixture, - ): - with ( - patch("api.db_utils.rls_transaction"), - patch("tasks.jobs.scan.return_prowler_provider") as mock_prowler_provider, - patch( - "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" - ) as mock_compliance_template, - patch("tasks.jobs.scan.generate_scan_compliance"), - patch("tasks.jobs.scan.create_objects_in_batches"), - patch("api.models.Finding.objects.filter") as mock_findings_filter, - ): - tenant = tenants_fixture[0] - scan = scans_fixture[0] - provider = providers_fixture[0] - - provider.provider = Provider.ProviderChoices.AWS - provider.save() - scan.provider = provider - scan.save() - - tenant_id = str(tenant.id) - scan_id = str(scan.id) - - mock_findings_filter.return_value = [] - - mock_prowler_provider_instance = MagicMock() - mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] - mock_prowler_provider.return_value = mock_prowler_provider_instance - - mock_compliance_template.__getitem__.return_value = {} - - mock_findings_filter.return_value = [] - - create_compliance_requirements(tenant_id, scan_id) - - mock_findings_filter.assert_called_once_with(scan_id=scan_id, muted=False) - def test_create_compliance_requirements_check_status_priority( - self, - tenants_fixture, - scans_fixture, - providers_fixture, + self, tenants_fixture, scans_fixture, providers_fixture, findings_fixture ): with ( - patch("api.db_utils.rls_transaction"), - patch( - "tasks.jobs.scan.return_prowler_provider" - ) as mock_return_prowler_provider, patch( "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" ) as mock_compliance_template, patch( "tasks.jobs.scan.generate_scan_compliance" ) as mock_generate_compliance, - patch("tasks.jobs.scan.create_objects_in_batches"), - patch("api.models.Finding.objects.filter") as mock_findings_filter, ): - tenant = tenants_fixture[0] - scan = scans_fixture[0] - provider = providers_fixture[0] - - provider.provider = Provider.ProviderChoices.AWS - provider.save() - scan.provider = provider - scan.save() - - tenant_id = str(tenant.id) - scan_id = str(scan.id) - - mock_finding1 = MagicMock() - mock_finding1.check_id = "check1" - mock_finding1.status = "PASS" - mock_resource1 = MagicMock() - mock_resource1.region = "us-east-1" - mock_finding1.resources.all.return_value = [mock_resource1] - - mock_finding2 = MagicMock() - mock_finding2.check_id = "check1" - mock_finding2.status = "FAIL" - mock_resource2 = MagicMock() - mock_resource2.region = "us-east-1" - mock_finding2.resources.all.return_value = [mock_resource2] - - mock_findings_filter.return_value = [mock_finding1, mock_finding2] - - mock_prowler_provider_instance = MagicMock() - mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] - mock_return_prowler_provider.return_value = mock_prowler_provider_instance + tenant_id = str(tenants_fixture[0].id) + scan_id = str(scans_fixture[0].id) mock_compliance_template.__getitem__.return_value = { "cis_1.4_aws": { @@ -867,38 +956,21 @@ class TestCreateComplianceRequirements: assert mock_generate_compliance.call_count == 1 - def test_compliance_overview_aggregation_requirement_fail_priority( + def test_create_compliance_requirements_multiple_regions( self, tenants_fixture, scans_fixture, providers_fixture, + findings_fixture, ): with ( - patch("api.db_utils.rls_transaction"), - patch( - "tasks.jobs.scan.return_prowler_provider" - ) as mock_return_prowler_provider, patch( "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" ) as mock_compliance_template, - patch( - "tasks.jobs.scan.generate_scan_compliance" - ) as mock_generate_compliance, - patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects, - patch("api.models.Finding.objects.filter") as mock_findings_filter, + patch("tasks.jobs.scan.generate_scan_compliance"), ): - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - mock_findings_filter.return_value = [] - - mock_prowler_provider = MagicMock() - mock_prowler_provider.get_regions.return_value = [ - "us-east-1", - "us-west-2", - "eu-west-1", - ] - mock_return_prowler_provider.return_value = mock_prowler_provider + tenant_id = str(tenants_fixture[0].id) + scan_id = str(scans_fixture[0].id) mock_compliance_template.__getitem__.return_value = { "test_compliance": { @@ -907,95 +979,6 @@ class TestCreateComplianceRequirements: "requirements": { "req_1": { "description": "Test Requirement 1", - "checks": {"check_1": None}, - "checks_status": { - "pass": 2, - "fail": 1, - "manual": 0, - "total": 3, - }, - "status": "FAIL", - } - }, - } - } - - mock_generate_compliance.return_value = { - "test_compliance": { - "framework": "Test Framework", - "version": "1.0", - "requirements": { - "req_1": { - "description": "Test Requirement 1", - "checks": { - "check_1": { - "us-east-1": {"status": "PASS"}, - "us-west-2": {"status": "FAIL"}, - "eu-west-1": {"status": "PASS"}, - } - }, - "checks_status": { - "pass": 2, - "fail": 1, - "manual": 0, - "total": 3, - }, - "status": "FAIL", - } - }, - } - } - - created_objects = [] - mock_create_objects.side_effect = ( - lambda tenant_id, model, objs, batch_size=500: created_objects.extend( - objs - ) - ) - - create_compliance_requirements(str(tenant.id), str(scan.id)) - - assert len(created_objects) == 3 - assert all(obj.requirement_status == "FAIL" for obj in created_objects) - - def test_compliance_overview_aggregation_requirement_pass_all_regions( - self, - tenants_fixture, - scans_fixture, - providers_fixture, - ): - with ( - patch("api.db_utils.rls_transaction"), - patch( - "tasks.jobs.scan.return_prowler_provider" - ) as mock_return_prowler_provider, - patch( - "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" - ) as mock_compliance_template, - patch( - "tasks.jobs.scan.generate_scan_compliance" - ) as mock_generate_compliance, - patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects, - patch("api.models.Finding.objects.filter") as mock_findings_filter, - ): - tenant = tenants_fixture[0] - scan = scans_fixture[0] - providers_fixture[0] - - mock_findings_filter.return_value = [] - - mock_prowler_provider = MagicMock() - mock_prowler_provider.get_regions.return_value = ["us-east-1", "us-west-2"] - mock_return_prowler_provider.return_value = mock_prowler_provider - - mock_compliance_template.__getitem__.return_value = { - "test_compliance": { - "framework": "Test Framework", - "version": "1.0", - "requirements": { - "req_1": { - "description": "Test Requirement 1", - "checks": {"check_1": None}, "checks_status": { "pass": 2, "fail": 0, @@ -1008,71 +991,26 @@ class TestCreateComplianceRequirements: } } - mock_generate_compliance.return_value = { - "test_compliance": { - "framework": "Test Framework", - "version": "1.0", - "requirements": { - "req_1": { - "description": "Test Requirement 1", - "checks": { - "check_1": { - "us-east-1": {"status": "PASS"}, - "us-west-2": {"status": "PASS"}, - } - }, - "checks_status": { - "pass": 2, - "fail": 0, - "manual": 0, - "total": 2, - }, - "status": "PASS", - } - }, - } - } + result = create_compliance_requirements(tenant_id, scan_id) - created_objects = [] - mock_create_objects.side_effect = ( - lambda tenant_id, model, objs, batch_size=500: created_objects.extend( - objs - ) - ) + assert "requirements_created" in result + assert len(result["regions_processed"]) >= 0 - create_compliance_requirements(str(tenant.id), str(scan.id)) - - assert len(created_objects) == 2 - assert all(obj.requirement_status == "PASS" for obj in created_objects) - - def test_compliance_overview_aggregation_multiple_requirements_mixed_status( + def test_create_compliance_requirements_mixed_status_requirements( self, tenants_fixture, scans_fixture, providers_fixture, + findings_fixture, ): with ( - patch("api.db_utils.rls_transaction"), - patch( - "tasks.jobs.scan.return_prowler_provider" - ) as mock_return_prowler_provider, patch( "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" ) as mock_compliance_template, - patch( - "tasks.jobs.scan.generate_scan_compliance" - ) as mock_generate_compliance, - patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects, - patch("api.models.Finding.objects.filter") as mock_findings_filter, + patch("tasks.jobs.scan.generate_scan_compliance"), ): - tenant = tenants_fixture[0] - scan = scans_fixture[0] - - mock_findings_filter.return_value = [] - - mock_prowler_provider = MagicMock() - mock_prowler_provider.get_regions.return_value = ["us-east-1", "us-west-2"] - mock_return_prowler_provider.return_value = mock_prowler_provider + tenant_id = str(tenants_fixture[0].id) + scan_id = str(scans_fixture[0].id) mock_compliance_template.__getitem__.return_value = { "test_compliance": { @@ -1081,7 +1019,6 @@ class TestCreateComplianceRequirements: "requirements": { "req_1": { "description": "Test Requirement 1", - "checks": {"check_1": None}, "checks_status": { "pass": 2, "fail": 0, @@ -1092,7 +1029,6 @@ class TestCreateComplianceRequirements: }, "req_2": { "description": "Test Requirement 2", - "checks": {"check_2": None}, "checks_status": { "pass": 1, "fail": 1, @@ -1105,64 +1041,7 @@ class TestCreateComplianceRequirements: } } - mock_generate_compliance.return_value = { - "test_compliance": { - "framework": "Test Framework", - "version": "1.0", - "requirements": { - "req_1": { - "description": "Test Requirement 1", - "checks": { - "check_1": { - "us-east-1": {"status": "PASS"}, - "us-west-2": {"status": "PASS"}, - } - }, - "checks_status": { - "pass": 2, - "fail": 0, - "manual": 0, - "total": 2, - }, - "status": "PASS", - }, - "req_2": { - "description": "Test Requirement 2", - "checks": { - "check_2": { - "us-east-1": {"status": "PASS"}, - "us-west-2": {"status": "FAIL"}, - } - }, - "checks_status": { - "pass": 1, - "fail": 1, - "manual": 0, - "total": 2, - }, - "status": "FAIL", - }, - }, - } - } + result = create_compliance_requirements(tenant_id, scan_id) - created_objects = [] - mock_create_objects.side_effect = ( - lambda tenant_id, model, objs, batch_size=500: created_objects.extend( - objs - ) - ) - - create_compliance_requirements(str(tenant.id), str(scan.id)) - - assert len(created_objects) == 4 - req_1_objects = [ - obj for obj in created_objects if obj.requirement_id == "req_1" - ] - req_2_objects = [ - obj for obj in created_objects if obj.requirement_id == "req_2" - ] - assert len(req_1_objects) == 2 - assert len(req_2_objects) == 2 - assert all(obj.requirement_status == "PASS" for obj in req_1_objects) - assert all(obj.requirement_status == "FAIL" for obj in req_2_objects) + assert "requirements_created" in result + assert result["requirements_created"] >= 0 diff --git a/dashboard/pages/overview.py b/dashboard/pages/overview.py index c51d33aa47..56fbff483e 100644 --- a/dashboard/pages/overview.py +++ b/dashboard/pages/overview.py @@ -1,5 +1,4 @@ # Standard library imports -import csv import glob import json import os @@ -20,7 +19,6 @@ from dash.dependencies import Input, Output # Config import from dashboard.config import ( critical_color, - encoding_format, fail_color, folder_path_overview, high_color, @@ -46,6 +44,7 @@ from dashboard.lib.dropdowns import ( create_table_row_dropdown, ) from dashboard.lib.layouts import create_layout_overview +from prowler.lib.logger import logger # Suppress warnings warnings.filterwarnings("ignore") @@ -55,11 +54,13 @@ warnings.filterwarnings("ignore") csv_files = [] for file in glob.glob(os.path.join(folder_path_overview, "*.csv")): - with open(file, "r", newline="", encoding=encoding_format) as csvfile: - reader = csv.reader(csvfile) - num_rows = sum(1 for row in reader) + try: + df = pd.read_csv(file, sep=";") + num_rows = len(df) if num_rows > 1: csv_files.append(file) + except Exception: + logger.error(f"Error reading file {file}") # Import logos providers @@ -191,7 +192,13 @@ else: data.rename(columns={"RESOURCE_ID": "RESOURCE_UID"}, inplace=True) # Remove dupplicates on the finding_uid colummn but keep the last one taking into account the timestamp - data = data.sort_values("TIMESTAMP").drop_duplicates("FINDING_UID", keep="last") + data["DATE"] = data["TIMESTAMP"].dt.date + data = ( + data.sort_values("TIMESTAMP") + .groupby(["DATE", "FINDING_UID"], as_index=False) + .last() + ) + data["TIMESTAMP"] = pd.to_datetime(data["TIMESTAMP"]) data["ASSESSMENT_TIME"] = data["TIMESTAMP"].dt.strftime("%Y-%m-%d") data_valid = pd.DataFrame() diff --git a/docs/developer-guide/lighthouse.md b/docs/developer-guide/lighthouse.md index e0a8d939dc..e01067844f 100644 --- a/docs/developer-guide/lighthouse.md +++ b/docs/developer-guide/lighthouse.md @@ -1,6 +1,6 @@ -# Extending Prowler Lighthouse +# Extending Prowler Lighthouse AI -This guide helps developers customize and extend Prowler Lighthouse by adding or modifying AI agents. +This guide helps developers customize and extend Prowler Lighthouse AI by adding or modifying AI agents. ## Understanding AI Agents @@ -13,7 +13,7 @@ AI agents fall into two main categories: - **Autonomous Agents**: Freely chooses from available tools to complete tasks, adapting their approach based on context. They decide which tools to use and when. - **Workflow Agents**: Follows structured paths with predefined logic. They execute specific tool sequences and can include conditional logic. -Prowler Lighthouse is an autonomous agent - selecting the right tool(s) based on the users query. +Prowler Lighthouse AI is an autonomous agent - selecting the right tool(s) based on the users query. ???+ note To learn more about AI agents, read [Anthropic's blog post on building effective agents](https://www.anthropic.com/engineering/building-effective-agents). @@ -24,15 +24,15 @@ The autonomous nature of agents depends on the underlying LLM. Autonomous agents After evaluating multiple LLM providers (OpenAI, Gemini, Claude, LLama) based on tool calling features and response accuracy, we recommend using the `gpt-4o` model. -## Prowler Lighthouse Architecture +## Prowler Lighthouse AI Architecture -Prowler Lighthouse uses a multi-agent architecture orchestrated by the [Langgraph-Supervisor](https://www.npmjs.com/package/@langchain/langgraph-supervisor) library. +Prowler Lighthouse AI uses a multi-agent architecture orchestrated by the [Langgraph-Supervisor](https://www.npmjs.com/package/@langchain/langgraph-supervisor) library. ### Architecture Components Prowler Lighthouse architecture -Prowler Lighthouse integrates with the NextJS application: +Prowler Lighthouse AI integrates with the NextJS application: - The [Langgraph-Supervisor](https://www.npmjs.com/package/@langchain/langgraph-supervisor) library integrates directly with NextJS - The system uses the authenticated user session to interact with the Prowler API server @@ -74,7 +74,7 @@ Modifying the supervisor prompt allows you to: The supervisor agent and all specialized agents are defined in the `route.ts` file. The supervisor agent uses [langgraph-supervisor](https://www.npmjs.com/package/@langchain/langgraph-supervisor), while other agents use the prebuilt [create-react-agent](https://langchain-ai.github.io/langgraphjs/how-tos/create-react-agent/). -To add new capabilities or all Lighthouse to interact with other APIs, create additional specialized agents: +To add new capabilities or all Lighthouse AI to interact with other APIs, create additional specialized agents: 1. First determine what the new agent would do. Create a detailed prompt defining the agent's purpose and capabilities. You can see an example from [here](https://github.com/prowler-cloud/prowler/blob/master/ui/lib/lighthouse/prompts.ts#L359-L385). ???+ note diff --git a/docs/getting-started/requirements.md b/docs/getting-started/requirements.md index 1405477533..d2be93c630 100644 --- a/docs/getting-started/requirements.md +++ b/docs/getting-started/requirements.md @@ -491,11 +491,15 @@ The provided credentials must have the appropriate permissions to perform all th ## Infrastructure as Code (IaC) -Prowler's Infrastructure as Code (IaC) provider enables you to scan local infrastructure code for security and compliance issues using [Checkov](https://www.checkov.io/). This provider supports a wide range of IaC frameworks and requires no cloud authentication. +Prowler's Infrastructure as Code (IaC) provider enables you to scan local or remote infrastructure code for security and compliance issues using [Checkov](https://www.checkov.io/). This provider supports a wide range of IaC frameworks and requires no cloud authentication for local scans. ### Authentication -The IaC provider does not require any authentication or credentials since it scans local files directly. This makes it ideal for CI/CD pipelines and local development environments. +- For local scans, no authentication is required. +- For remote repository scans, authentication can be provided via: + - [**GitHub Username and Personal Access Token (PAT)**](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) + - [**GitHub OAuth App Token**](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token) + - [**Git URL**](https://git-scm.com/docs/git-clone#_git_urls) ### Supported Frameworks @@ -515,27 +519,3 @@ The IaC provider leverages Checkov to support multiple frameworks, including: - Kustomize - OpenAPI - SAST, SCA (Software Composition Analysis) - -### Usage - -To run Prowler with the IaC provider, use the `iac` flag. You can specify the directory to scan, frameworks to include, and paths to exclude. - -#### Basic Example - -```console -prowler iac --scan-path ./my-iac-directory -``` - -#### Specify Frameworks - -Scan only Terraform and Kubernetes files: - -```console -prowler iac --scan-path ./my-iac-directory --frameworks terraform kubernetes -``` - -#### Exclude Paths - -```console -prowler iac --scan-path ./my-iac-directory --exclude-path ./my-iac-directory/test,./my-iac-directory/examples -``` diff --git a/docs/img/mutelist-ui-1.png b/docs/img/mutelist-ui-1.png new file mode 100644 index 0000000000..8114e64fdf Binary files /dev/null and b/docs/img/mutelist-ui-1.png differ diff --git a/docs/img/mutelist-ui-2.png b/docs/img/mutelist-ui-2.png new file mode 100644 index 0000000000..847f7c1b16 Binary files /dev/null and b/docs/img/mutelist-ui-2.png differ diff --git a/docs/img/mutelist-ui-3.png b/docs/img/mutelist-ui-3.png new file mode 100644 index 0000000000..da1951c533 Binary files /dev/null and b/docs/img/mutelist-ui-3.png differ diff --git a/docs/img/mutelist-ui-4.png b/docs/img/mutelist-ui-4.png new file mode 100644 index 0000000000..82ce685e51 Binary files /dev/null and b/docs/img/mutelist-ui-4.png differ diff --git a/docs/img/mutelist-ui-5.png b/docs/img/mutelist-ui-5.png new file mode 100644 index 0000000000..128bf30731 Binary files /dev/null and b/docs/img/mutelist-ui-5.png differ diff --git a/docs/img/mutelist-ui-6.png b/docs/img/mutelist-ui-6.png new file mode 100644 index 0000000000..659eccb61e Binary files /dev/null and b/docs/img/mutelist-ui-6.png differ diff --git a/docs/img/mutelist-ui-7.png b/docs/img/mutelist-ui-7.png new file mode 100644 index 0000000000..e6352c97e9 Binary files /dev/null and b/docs/img/mutelist-ui-7.png differ diff --git a/docs/img/mutelist-ui-8.png b/docs/img/mutelist-ui-8.png new file mode 100644 index 0000000000..54e2110edb Binary files /dev/null and b/docs/img/mutelist-ui-8.png differ diff --git a/docs/img/mutelist-ui-9.png b/docs/img/mutelist-ui-9.png new file mode 100644 index 0000000000..cba2ece1a3 Binary files /dev/null and b/docs/img/mutelist-ui-9.png differ diff --git a/docs/img/saml/idp_config.png b/docs/img/saml/idp_config.png new file mode 100644 index 0000000000..0665b34886 Binary files /dev/null and b/docs/img/saml/idp_config.png differ diff --git a/docs/img/saml/saml-sso-azure-1.png b/docs/img/saml/saml-sso-azure-1.png new file mode 100644 index 0000000000..c714d742e7 Binary files /dev/null and b/docs/img/saml/saml-sso-azure-1.png differ diff --git a/docs/img/saml/saml-sso-azure-2.png b/docs/img/saml/saml-sso-azure-2.png new file mode 100644 index 0000000000..483af4548c Binary files /dev/null and b/docs/img/saml/saml-sso-azure-2.png differ diff --git a/docs/img/saml/saml-sso-azure-3.png b/docs/img/saml/saml-sso-azure-3.png new file mode 100644 index 0000000000..7983694c5c Binary files /dev/null and b/docs/img/saml/saml-sso-azure-3.png differ diff --git a/docs/img/saml/saml-sso-azure-4.png b/docs/img/saml/saml-sso-azure-4.png new file mode 100644 index 0000000000..f6c6029343 Binary files /dev/null and b/docs/img/saml/saml-sso-azure-4.png differ diff --git a/docs/img/saml/saml-sso-azure-5.png b/docs/img/saml/saml-sso-azure-5.png new file mode 100644 index 0000000000..6dbeb6e65d Binary files /dev/null and b/docs/img/saml/saml-sso-azure-5.png differ diff --git a/docs/img/saml/saml-sso-azure-6.png b/docs/img/saml/saml-sso-azure-6.png new file mode 100644 index 0000000000..b351004a87 Binary files /dev/null and b/docs/img/saml/saml-sso-azure-6.png differ diff --git a/docs/img/saml/saml-sso-azure-7.png b/docs/img/saml/saml-sso-azure-7.png new file mode 100644 index 0000000000..76551bef06 Binary files /dev/null and b/docs/img/saml/saml-sso-azure-7.png differ diff --git a/docs/img/saml/saml-sso-azure-8.png b/docs/img/saml/saml-sso-azure-8.png new file mode 100644 index 0000000000..5dd2f1e1ef Binary files /dev/null and b/docs/img/saml/saml-sso-azure-8.png differ diff --git a/docs/img/saml/saml-sso-azure-9.png b/docs/img/saml/saml-sso-azure-9.png new file mode 100644 index 0000000000..130dd5e7cd Binary files /dev/null and b/docs/img/saml/saml-sso-azure-9.png differ diff --git a/docs/img/saml/saml-step-1.png b/docs/img/saml/saml-step-1.png new file mode 100644 index 0000000000..d505c2597c Binary files /dev/null and b/docs/img/saml/saml-step-1.png differ diff --git a/docs/img/saml/saml-step-2.png b/docs/img/saml/saml-step-2.png new file mode 100644 index 0000000000..ddb036c98d Binary files /dev/null and b/docs/img/saml/saml-step-2.png differ diff --git a/docs/img/saml/saml-step-3.png b/docs/img/saml/saml-step-3.png new file mode 100644 index 0000000000..2b8dfbd845 Binary files /dev/null and b/docs/img/saml/saml-step-3.png differ diff --git a/docs/img/saml/saml-step-4.png b/docs/img/saml/saml-step-4.png new file mode 100644 index 0000000000..cca0987c50 Binary files /dev/null and b/docs/img/saml/saml-step-4.png differ diff --git a/docs/img/saml/saml-step-5.png b/docs/img/saml/saml-step-5.png new file mode 100644 index 0000000000..ea83f960eb Binary files /dev/null and b/docs/img/saml/saml-step-5.png differ diff --git a/docs/img/saml/saml-step-remove.png b/docs/img/saml/saml-step-remove.png new file mode 100644 index 0000000000..f0868691af Binary files /dev/null and b/docs/img/saml/saml-step-remove.png differ diff --git a/docs/img/saml/saml_attribute_statements.png b/docs/img/saml/saml_attribute_statements.png new file mode 100644 index 0000000000..62d6366131 Binary files /dev/null and b/docs/img/saml/saml_attribute_statements.png differ diff --git a/docs/index.md b/docs/index.md index fb8e1d734e..f8625de875 100644 --- a/docs/index.md +++ b/docs/index.md @@ -312,6 +312,51 @@ Prowler is available as a project in [PyPI](https://pypi.org/project/prowler/), prowler azure --az-cli-auth ``` +### Prowler App Update + +You have two options to upgrade your Prowler App installation: + +#### Option 1: Change env file with the following values + +Edit your `.env` file and change the version values: + +```env +PROWLER_UI_VERSION="5.9.0" +PROWLER_API_VERSION="5.9.0" +``` + +#### Option 2: Run the following command + +```bash +docker compose pull --policy always +``` + +The `--policy always` flag ensures that Docker pulls the latest images even if they already exist locally. + + +???+ note "What Gets Preserved During Upgrade" + + Everything is preserved, nothing will be deleted after the update. + +#### Troubleshooting + +If containers don't start, check logs for errors: + +```bash +# Check logs for errors +docker compose logs + +# Verify image versions +docker images | grep prowler +``` + +If you encounter issues, you can rollback to the previous version by changing the `.env` file back to your previous version and running: + +```bash +docker compose pull +docker compose up -d +``` + ## Prowler container versions The available versions of Prowler CLI are the following: @@ -614,12 +659,23 @@ prowler github --github-app-id app_id --github-app-key app_key #### Infrastructure as Code (IaC) -Prowler's Infrastructure as Code (IaC) provider enables you to scan local infrastructure code for security and compliance issues using [Checkov](https://www.checkov.io/). This provider supports a wide range of IaC frameworks, allowing you to assess your code before deployment. +Prowler's Infrastructure as Code (IaC) provider enables you to scan local or remote infrastructure code for security and compliance issues using [Checkov](https://www.checkov.io/). This provider supports a wide range of IaC frameworks, allowing you to assess your code before deployment. ```console # Scan a directory for IaC files prowler iac --scan-path ./my-iac-directory +# Scan a remote GitHub repository (public or private) +prowler iac --scan-repository-url https://github.com/user/repo.git + +# Authenticate to a private repo with GitHub username and PAT +prowler iac --scan-repository-url https://github.com/user/repo.git \ + --github-username --personal-access-token + +# Authenticate to a private repo with OAuth App Token +prowler iac --scan-repository-url https://github.com/user/repo.git \ + --oauth-app-token + # Specify frameworks to scan (default: all) prowler iac --scan-path ./my-iac-directory --frameworks terraform kubernetes @@ -628,8 +684,10 @@ prowler iac --scan-path ./my-iac-directory --exclude-path ./my-iac-directory/tes ``` ???+ note - - The IaC provider does not require cloud authentication - - It is ideal for CI/CD pipelines and local development environments + - `--scan-path` and `--scan-repository-url` are mutually exclusive; only one can be specified at a time. + - For remote repository scans, authentication can be provided via CLI flags or environment variables (`GITHUB_OAUTH_APP_TOKEN`, `GITHUB_USERNAME`, `GITHUB_PERSONAL_ACCESS_TOKEN`). CLI flags take precedence. + - The IaC provider does not require cloud authentication for local scans. + - It is ideal for CI/CD pipelines and local development environments. - For more details on supported frameworks and rules, see the [Checkov documentation](https://www.checkov.io/1.Welcome/Quick%20Start.html) See more details about IaC scanning in the [IaC Tutorial](tutorials/iac/getting-started-iac.md) section. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a107c58391..e5650723db 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -12,3 +12,34 @@ See section [Logging](./tutorials/logging.md) for further information or [contact us](./contact.md). + +## Common Issues with Docker Compose Installation + +- **Problem adding AWS Provider using "Connect assuming IAM Role" in Docker (see [GitHub Issue #7745](https://github.com/prowler-cloud/prowler/issues/7745))**: + + When running Prowler App via Docker, you may encounter errors such as `Provider not set`, `AWS assume role error - Unable to locate credentials`, or `Provider has no secret` when trying to add an AWS Provider using the "Connect assuming IAM Role" option. This typically happens because the container does not have access to the necessary AWS credentials or profiles. + + **Workaround:** + + - Ensure your AWS credentials and configuration are available to the Docker container. You can do this by mounting your local `.aws` directory into the container. For example, in your `docker-compose.yaml`, add the following volume to the relevant services: + + ```yaml + volumes: + - "${HOME}/.aws:/home/prowler/.aws:ro" + ``` + This should be added to the `api`, `worker`, and `worker-beat` services. + + - Create or update your `~/.aws/config` and `~/.aws/credentials` files with the appropriate profiles and roles. For example: + + ```ini + [profile prowler-profile] + role_arn = arn:aws:iam:::role/ProwlerScan + source_profile = default + ``` + And set the environment variable in your `.env` file: + + ```env + AWS_PROFILE=prowler-profile + ``` + + - If you are scanning multiple AWS accounts, you may need to add multiple profiles to your AWS config. Note that this workaround is mainly for local testing; for production or multi-account setups, follow the [CloudFormation Template guide](https://github.com/prowler-cloud/prowler/issues/7745) and ensure the correct IAM roles and permissions are set up in each account. diff --git a/docs/tutorials/configuration_file.md b/docs/tutorials/configuration_file.md index d7790292e9..c948cb4798 100644 --- a/docs/tutorials/configuration_file.md +++ b/docs/tutorials/configuration_file.md @@ -78,6 +78,7 @@ The following list includes all the Azure checks with configurable variables tha | `app_ensure_python_version_is_latest` | `python_latest_version` | String | | `app_ensure_java_version_is_latest` | `java_latest_version` | String | | `sqlserver_recommended_minimal_tls_version` | `recommended_minimal_tls_versions` | List of Strings | +| `defender_attack_path_notifications_properly_configured` | `defender_attack_path_minimal_risk_level` | String | ## GCP diff --git a/docs/tutorials/github/authentication.md b/docs/tutorials/github/authentication.md index 5b589921ec..c3a29ad0e8 100644 --- a/docs/tutorials/github/authentication.md +++ b/docs/tutorials/github/authentication.md @@ -27,10 +27,10 @@ prowler github --oauth-app-token oauth_token ``` ### GitHub App Credentials -Use GitHub App credentials by specifying the App ID and the private key. +Use GitHub App credentials by specifying the App ID and the private key path. ```console -prowler github --github-app-id app_id --github-app-key app_key +prowler github --github-app-id app_id --github-app-key-path app_key_path ``` ### Automatic Login Method Detection @@ -38,7 +38,7 @@ If no login method is explicitly provided, Prowler will automatically attempt to 1. `GITHUB_PERSONAL_ACCESS_TOKEN` 2. `GITHUB_OAUTH_APP_TOKEN` -3. `GITHUB_APP_ID` and `GITHUB_APP_KEY` +3. `GITHUB_APP_ID` and `GITHUB_APP_KEY` (where the key is the content of the private key file) ???+ note Ensure the corresponding environment variables are set up before running Prowler for automatic detection if you don't plan to specify the login method. diff --git a/docs/tutorials/github/getting-started-github.md b/docs/tutorials/github/getting-started-github.md new file mode 100644 index 0000000000..604d97fdda --- /dev/null +++ b/docs/tutorials/github/getting-started-github.md @@ -0,0 +1,209 @@ +# Getting Started with GitHub Authentication + +This guide explains how to set up authentication with GitHub for Prowler. The documentation covers credential retrieval processes for each supported authentication method. + +## Prerequisites + +- GitHub account +- Token creation permissions (organization-level access requires admin permissions) + +## Authentication Methods + +### 1. Personal Access Token (PAT) + +Personal Access Tokens provide the simplest GitHub authentication method and support individual user authentication or testing scenarios. + +#### How to Create a Personal Access Token + +1. **Navigate to GitHub Settings** + - Open [GitHub](https://github.com) and sign in + - Click the profile picture in the top right corner + - Select "Settings" from the dropdown menu + +2. **Access Developer Settings** + - Scroll down the left sidebar + - Click "Developer settings" + +3. **Generate New Token** + - Click "Personal access tokens" + - Select "Tokens (classic)" + - Click "Generate new token" + +4. **Configure Token Permissions** + To enable Prowler functionality, configure the following scopes: + - `repo`: Full control of private repositories + - `read:org`: Read organization and team membership + - `read:user`: Read user profile data + - `read:discussion`: Read discussions + - `read:enterprise`: Read enterprise data (if applicable) + +5. **Copy and Store the Token** + - Copy the generated token immediately (GitHub displays tokens only once) + - Store tokens securely using environment variables + +#### How to Use Personal Access Tokens + +Choose one of the following methods: + +**Command-line flag:** + +```console +prowler github --personal-access-token your_token_here +``` + +**Environment variable:** + +```console +export GITHUB_PERSONAL_ACCESS_TOKEN="your_token_here" +prowler github +``` + +### 2. OAuth App Token + +OAuth Apps enable applications to act on behalf of users with explicit consent. + +#### How to Create an OAuth App + +1. **Navigate to Developer Settings** + - Open GitHub Settings → Developer settings + - Click "OAuth Apps" + +2. **Register New Application** + - Click "New OAuth App" + - Complete the required fields: + - **Application name**: Descriptive application name + - **Homepage URL**: Application homepage + - **Authorization callback URL**: User redirection URL after authorization + +3. **Obtain Authorization Code** + - Request authorization code (replace `{app_id}` with the application ID): + ``` + https://github.com/login/oauth/authorize?client_id={app_id} + ``` + +4. **Exchange Code for Token** + - Exchange authorization code for access token (replace `{app_id}`, `{secret}`, and `{code}`): + ``` + https://github.com/login/oauth/access_token?code={code}&client_id={app_id}&client_secret={secret} + ``` + +#### How to Use OAuth Tokens + +Choose one of the following methods: + +**Command-line flag:** + +```console +prowler github --oauth-app-token your_oauth_token +``` + +**Environment variable:** + +```console +export GITHUB_OAUTH_APP_TOKEN="your_oauth_token" +prowler github +``` + +### 3. GitHub App Credentials + +GitHub Apps provide the recommended integration method for accessing multiple repositories or organizations. + +#### How to Create a GitHub App + +1. **Navigate to Developer Settings** + - Open GitHub Settings → Developer settings + - Click "GitHub Apps" + +2. **Create New GitHub App** + - Click "New GitHub App" + - Complete the required fields: + - **GitHub App name**: Unique application name + - **Homepage URL**: Application homepage + - **Webhook URL**: Webhook payload URL (optional) + - **Permissions**: Application permission requirements + +3. **Configure Permissions** + To enable Prowler functionality, configure these permissions: + - **Repository permissions**: + - Contents (Read) + - Metadata (Read) + - Pull requests (Read) + - **Organization permissions**: + - Members (Read) + - Administration (Read) + - **Account permissions**: + - Email addresses (Read) + +4. **Generate Private Key** + - Scroll to the "Private keys" section after app creation + - Click "Generate a private key" + - Download the `.pem` file and store securely + +5. **Record App ID** + - Locate the App ID at the top of the GitHub App settings page + +#### How to Install the GitHub App + +1. **Install Application** + - Navigate to GitHub App settings + - Click "Install App" in the left sidebar + - Select the target account/organization + - Choose specific repositories or select "All repositories" + +#### How to Use GitHub App Credentials + +Choose one of the following methods: + +**Command-line flags:** + +```console +prowler github --github-app-id your_app_id --github-app-key /path/to/private-key.pem +``` + +**Environment variables:** + +```console +export GITHUB_APP_ID="your_app_id" +export GITHUB_APP_KEY="private-key-content" +prowler github +``` + +## Best Practices + +### Security Considerations + +Implement the following security measures: + +- **Secure Credential Storage**: Store credentials using environment variables instead of hardcoding tokens +- **Secrets Management**: Use dedicated secrets management systems in production environments +- **Regular Token Rotation**: Rotate tokens and keys regularly +- **Least Privilege Principle**: Grant only minimum required permissions +- **Permission Auditing**: Review and audit permissions regularly +- **Token Expiration**: Set appropriate expiration times for tokens +- **Usage Monitoring**: Monitor token usage and revoke unused tokens + +### Authentication Method Selection + +Choose the appropriate method based on use case: + +- **Personal Access Token**: Individual use, testing, or simple automation +- **OAuth App Token**: Applications requiring user consent and delegation +- **GitHub App**: Production integrations, especially for organizations + +## Troubleshooting Common Issues + +### Insufficient Permissions +- Verify token/app has necessary scopes/permissions +- Check organization restrictions on third-party applications + +### Token Expiration +- Confirm token has not expired +- Verify fine-grained tokens have correct resource access + +### Rate Limiting +- GitHub implements API call rate limits +- Consider GitHub Apps for higher rate limits + +### Organization Settings +- Some organizations restrict third-party applications +- Contact organization administrator if access is denied diff --git a/docs/tutorials/iac/getting-started-iac.md b/docs/tutorials/iac/getting-started-iac.md index 63e0e9f78c..052f72bae9 100644 --- a/docs/tutorials/iac/getting-started-iac.md +++ b/docs/tutorials/iac/getting-started-iac.md @@ -1,6 +1,6 @@ # Getting Started with the IaC Provider -Prowler's Infrastructure as Code (IaC) provider enables you to scan local infrastructure code for security and compliance issues using [Checkov](https://www.checkov.io/). This provider supports a wide range of IaC frameworks, allowing you to assess your code before deployment. +Prowler's Infrastructure as Code (IaC) provider enables you to scan local or remote infrastructure code for security and compliance issues using [Checkov](https://www.checkov.io/). This provider supports a wide range of IaC frameworks, allowing you to assess your code before deployment. ## Supported Frameworks @@ -23,21 +23,50 @@ The IaC provider leverages Checkov to support multiple frameworks, including: ## How It Works -- The IaC provider scans your local directory (or a specified path) for supported IaC files. -- No cloud credentials or authentication are required. +- The IaC provider scans your local directory (or a specified path) for supported IaC files, or scan a remote repository. +- No cloud credentials or authentication are required for local scans. +- For remote repository scans, authentication can be provided via [git URL](https://git-scm.com/docs/git-clone#_git_urls), CLI flags or environment variables. - Mutelist logic is handled by Checkov, not Prowler. - Results are output in the same formats as other Prowler providers (CSV, JSON, HTML, etc.). ## Usage -To run Prowler with the IaC provider, use the `iac` argument. You can specify the directory to scan, frameworks to include, and paths to exclude. +To run Prowler with the IaC provider, use the `iac` argument. You can specify the directory or repository to scan, frameworks to include, and paths to exclude. -### Basic Example +### Scan a Local Directory (default) ```sh prowler iac --scan-path ./my-iac-directory ``` +### Scan a Remote GitHub Repository + +```sh +prowler iac --scan-repository-url https://github.com/user/repo.git +``` + +#### Authentication for Remote Private Repositories + +You can provide authentication for private repositories using one of the following methods: + +- **GitHub Username and Personal Access Token (PAT):** + ```sh + prowler iac --scan-repository-url https://github.com/user/repo.git \ + --github-username --personal-access-token + ``` +- **GitHub OAuth App Token:** + ```sh + prowler iac --scan-repository-url https://github.com/user/repo.git \ + --oauth-app-token + ``` +- If not provided via CLI, the following environment variables will be used (in order of precedence): + - `GITHUB_OAUTH_APP_TOKEN` + - `GITHUB_USERNAME` and `GITHUB_PERSONAL_ACCESS_TOKEN` +- If neither CLI flags nor environment variables are set, the scan will attempt to clone without authentication or using the provided in the [git URL](https://git-scm.com/docs/git-clone#_git_urls). + +#### Mutually Exclusive Flags +- `--scan-path` and `--scan-repository-url` are mutually exclusive. Only one can be specified at a time. + ### Specify Frameworks Scan only Terraform and Kubernetes files: @@ -62,6 +91,8 @@ prowler iac --scan-path ./iac --output-formats csv json html ## Notes -- The IaC provider does not require cloud authentication. +- The IaC provider does not require cloud authentication for local scans. +- For remote repository scans, authentication is optional but required for private repos. +- CLI flags override environment variables for authentication. - It is ideal for CI/CD pipelines and local development environments. - For more details on supported frameworks and rules, see the [Checkov documentation](https://www.checkov.io/1.Welcome/Quick%20Start.html). diff --git a/docs/tutorials/prowler-app-lighthouse.md b/docs/tutorials/prowler-app-lighthouse.md index 52e94cfe9d..2524818c32 100644 --- a/docs/tutorials/prowler-app-lighthouse.md +++ b/docs/tutorials/prowler-app-lighthouse.md @@ -1,12 +1,12 @@ -# Prowler Lighthouse +# Prowler Lighthouse AI -Prowler Lighthouse is an AI Cloud Security Analyst chatbot that helps you understand, prioritize, and remediate security findings in your cloud environments. It's designed to provide security expertise for teams without dedicated resources, acting as your 24/7 virtual cloud security analyst. +Prowler Lighthouse AI is a Cloud Security Analyst chatbot that helps you understand, prioritize, and remediate security findings in your cloud environments. It's designed to provide security expertise for teams without dedicated resources, acting as your 24/7 virtual cloud security analyst. Prowler Lighthouse ## How It Works -Prowler Lighthouse uses OpenAI's language models and integrates with your Prowler security findings data. +Prowler Lighthouse AI uses OpenAI's language models and integrates with your Prowler security findings data. Here's what's happening behind the scenes: @@ -14,28 +14,28 @@ Here's what's happening behind the scenes: - It uses a ["supervisor" architecture](https://langchain-ai.lang.chat/langgraphjs/tutorials/multi_agent/agent_supervisor/) that interacts with different agents for specialized tasks. For example, `findings_agent` can analyze detected security findings, while `overview_agent` provides a summary of connected cloud accounts. - The system connects to OpenAI models to understand, fetch the right data, and respond to the user's query. ???+ note - Lighthouse is tested against `gpt-4o` and `gpt-4o-mini` OpenAI models. + Lighthouse AI is tested against `gpt-4o` and `gpt-4o-mini` OpenAI models. - The supervisor agent is the main contact point. It is what users interact with directly from the chat interface. It coordinates with other agents to answer users' questions comprehensively. -Lighthouse Architecture +Lighthouse AI Architecture ???+ note All agents can only read relevant security data. They cannot modify your data or access sensitive information like configured secrets or tenant details. ## Set up -Getting started with Prowler Lighthouse is easy: +Getting started with Prowler Lighthouse AI is easy: 1. Go to the configuration page in your Prowler dashboard. 2. Enter your OpenAI API key. 3. Select your preferred model. The recommended one for best results is `gpt-4o`. 4. (Optional) Add business context to improve response quality and prioritization. -Lighthouse Configuration +Lighthouse AI Configuration ### Adding Business Context -The optional business context field lets you provide additional information to help Lighthouse understand your environment and priorities, including: +The optional business context field lets you provide additional information to help Lighthouse AI understand your environment and priorities, including: - Your organization's cloud security goals - Information about account owners or responsible teams @@ -46,7 +46,7 @@ Better context leads to more relevant responses and prioritization that aligns w ## Capabilities -Prowler Lighthouse is designed to be your AI security team member, with capabilities including: +Prowler Lighthouse AI is designed to be your AI security team member, with capabilities including: ### Natural Language Querying @@ -70,7 +70,7 @@ Get tailored step-by-step instructions for fixing security issues: ### Enhanced Context and Analysis -Lighthouse can provide additional context to help you understand the findings: +Lighthouse AI can provide additional context to help you understand the findings: - Explain security concepts related to findings in simple terms - Provide risk assessments based on your environment and context @@ -82,20 +82,20 @@ Lighthouse can provide additional context to help you understand the findings: ## Important Notes -Prowler Lighthouse is powerful, but there are limitations: +Prowler Lighthouse AI is powerful, but there are limitations: - **Continuous improvement**: Please report any issues, as the feature may make mistakes or encounter errors, despite extensive testing. -- **Access limitations**: Lighthouse can only access data the logged-in user can view. If you can't see certain information, Lighthouse can't see it either. -- **NextJS session dependence**: If your Prowler application session expires or logs out, Lighthouse will error out. Refresh and log back in to continue. +- **Access limitations**: Lighthouse AI can only access data the logged-in user can view. If you can't see certain information, Lighthouse AI can't see it either. +- **NextJS session dependence**: If your Prowler application session expires or logs out, Lighthouse AI will error out. Refresh and log back in to continue. - **Response quality**: The response quality depends on the selected OpenAI model. For best results, use gpt-4o. ### Getting Help -If you encounter issues with Prowler Lighthouse or have suggestions for improvements, please [reach out through our Slack channel](https://goto.prowler.com/slack). +If you encounter issues with Prowler Lighthouse AI or have suggestions for improvements, please [reach out through our Slack channel](https://goto.prowler.com/slack). ### What Data Is Shared to OpenAI? -The following API endpoints are accessible to Prowler Lighthouse. Data from the following API endpoints could be shared with OpenAI depending on the scope of user's query: +The following API endpoints are accessible to Prowler Lighthouse AI. Data from the following API endpoints could be shared with OpenAI depending on the scope of user's query: #### Accessible API Endpoints @@ -139,7 +139,7 @@ The following API endpoints are accessible to Prowler Lighthouse. Data from the #### Excluded API Endpoints -Not all Prowler API endpoints are integrated with Lighthouse. They are intentionally excluded for the following reasons: +Not all Prowler API endpoints are integrated with Lighthouse AI. They are intentionally excluded for the following reasons: - OpenAI/other LLM providers shouldn't have access to sensitive data (like fetching provider secrets and other sensitive config) - Users queries don't need responses from those API endpoints (ex: tasks, tenant details, downloading zip file, etc.) @@ -173,7 +173,7 @@ Not all Prowler API endpoints are integrated with Lighthouse. They are intention - List all tasks - `/api/v1/tasks` - Retrieve data from a specific task - `/api/v1/tasks/{id}` -**Lighthouse Configuration:** +**Lighthouse AI Configuration:** - List OpenAI configuration - `/api/v1/lighthouse-config` - Retrieve OpenAI key and configuration - `/api/v1/lighthouse-config/{id}` @@ -187,7 +187,7 @@ Not all Prowler API endpoints are integrated with Lighthouse. They are intention During feature development, we evaluated other LLM models. -- **Claude AI** - Claude models have [tier-based ratelimits](https://docs.anthropic.com/en/api/rate-limits#requirements-to-advance-tier). For Lighthouse to answer slightly complex questions, there are a handful of API calls to the LLM provider within few seconds. With Claude's tiering system, users must purchase $400 credits or convert their subscription to monthly invoicing after talking to their sales team. This pricing may not suit all Prowler users. +- **Claude AI** - Claude models have [tier-based ratelimits](https://docs.anthropic.com/en/api/rate-limits#requirements-to-advance-tier). For Lighthouse AI to answer slightly complex questions, there are a handful of API calls to the LLM provider within few seconds. With Claude's tiering system, users must purchase $400 credits or convert their subscription to monthly invoicing after talking to their sales team. This pricing may not suit all Prowler users. - **Gemini Models** - Gemini lacks a solid tool calling feature like OpenAI. It calls functions recursively until exceeding limits. Gemini-2.5-Pro-Experimental is better than previous models regarding tool calling and responding, but it's still experimental. - **Deepseek V3** - Doesn't support system prompt messages. @@ -197,8 +197,8 @@ Context windows are limited. While demo data fits inside the context window, que **3. Is my security data shared with OpenAI?** -Minimal data is shared to generate useful responses. Agents can access security findings and remediation details when needed. Provider secrets are protected by design and cannot be read. The Lighthouse key is only accessible to our NextJS server and is never sent to LLMs. Resource metadata (names, tags, account/project IDs, etc) may be shared with OpenAI based on your query requirements. +Minimal data is shared to generate useful responses. Agents can access security findings and remediation details when needed. Provider secrets are protected by design and cannot be read. The OpenAI key configured with Lighthouse AI is only accessible to our NextJS server and is never sent to LLMs. Resource metadata (names, tags, account/project IDs, etc) may be shared with OpenAI based on your query requirements. -**4. Can the Lighthouse change my cloud environment?** +**4. Can the Lighthouse AI change my cloud environment?** No. The agent doesn't have the tools to make the changes, even if the configured cloud provider API keys contain permissions to modify resources. diff --git a/docs/tutorials/prowler-app-mute-findings.md b/docs/tutorials/prowler-app-mute-findings.md new file mode 100644 index 0000000000..599ade9c55 --- /dev/null +++ b/docs/tutorials/prowler-app-mute-findings.md @@ -0,0 +1,59 @@ +# Mute Findings (Mutelist) + +Prowler App allows users to mute specific findings to focus on the most critical security issues. This comprehensive guide demonstrates how to effectively use the Mutelist feature to manage and prioritize security findings. + +## What Is the Mutelist Feature? + +The Mutelist feature enables users to: + +- **Suppress specific findings** from appearing in future scans +- **Focus on critical issues** by hiding resolved or accepted risks +- **Maintain audit trails** of muted findings for compliance purposes +- **Streamline security workflows** by reducing noise from non-critical findings + +## Prerequisites + +Before muting findings, ensure: + +- Valid access to Prowler App with appropriate permissions +- A provider added to the Prowler App +- Understanding of the security implications of muting specific findings + +???+ warning + Muting findings does not resolve underlying security issues. Review each finding carefully before muting to ensure it represents an acceptable risk or has been properly addressed. + +## Step 1: Add a provider + +To configure Mutelist: + +1. Log into Prowler App +2. Navigate to the providers page +![Add provider](../img/mutelist-ui-1.png) +3. Add a provider, then "Configure Muted Findings" button will be enabled in providers page and scans page +![Button enabled in providers page](../img/mutelist-ui-2.png) +![Button enabled in scans pages](../img/mutelist-ui-3.png) + + +## Step 2: Configure Mutelist + +1. Open the modal by clicking "Configure Muted Findings" button +![Open modal](../img/mutelist-ui-4.png) +1. Provide a valid Mutelist in `YAML` format. More details about Mutelist [here](../tutorials/mutelist.md) +![Valid YAML configuration](../img/mutelist-ui-5.png) +If the YAML configuration is invalid, an error message will be displayed +![Wrong YAML configuration](../img/mutelist-ui-7.png) +![Wrong YAML configuration 2](../img/mutelist-ui-8.png) + +## Step 3: Review the Mutelist + +1. Once added, the configuration can be removed or updated +![Remove or update configuration](../img/mutelist-ui-6.png) + +## Step 4: Check muted findings in the scan results + +1. Run a new scan +2. Check the muted findings in the scan results +![Check muted fidings](../img/mutelist-ui-9.png) + +???+ note + The Mutelist configuration takes effect on the next scans. \ No newline at end of file diff --git a/docs/tutorials/prowler-app-sso-entra.md b/docs/tutorials/prowler-app-sso-entra.md new file mode 100644 index 0000000000..a5be365846 --- /dev/null +++ b/docs/tutorials/prowler-app-sso-entra.md @@ -0,0 +1,43 @@ +# Entra ID Configuration + +This page provides instructions for creating and configuring a Microsoft Entra ID (formerly Azure AD) application to use SAML SSO with Prowler App. + +## Creating and Configuring the Enterprise Application + +1. From the "Enterprise Applications" page in the Azure Portal, click "+ New application". + + ![New application](../img/saml/saml-sso-azure-1.png) + +2. At the top of the page, click "+ Create your own application". + + ![Create application](../img/saml/saml-sso-azure-2.png) + +3. Enter a name for the application and select the "Integrate any other application you don't find in the gallery (Non-gallery)" option. + + ![Enter name](../img/saml/saml-sso-azure-3.png) + +4. Assign users and groups to the application, then proceed to "Set up single sign on" and select "SAML" as the method. + + ![Select SAML](../img/saml/saml-sso-azure-4.png) + +5. In the "Basic SAML Configuration" section, click "Edit". + + ![Edit](../img/saml/saml-sso-azure-5.png) + +6. Enter the "Identifier (Entity ID)" and "Reply URL (Assertion Consumer Service URL)". These values can be obtained from the SAML SSO integration setup in Prowler App. For detailed instructions, refer to the [SAML SSO Configuration](./prowler-app-sso.md) page. + + ![Enter data](../img/saml/saml-sso-azure-6.png) + +7. In the "SAML Certificates" section, click "Edit". + + ![Edit](../img/saml/saml-sso-azure-7.png) + +8. For the "Signing Option," select "Sign SAML response and assertion", and then click "Save". + + ![Signing options](../img/saml/saml-sso-azure-8.png) + +9. Once the changes are saved, the metadata XML can be downloaded from the "App Federation Metadata Url". + + ![Metadata XML](../img/saml/saml-sso-azure-9.png) + +10. Save the downloaded Metadata XML to a file. To complete the setup, upload this file during the Prowler App integration. (See the [SAML SSO Configuration](./prowler-app-sso.md) page for details). diff --git a/docs/tutorials/prowler-app-sso.md b/docs/tutorials/prowler-app-sso.md index 2ca898beb4..61d913b2fd 100644 --- a/docs/tutorials/prowler-app-sso.md +++ b/docs/tutorials/prowler-app-sso.md @@ -1,175 +1,203 @@ -# Configuring SAML Single Sign-On (SSO) in Prowler +# SAML Single Sign-On (SSO) Configuration -This guide explains how to enable and test SAML SSO integration in Prowler. It includes environment setup, API endpoints, and how to configure Okta as your Identity Provider (IdP). +This guide provides comprehensive instructions to configure SAML-based Single Sign-On (SSO) in Prowler App. This configuration allows users to authenticate using the organization's Identity Provider (IdP). + +This document is divided into two main sections: + +- **User Guide**: For organization administrators to configure SAML SSO through Prowler App. + +- **Developer and Administrator Guide**: For developers and system administrators running self-hosted Prowler App instances, providing technical details on environment configuration, API usage, and testing. --- -## Environment Configuration +## User Guide: Configuring SAML SSO in Prowler App -### `DJANGO_ALLOWED_HOSTS` +Follow these steps to enable and configure SAML SSO for an organization. -Update this variable to specify which domains Django should accept incoming requests from. This typically includes: +### Key Features -- `localhost` for local development -- container hostnames (e.g. `prowler-api`) -- public-facing domains or tunnels (e.g. ngrok) +Prowler can be integrated with SAML SSO identity providers such as Okta to enable single sign-on for the organization's users. The Prowler SAML integration currently supports the following features: -**Example**: +- **IdP-Initiated SSO**: Users can initiate login from their Identity Provider's dashboard. +- **SP-Initiated SSO**: Users can initiate login directly from the Prowler login page. +- **Just-in-Time Provisioning**: Users from the organization signing into Prowler for the first time will be automatically created. -```env -DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,prowler-api,mycompany.prowler -``` +???+ warning "Deactivate SAML" + If the SAML configuration is removed, users who previously authenticated via SAML will need to reset their password to regain access using standard login. This is because their accounts no longer have valid authentication credentials without the SAML integration. -# SAML Configuration API +### Prerequisites -You can manage SAML settings via the API. Prowler provides full CRUD support for tenant-specific SAML configuration. +- Administrator access to the Prowler organization is required. +- Administrative access to the SAML 2.0 compliant Identity Provider (e.g., Okta, Azure AD, Google Workspace) is necessary. -- GET /api/v1/saml-config: Retrieve the current configuration +### Configuration Steps -- POST /api/v1/saml-config: Create a new configuration +#### Step 1: Access Profile Settings -- PATCH /api/v1/saml-config: Update the existing configuration +To access the account settings, click the "Account" button in the top-right corner of Prowler App, or navigate directly to `https://cloud.prowler.com/profile` (or `http://localhost:3000/profile` for local setups). -- DELETE /api/v1/saml-config: Remove the current configuration +![Access Profile Settings](../img/saml/saml-step-1.png) +#### Step 2: Enable SAML Integration -???+ note "API Note" - SSO with SAML API documentation.[Prowler API Reference - Upload SAML configuration](https://api.prowler.com/api/v1/docs#tag/SAML/operation/saml_config_create) +On the profile page, find the "SAML SSO Integration" card and click "Enable" to begin the configuration process. -# SAML Initiate +![Enable SAML Integration](../img/saml/saml-step-2.png) -### Description +#### Step 3: Configure the Identity Provider (IdP) -This endpoint receives an email and checks if there is an active SAML configuration for the associated domain (i.e., the part after the @). If a configuration exists it responds with an HTTP 302 redirect to the appropriate saml_login endpoint for the organization. +The Prowler SAML configuration panel displays the information needed to configure the IdP. This information must be used to create a new SAML application in the IdP. -- POST /api/v1/accounts/saml/initiate/ +1. **Assertion Consumer Service (ACS) URL**: The endpoint in Prowler that will receive the SAML assertion from the IdP. +2. **Audience URI (Entity ID)**: A unique identifier for the Prowler application (Service Provider). -???+ note - Important: This endpoint is intended to be used from a browser, as it returns a 302 redirect that needs to be followed to continue the SAML authentication flow. For testing purposes, it is better to use a browser or a tool that follows redirects (such as Postman) rather than relying on unit tests that cannot capture the redirect behavior. +To configure the IdP, copy the **ACS URL** and **Audience URI** from Prowler and use them to set up a new SAML application. -### Expected payload -``` -{ - "email_domain": "user@domain.com" -} -``` +![IdP configuration](../img/saml/idp_config.png) -### Possible responses +???+ info "IdP Configuration" + The exact steps for configuring an IdP vary depending on the provider (Okta, Azure AD, etc.). Please refer to the IdP's documentation for instructions on creating a SAML application. For SSO integration with Azure AD / Entra ID, see our [Entra ID configuration instructions](./prowler-app-sso-entra.md). - • 302 FOUND: Redirects to the SAML login URL associated with the organization. +#### Step 4: Configure Attribute Mapping in the IdP - • 403 FORBIDDEN: The domain is not authorized. +For Prowler to correctly identify and provision users, the IdP must be configured to send the following attributes in the SAML assertion: -### Validation logic +| Attribute Name | Description | Required | +|----------------|---------------------------------------------------------------------------------------------------------|----------| +| `firstName` | The user's first name. | Yes | +| `lastName` | The user's last name. | Yes | +| `userType` | The Prowler role to be assigned to the user (e.g., `admin`, `auditor`). If a role with that name already exists, it will be used; otherwise, a new role called `no_permissions` will be created with minimal permissions. You can then edit the permissions for that role in the [RBAC Management tab](./prowler-app-rbac.md). | No | +| `companyName` | The user's company name. This is automatically populated if the IdP sends an `organization` attribute. | No | - • Looks up the domain in SAMLDomainIndex. +???+ info "IdP Attribute Mapping" + Note that the attribute name is just an example and may be different in your IdP. For instance, if your IdP provides a 'division' attribute, you can map it to 'userType'. + ![IdP configuration](../img/saml/saml_attribute_statements.png) - • Retrieves the related SAMLConfiguration object via tenant_id. +???+ warning "Dynamic Updates" + These attributes are updated in Prowler each time a user logs in. Any changes made in the identity provider (IdP) will be reflected the next time the user logs in again. +#### Step 5: Upload IdP Metadata to Prowler -# SAML Integration: Testing Guide +Once the IdP is configured, it provides a **metadata XML file**. This file contains the IdP's configuration information, such as its public key and login URL. -This document outlines the process for testing the SAML integration functionality. +To complete the Prowler-side configuration: + +1. Return to the Prowler SAML configuration page. + +2. Enter the **email domain** for the organization (e.g., `mycompany.com`). Prowler uses this to identify users who should authenticate via SAML. + +3. Upload the **metadata XML file** downloaded from the IdP. + +![Configure Prowler with IdP Metadata](../img/saml/saml-step-3.png) + +#### Step 6: Save and Verify Configuration + +Click the "Save" button to complete the setup. The "SAML Integration" card will now show an "Active" status, indicating that the configuration is complete and enabled. + +![Verify Integration Status](../img/saml/saml-step-4.png) + +???+ info "IdP Configuration" + The exact steps for configuring an IdP vary depending on the provider (Okta, Azure AD, etc.). Please refer to the IdP's documentation for instructions on creating a SAML application. + +##### Remove SAML Configuration +You can disable SAML SSO by removing the existing configuration from the integration panel. +![Remove SAML configuration](../img/saml/saml-step-remove.png) + +### Signing in with SAML SSO + +Once SAML SSO is enabled, users from the configured domain can sign in by entering their email address on the login page and clicking "Continue with SAML SSO". They will be redirected to the IdP to authenticate and then returned to Prowler. + +![Sign in with SAML SSO](../img/saml/saml-step-5.png) --- -## 1. Start Ngrok and Update ALLOWED_HOSTS +## Developer and Administrator Guide -Start ngrok on port 8080: -``` +This section provides technical details for developers and administrators of self-hosted Prowler instances. + +### Environment Configuration + +For self-hosted deployments, several environment variables must be configured to ensure SAML SSO functions correctly. These variables are typically set in an `.env` file. + +| Variable | Description | Example | +|---------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------| +| `API_BASE_URL` | The base URL of the Prowler API instance. | `http://mycompany.prowler/api/v1` | +| `DJANGO_ALLOWED_HOSTS` | A comma-separated list of hostnames that the Django backend will accept requests from. Include any domains used to access the Prowler API. | `localhost,127.0.0.1,prowler-api,mycompany.prowler` | +| `AUTH_URL` | The base URL of the Prowler web UI. This is used to construct the callback URL after authentication. | `http://mycompany.prowler` | +| `SAML_SSO_CALLBACK_URL` | The full callback URL where users are redirected after authenticating with the IdP. It is typically constructed using the `AUTH_URL`. | `${AUTH_URL}/api/auth/callback/saml` | + +After modifying these variables, the Prowler API must be restarted for the changes to take effect. + +### SAML API Reference + +Prowler provides a REST API to manage SAML configurations programmatically. + +- **Endpoint**: `/api/v1/saml-config` +- **Methods**: + - `GET`: Retrieve the current SAML configuration for the tenant. + - `POST`: Create a new SAML configuration. + - `PATCH`: Update an existing SAML configuration. + - `DELETE`: Remove the SAML configuration. + +???+ note "API Documentation" + For detailed information on using the API, refer to the [Prowler API Reference](https://api.prowler.com/api/v1/docs#tag/SAML/operation/saml_config_create). + +#### SAML Initiate Endpoint + +- **Endpoint**: `POST /api/v1/accounts/saml/initiate/` +- **Description**: This endpoint initiates the SAML login flow. It takes an email address, determines if the domain has a SAML configuration, and redirects the user to the appropriate IdP login page. It is primarily designed for browser-based flows. + +### Testing SAML Integration + +Follow these steps to test a SAML integration in a development environment. + +#### 1. Expose the Local Environment + +Since the IdP needs to send requests to the local Prowler instance, it must be exposed to the internet. A tool like `ngrok` can be used for this purpose. + +To start ngrok, run the following command: +```bash ngrok http 8080 ``` +This command provides a public URL (e.g., `https://.ngrok.io`) that forwards to the local server on port 8080. -Then, copy the generated ngrok URL and include it in the ALLOWED_HOSTS setting. If you're using the development environment, it usually defaults to *, but in some cases this may not work properly, like in my tests (investigate): +#### 2. Update `DJANGO_ALLOWED_HOSTS` -``` -ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"]) +To allow requests from ngrok, add its URL to the `DJANGO_ALLOWED_HOSTS` environment variable. + +```env +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,prowler-api,*.ngrok.io ``` -## 2. Configure the Identity Provider (IdP) +#### 3. Configure the IdP -Start your environment and configure your IdP. You will need to download the IdP's metadata XML file. +When configuring the IdP for testing, use the ngrok URL for the ACS URL: +`https:///api/v1/accounts/saml//acs/` -Your Assertion Consumer Service (ACS) URL must follow this format: +#### 4. Configure Prowler via API -``` -https:///api/v1/accounts/saml//acs/ -``` - -## 3. IdP Attribute Mapping - -The following fields are expected from the IdP: - -- firstName - -- lastName - -- userType (this is the name of the role the user should be assigned) - -- companyName (this is filled automatically if the IdP includes an "organization" field) - -These values are dynamic. If the values change in the IdP, they will be updated on the next login. - -## 4. SAML Configuration API (POST) - -SAML configuration is managed via a CRUD API. Use the following POST request to create a new configuration: +To create a SAML configuration for testing, use `curl`. Make sure to replace placeholders with actual data. ```bash curl --location 'http://localhost:8080/api/v1/saml-config' \ --header 'Content-Type: application/vnd.api+json' \ --header 'Accept: application/vnd.api+json' \ ---header 'Authorization: Bearer ' \ +--header 'Authorization: Bearer ' \ --data '{ "data": { "type": "saml-configurations", "attributes": { - "email_domain": "prowler.com", - "metadata_xml": "" + "email_domain": "yourdomain.com", + "metadata_xml": "" } } }' ``` -## 5. SAML SSO Callback Configuration +#### 5. Initiate Login Flow -### Environment Variable Configuration +To test the end-to-end flow, construct the login URL and open it in a browser. This will start the IdP-initiated login flow. -The SAML authentication flow requires proper callback URL configuration to handle post-authentication redirects. Configure the following environment variables: +`https:///api/v1/accounts/saml//login/` -#### `SAML_SSO_CALLBACK_URL` - -Specifies the callback endpoint that will be invoked upon successful SAML authentication completion. This URL directs users back to the web application interface. - -```env -SAML_SSO_CALLBACK_URL="${AUTH_URL}/api/auth/callback/saml" -``` - -#### `AUTH_URL` - -Defines the base URL of the web user interface application that serves as the authentication callback destination. - -```env -AUTH_URL="" -``` - -### Configuration Notes - -- The `SAML_SSO_CALLBACK_URL` dynamically references the `AUTH_URL` variable to construct the complete callback endpoint -- Ensure the `AUTH_URL` points to the correct web UI deployment (development, staging, or production) -- The callback endpoint `/api/auth/callback/saml` must be accessible and properly configured to handle SAML authentication responses -- Both environment variables are required for proper SAML SSO functionality -- Verify that the `NEXT_PUBLIC_API_BASE_URL` environment variable is properly configured to reference the correct API server base URL corresponding to your target deployment environment. This ensures proper routing of SAML callback requests to the appropriate backend services. - -## 6. Start SAML Login Flow - -Once everything is configured, start the SAML login process by visiting the following URL: - -``` -https:///api/v1/accounts/saml//login/?email= -``` - -At the end you will get a valid access and refresh token - -## 7. Notes on the initiate Endpoint - -The initiate endpoint is not strictly required. It was created to allow extra checks or behavior modifications (like enumeration mitigation). It also simplifies UI integration with SAML, but again, it's optional. +If successful, the user will be redirected back to the Prowler application with a valid session. diff --git a/mkdocs.yml b/mkdocs.yml index c0adae11db..c8db99c51b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -54,6 +54,7 @@ nav: - Role-Based Access Control: tutorials/prowler-app-rbac.md - Social Login: tutorials/prowler-app-social-login.md - SSO with SAML: tutorials/prowler-app-sso.md + - Mute findings: tutorials/prowler-app-mute-findings.md - Lighthouse: tutorials/prowler-app-lighthouse.md - CLI: - Miscellaneous: tutorials/misc.md @@ -109,6 +110,7 @@ nav: - Use of PowerShell: tutorials/microsoft365/use-of-powershell.md - GitHub: - Authentication: tutorials/github/authentication.md + - Getting Started: tutorials/github/getting-started-github.md - IaC: - Getting Started: tutorials/iac/getting-started-iac.md - Developer Guide: diff --git a/poetry.lock b/poetry.lock index 6977137741..9e831b5858 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "about-time" @@ -41,103 +41,103 @@ files = [ [[package]] name = "aiohttp" -version = "3.12.13" +version = "3.12.14" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "aiohttp-3.12.13-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5421af8f22a98f640261ee48aae3a37f0c41371e99412d55eaf2f8a46d5dad29"}, - {file = "aiohttp-3.12.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0fcda86f6cb318ba36ed8f1396a6a4a3fd8f856f84d426584392083d10da4de0"}, - {file = "aiohttp-3.12.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cd71c9fb92aceb5a23c4c39d8ecc80389c178eba9feab77f19274843eb9412d"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34ebf1aca12845066c963016655dac897651e1544f22a34c9b461ac3b4b1d3aa"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:893a4639694c5b7edd4bdd8141be296042b6806e27cc1d794e585c43010cc294"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:663d8ee3ffb3494502ebcccb49078faddbb84c1d870f9c1dd5a29e85d1f747ce"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0f8f6a85a0006ae2709aa4ce05749ba2cdcb4b43d6c21a16c8517c16593aabe"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1582745eb63df267c92d8b61ca655a0ce62105ef62542c00a74590f306be8cb5"}, - {file = "aiohttp-3.12.13-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d59227776ee2aa64226f7e086638baa645f4b044f2947dbf85c76ab11dcba073"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06b07c418bde1c8e737d8fa67741072bd3f5b0fb66cf8c0655172188c17e5fa6"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:9445c1842680efac0f81d272fd8db7163acfcc2b1436e3f420f4c9a9c5a50795"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:09c4767af0b0b98c724f5d47f2bf33395c8986995b0a9dab0575ca81a554a8c0"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f3854fbde7a465318ad8d3fc5bef8f059e6d0a87e71a0d3360bb56c0bf87b18a"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2332b4c361c05ecd381edb99e2a33733f3db906739a83a483974b3df70a51b40"}, - {file = "aiohttp-3.12.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1561db63fa1b658cd94325d303933553ea7d89ae09ff21cc3bcd41b8521fbbb6"}, - {file = "aiohttp-3.12.13-cp310-cp310-win32.whl", hash = "sha256:a0be857f0b35177ba09d7c472825d1b711d11c6d0e8a2052804e3b93166de1ad"}, - {file = "aiohttp-3.12.13-cp310-cp310-win_amd64.whl", hash = "sha256:fcc30ad4fb5cb41a33953292d45f54ef4066746d625992aeac33b8c681173178"}, - {file = "aiohttp-3.12.13-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7c229b1437aa2576b99384e4be668af1db84b31a45305d02f61f5497cfa6f60c"}, - {file = "aiohttp-3.12.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04076d8c63471e51e3689c93940775dc3d12d855c0c80d18ac5a1c68f0904358"}, - {file = "aiohttp-3.12.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55683615813ce3601640cfaa1041174dc956d28ba0511c8cbd75273eb0587014"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:921bc91e602d7506d37643e77819cb0b840d4ebb5f8d6408423af3d3bf79a7b7"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e72d17fe0974ddeae8ed86db297e23dba39c7ac36d84acdbb53df2e18505a013"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0653d15587909a52e024a261943cf1c5bdc69acb71f411b0dd5966d065a51a47"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a77b48997c66722c65e157c06c74332cdf9c7ad00494b85ec43f324e5c5a9b9a"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6946bae55fd36cfb8e4092c921075cde029c71c7cb571d72f1079d1e4e013bc"}, - {file = "aiohttp-3.12.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f95db8c8b219bcf294a53742c7bda49b80ceb9d577c8e7aa075612b7f39ffb7"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03d5eb3cfb4949ab4c74822fb3326cd9655c2b9fe22e4257e2100d44215b2e2b"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6383dd0ffa15515283c26cbf41ac8e6705aab54b4cbb77bdb8935a713a89bee9"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6548a411bc8219b45ba2577716493aa63b12803d1e5dc70508c539d0db8dbf5a"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81b0fcbfe59a4ca41dc8f635c2a4a71e63f75168cc91026c61be665945739e2d"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6a83797a0174e7995e5edce9dcecc517c642eb43bc3cba296d4512edf346eee2"}, - {file = "aiohttp-3.12.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5734d8469a5633a4e9ffdf9983ff7cdb512524645c7a3d4bc8a3de45b935ac3"}, - {file = "aiohttp-3.12.13-cp311-cp311-win32.whl", hash = "sha256:fef8d50dfa482925bb6b4c208b40d8e9fa54cecba923dc65b825a72eed9a5dbd"}, - {file = "aiohttp-3.12.13-cp311-cp311-win_amd64.whl", hash = "sha256:9a27da9c3b5ed9d04c36ad2df65b38a96a37e9cfba6f1381b842d05d98e6afe9"}, - {file = "aiohttp-3.12.13-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0aa580cf80558557285b49452151b9c69f2fa3ad94c5c9e76e684719a8791b73"}, - {file = "aiohttp-3.12.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b103a7e414b57e6939cc4dece8e282cfb22043efd0c7298044f6594cf83ab347"}, - {file = "aiohttp-3.12.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f64e748e9e741d2eccff9597d09fb3cd962210e5b5716047cbb646dc8fe06f"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c955989bf4c696d2ededc6b0ccb85a73623ae6e112439398935362bacfaaf6"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d640191016763fab76072c87d8854a19e8e65d7a6fcfcbf017926bdbbb30a7e5"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4dc507481266b410dede95dd9f26c8d6f5a14315372cc48a6e43eac652237d9b"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8a94daa873465d518db073bd95d75f14302e0208a08e8c942b2f3f1c07288a75"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f52420cde4ce0bb9425a375d95577fe082cb5721ecb61da3049b55189e4e6"}, - {file = "aiohttp-3.12.13-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f7df1f620ec40f1a7fbcb99ea17d7326ea6996715e78f71a1c9a021e31b96b8"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3062d4ad53b36e17796dce1c0d6da0ad27a015c321e663657ba1cc7659cfc710"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8605e22d2a86b8e51ffb5253d9045ea73683d92d47c0b1438e11a359bdb94462"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:54fbbe6beafc2820de71ece2198458a711e224e116efefa01b7969f3e2b3ddae"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:050bd277dfc3768b606fd4eae79dd58ceda67d8b0b3c565656a89ae34525d15e"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2637a60910b58f50f22379b6797466c3aa6ae28a6ab6404e09175ce4955b4e6a"}, - {file = "aiohttp-3.12.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e986067357550d1aaa21cfe9897fa19e680110551518a5a7cf44e6c5638cb8b5"}, - {file = "aiohttp-3.12.13-cp312-cp312-win32.whl", hash = "sha256:ac941a80aeea2aaae2875c9500861a3ba356f9ff17b9cb2dbfb5cbf91baaf5bf"}, - {file = "aiohttp-3.12.13-cp312-cp312-win_amd64.whl", hash = "sha256:671f41e6146a749b6c81cb7fd07f5a8356d46febdaaaf07b0e774ff04830461e"}, - {file = "aiohttp-3.12.13-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d4a18e61f271127465bdb0e8ff36e8f02ac4a32a80d8927aa52371e93cd87938"}, - {file = "aiohttp-3.12.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:532542cb48691179455fab429cdb0d558b5e5290b033b87478f2aa6af5d20ace"}, - {file = "aiohttp-3.12.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d7eea18b52f23c050ae9db5d01f3d264ab08f09e7356d6f68e3f3ac2de9dfabb"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad7c8e5c25f2a26842a7c239de3f7b6bfb92304593ef997c04ac49fb703ff4d7"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6af355b483e3fe9d7336d84539fef460120c2f6e50e06c658fe2907c69262d6b"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a95cf9f097498f35c88e3609f55bb47b28a5ef67f6888f4390b3d73e2bac6177"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8ed8c38a1c584fe99a475a8f60eefc0b682ea413a84c6ce769bb19a7ff1c5ef"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a0b9170d5d800126b5bc89d3053a2363406d6e327afb6afaeda2d19ee8bb103"}, - {file = "aiohttp-3.12.13-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:372feeace612ef8eb41f05ae014a92121a512bd5067db8f25101dd88a8db11da"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a946d3702f7965d81f7af7ea8fb03bb33fe53d311df48a46eeca17e9e0beed2d"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a0c4725fae86555bbb1d4082129e21de7264f4ab14baf735278c974785cd2041"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b28ea2f708234f0a5c44eb6c7d9eb63a148ce3252ba0140d050b091b6e842d1"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d4f5becd2a5791829f79608c6f3dc745388162376f310eb9c142c985f9441cc1"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:60f2ce6b944e97649051d5f5cc0f439360690b73909230e107fd45a359d3e911"}, - {file = "aiohttp-3.12.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69fc1909857401b67bf599c793f2183fbc4804717388b0b888f27f9929aa41f3"}, - {file = "aiohttp-3.12.13-cp313-cp313-win32.whl", hash = "sha256:7d7e68787a2046b0e44ba5587aa723ce05d711e3a3665b6b7545328ac8e3c0dd"}, - {file = "aiohttp-3.12.13-cp313-cp313-win_amd64.whl", hash = "sha256:5a178390ca90419bfd41419a809688c368e63c86bd725e1186dd97f6b89c2706"}, - {file = "aiohttp-3.12.13-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:36f6c973e003dc9b0bb4e8492a643641ea8ef0e97ff7aaa5c0f53d68839357b4"}, - {file = "aiohttp-3.12.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6cbfc73179bd67c229eb171e2e3745d2afd5c711ccd1e40a68b90427f282eab1"}, - {file = "aiohttp-3.12.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1e8b27b2d414f7e3205aa23bb4a692e935ef877e3a71f40d1884f6e04fd7fa74"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eabded0c2b2ef56243289112c48556c395d70150ce4220d9008e6b4b3dd15690"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:003038e83f1a3ff97409999995ec02fe3008a1d675478949643281141f54751d"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b6f46613031dbc92bdcaad9c4c22c7209236ec501f9c0c5f5f0b6a689bf50f3"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c332c6bb04650d59fb94ed96491f43812549a3ba6e7a16a218e612f99f04145e"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3fea41a2c931fb582cb15dc86a3037329e7b941df52b487a9f8b5aa960153cbd"}, - {file = "aiohttp-3.12.13-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:846104f45d18fb390efd9b422b27d8f3cf8853f1218c537f36e71a385758c896"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d6c85ac7dd350f8da2520bac8205ce99df4435b399fa7f4dc4a70407073e390"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5a1ecce0ed281bec7da8550da052a6b89552db14d0a0a45554156f085a912f48"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5304d74867028cca8f64f1cc1215eb365388033c5a691ea7aa6b0dc47412f495"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:64d1f24ee95a2d1e094a4cd7a9b7d34d08db1bbcb8aa9fb717046b0a884ac294"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:119c79922a7001ca6a9e253228eb39b793ea994fd2eccb79481c64b5f9d2a055"}, - {file = "aiohttp-3.12.13-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bb18f00396d22e2f10cd8825d671d9f9a3ba968d708a559c02a627536b36d91c"}, - {file = "aiohttp-3.12.13-cp39-cp39-win32.whl", hash = "sha256:0022de47ef63fd06b065d430ac79c6b0bd24cdae7feaf0e8c6bac23b805a23a8"}, - {file = "aiohttp-3.12.13-cp39-cp39-win_amd64.whl", hash = "sha256:29e08111ccf81b2734ae03f1ad1cb03b9615e7d8f616764f22f71209c094f122"}, - {file = "aiohttp-3.12.13.tar.gz", hash = "sha256:47e2da578528264a12e4e3dd8dd72a7289e5f812758fe086473fab037a10fcce"}, + {file = "aiohttp-3.12.14-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:906d5075b5ba0dd1c66fcaaf60eb09926a9fef3ca92d912d2a0bbdbecf8b1248"}, + {file = "aiohttp-3.12.14-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c875bf6fc2fd1a572aba0e02ef4e7a63694778c5646cdbda346ee24e630d30fb"}, + {file = "aiohttp-3.12.14-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fbb284d15c6a45fab030740049d03c0ecd60edad9cd23b211d7e11d3be8d56fd"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38e360381e02e1a05d36b223ecab7bc4a6e7b5ab15760022dc92589ee1d4238c"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aaf90137b5e5d84a53632ad95ebee5c9e3e7468f0aab92ba3f608adcb914fa95"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e532a25e4a0a2685fa295a31acf65e027fbe2bea7a4b02cdfbbba8a064577663"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eab9762c4d1b08ae04a6c77474e6136da722e34fdc0e6d6eab5ee93ac29f35d1"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abe53c3812b2899889a7fca763cdfaeee725f5be68ea89905e4275476ffd7e61"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5760909b7080aa2ec1d320baee90d03b21745573780a072b66ce633eb77a8656"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:02fcd3f69051467bbaa7f84d7ec3267478c7df18d68b2e28279116e29d18d4f3"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4dcd1172cd6794884c33e504d3da3c35648b8be9bfa946942d353b939d5f1288"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:224d0da41355b942b43ad08101b1b41ce633a654128ee07e36d75133443adcda"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e387668724f4d734e865c1776d841ed75b300ee61059aca0b05bce67061dcacc"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:dec9cde5b5a24171e0b0a4ca064b1414950904053fb77c707efd876a2da525d8"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bbad68a2af4877cc103cd94af9160e45676fc6f0c14abb88e6e092b945c2c8e3"}, + {file = "aiohttp-3.12.14-cp310-cp310-win32.whl", hash = "sha256:ee580cb7c00bd857b3039ebca03c4448e84700dc1322f860cf7a500a6f62630c"}, + {file = "aiohttp-3.12.14-cp310-cp310-win_amd64.whl", hash = "sha256:cf4f05b8cea571e2ccc3ca744e35ead24992d90a72ca2cf7ab7a2efbac6716db"}, + {file = "aiohttp-3.12.14-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f4552ff7b18bcec18b60a90c6982049cdb9dac1dba48cf00b97934a06ce2e597"}, + {file = "aiohttp-3.12.14-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8283f42181ff6ccbcf25acaae4e8ab2ff7e92b3ca4a4ced73b2c12d8cd971393"}, + {file = "aiohttp-3.12.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:040afa180ea514495aaff7ad34ec3d27826eaa5d19812730fe9e529b04bb2179"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b413c12f14c1149f0ffd890f4141a7471ba4b41234fe4fd4a0ff82b1dc299dbb"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d6f607ce2e1a93315414e3d448b831238f1874b9968e1195b06efaa5c87e245"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:565e70d03e924333004ed101599902bba09ebb14843c8ea39d657f037115201b"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4699979560728b168d5ab63c668a093c9570af2c7a78ea24ca5212c6cdc2b641"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad5fdf6af93ec6c99bf800eba3af9a43d8bfd66dce920ac905c817ef4a712afe"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ac76627c0b7ee0e80e871bde0d376a057916cb008a8f3ffc889570a838f5cc7"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:798204af1180885651b77bf03adc903743a86a39c7392c472891649610844635"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4f1205f97de92c37dd71cf2d5bcfb65fdaed3c255d246172cce729a8d849b4da"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:76ae6f1dd041f85065d9df77c6bc9c9703da9b5c018479d20262acc3df97d419"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a194ace7bc43ce765338ca2dfb5661489317db216ea7ea700b0332878b392cab"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:16260e8e03744a6fe3fcb05259eeab8e08342c4c33decf96a9dad9f1187275d0"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c779e5ebbf0e2e15334ea404fcce54009dc069210164a244d2eac8352a44b28"}, + {file = "aiohttp-3.12.14-cp311-cp311-win32.whl", hash = "sha256:a289f50bf1bd5be227376c067927f78079a7bdeccf8daa6a9e65c38bae14324b"}, + {file = "aiohttp-3.12.14-cp311-cp311-win_amd64.whl", hash = "sha256:0b8a69acaf06b17e9c54151a6c956339cf46db4ff72b3ac28516d0f7068f4ced"}, + {file = "aiohttp-3.12.14-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a0ecbb32fc3e69bc25efcda7d28d38e987d007096cbbeed04f14a6662d0eee22"}, + {file = "aiohttp-3.12.14-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0400f0ca9bb3e0b02f6466421f253797f6384e9845820c8b05e976398ac1d81a"}, + {file = "aiohttp-3.12.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a56809fed4c8a830b5cae18454b7464e1529dbf66f71c4772e3cfa9cbec0a1ff"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f2e373276e4755691a963e5d11756d093e346119f0627c2d6518208483fb6d"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ca39e433630e9a16281125ef57ece6817afd1d54c9f1bf32e901f38f16035869"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c748b3f8b14c77720132b2510a7d9907a03c20ba80f469e58d5dfd90c079a1c"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a568abe1b15ce69d4cc37e23020720423f0728e3cb1f9bcd3f53420ec3bfe7"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9888e60c2c54eaf56704b17feb558c7ed6b7439bca1e07d4818ab878f2083660"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3006a1dc579b9156de01e7916d38c63dc1ea0679b14627a37edf6151bc530088"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa8ec5c15ab80e5501a26719eb48a55f3c567da45c6ea5bb78c52c036b2655c7"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:39b94e50959aa07844c7fe2206b9f75d63cc3ad1c648aaa755aa257f6f2498a9"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:04c11907492f416dad9885d503fbfc5dcb6768d90cad8639a771922d584609d3"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:88167bd9ab69bb46cee91bd9761db6dfd45b6e76a0438c7e884c3f8160ff21eb"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:791504763f25e8f9f251e4688195e8b455f8820274320204f7eafc467e609425"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785b112346e435dd3a1a67f67713a3fe692d288542f1347ad255683f066d8e0"}, + {file = "aiohttp-3.12.14-cp312-cp312-win32.whl", hash = "sha256:15f5f4792c9c999a31d8decf444e79fcfd98497bf98e94284bf390a7bb8c1729"}, + {file = "aiohttp-3.12.14-cp312-cp312-win_amd64.whl", hash = "sha256:3b66e1a182879f579b105a80d5c4bd448b91a57e8933564bf41665064796a338"}, + {file = "aiohttp-3.12.14-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3143a7893d94dc82bc409f7308bc10d60285a3cd831a68faf1aa0836c5c3c767"}, + {file = "aiohttp-3.12.14-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3d62ac3d506cef54b355bd34c2a7c230eb693880001dfcda0bf88b38f5d7af7e"}, + {file = "aiohttp-3.12.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48e43e075c6a438937c4de48ec30fa8ad8e6dfef122a038847456bfe7b947b63"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:077b4488411a9724cecc436cbc8c133e0d61e694995b8de51aaf351c7578949d"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d8c35632575653f297dcbc9546305b2c1133391089ab925a6a3706dfa775ccab"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b8ce87963f0035c6834b28f061df90cf525ff7c9b6283a8ac23acee6502afd4"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a2cf66e32a2563bb0766eb24eae7e9a269ac0dc48db0aae90b575dc9583026"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdea089caf6d5cde975084a884c72d901e36ef9c2fd972c9f51efbbc64e96fbd"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7865f27db67d49e81d463da64a59365ebd6b826e0e4847aa111056dcb9dc88"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0ab5b38a6a39781d77713ad930cb5e7feea6f253de656a5f9f281a8f5931b086"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b3b15acee5c17e8848d90a4ebc27853f37077ba6aec4d8cb4dbbea56d156933"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e4c972b0bdaac167c1e53e16a16101b17c6d0ed7eac178e653a07b9f7fad7151"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7442488b0039257a3bdbc55f7209587911f143fca11df9869578db6c26feeeb8"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f68d3067eecb64c5e9bab4a26aa11bd676f4c70eea9ef6536b0a4e490639add3"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f88d3704c8b3d598a08ad17d06006cb1ca52a1182291f04979e305c8be6c9758"}, + {file = "aiohttp-3.12.14-cp313-cp313-win32.whl", hash = "sha256:a3c99ab19c7bf375c4ae3debd91ca5d394b98b6089a03231d4c580ef3c2ae4c5"}, + {file = "aiohttp-3.12.14-cp313-cp313-win_amd64.whl", hash = "sha256:3f8aad695e12edc9d571f878c62bedc91adf30c760c8632f09663e5f564f4baa"}, + {file = "aiohttp-3.12.14-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b8cc6b05e94d837bcd71c6531e2344e1ff0fb87abe4ad78a9261d67ef5d83eae"}, + {file = "aiohttp-3.12.14-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1dcb015ac6a3b8facd3677597edd5ff39d11d937456702f0bb2b762e390a21b"}, + {file = "aiohttp-3.12.14-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3779ed96105cd70ee5e85ca4f457adbce3d9ff33ec3d0ebcdf6c5727f26b21b3"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:717a0680729b4ebd7569c1dcd718c46b09b360745fd8eb12317abc74b14d14d0"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b5dd3a2ef7c7e968dbbac8f5574ebeac4d2b813b247e8cec28174a2ba3627170"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4710f77598c0092239bc12c1fcc278a444e16c7032d91babf5abbf7166463f7b"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f3e9f75ae842a6c22a195d4a127263dbf87cbab729829e0bd7857fb1672400b2"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f9c8d55d6802086edd188e3a7d85a77787e50d56ce3eb4757a3205fa4657922"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79b29053ff3ad307880d94562cca80693c62062a098a5776ea8ef5ef4b28d140"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:23e1332fff36bebd3183db0c7a547a1da9d3b4091509f6d818e098855f2f27d3"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:a564188ce831fd110ea76bcc97085dd6c625b427db3f1dbb14ca4baa1447dcbc"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a7a1b4302f70bb3ec40ca86de82def532c97a80db49cac6a6700af0de41af5ee"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:1b07ccef62950a2519f9bfc1e5b294de5dd84329f444ca0b329605ea787a3de5"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:938bd3ca6259e7e48b38d84f753d548bd863e0c222ed6ee6ace3fd6752768a84"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8bc784302b6b9f163b54c4e93d7a6f09563bd01ff2b841b29ed3ac126e5040bf"}, + {file = "aiohttp-3.12.14-cp39-cp39-win32.whl", hash = "sha256:a3416f95961dd7d5393ecff99e3f41dc990fb72eda86c11f2a60308ac6dcd7a0"}, + {file = "aiohttp-3.12.14-cp39-cp39-win_amd64.whl", hash = "sha256:196858b8820d7f60578f8b47e5669b3195c21d8ab261e39b1d705346458f445f"}, + {file = "aiohttp-3.12.14.tar.gz", hash = "sha256:6e06e120e34d93100de448fd941522e11dafa78ef1a893c179901b7d66aa29f2"}, ] [package.dependencies] aiohappyeyeballs = ">=2.5.0" -aiosignal = ">=1.1.2" +aiosignal = ">=1.4.0" async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} attrs = ">=17.3.0" frozenlist = ">=1.1.1" @@ -166,18 +166,19 @@ docs = ["sphinx (==7.3.7)", "sphinx-mdinclude (==0.6.0)"] [[package]] name = "aiosignal" -version = "1.3.2" +version = "1.4.0" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5"}, - {file = "aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54"}, + {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 = "alive-progress" @@ -1939,6 +1940,54 @@ files = [ {file = "dpath-2.1.3.tar.gz", hash = "sha256:d1a7a0e6427d0a4156c792c82caf1f0109603f68ace792e36ca4596fd2cb8d9d"}, ] +[[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" @@ -6623,4 +6672,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">3.9.1,<3.13" -content-hash = "4b0eee5566caf8e9d1e2e6fe8ac37733b29dd4275c2d65ac5291fa3acd514d9e" +content-hash = "7a3f5d9a2b06322b3c4b65d1010116f84ea5e725693e51316ffeb23d4ed09c96" diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index c1ee43f193..72c6742888 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the **Prowler SDK** are documented in this file. -## [v5.9.0] (Prowler UNRELEASED) +## [v5.9.0] (Prowler v5.9.0) ### Added - `storage_smb_channel_encryption_with_secure_algorithm` check for Azure provider [(#8123)](https://github.com/prowler-cloud/prowler/pull/8123) @@ -11,11 +11,34 @@ All notable changes to the **Prowler SDK** are documented in this file. - `vm_linux_enforce_ssh_authentication` check for Azure provider [(#8149)](https://github.com/prowler-cloud/prowler/pull/8149) - `vm_ensure_using_approved_images` check for Azure provider [(#8168)](https://github.com/prowler-cloud/prowler/pull/8168) - `vm_scaleset_associated_load_balancer` check for Azure provider [(#8181)](https://github.com/prowler-cloud/prowler/pull/8181) +- `defender_attack_path_notifications_properly_configured` check for Azure provider [(#8245)](https://github.com/prowler-cloud/prowler/pull/8245) +- `entra_intune_enrollment_sign_in_frequency_every_time` check for M365 provider [(#8223)](https://github.com/prowler-cloud/prowler/pull/8223) +- Support for remote repository scanning in IaC provider [(#8193)](https://github.com/prowler-cloud/prowler/pull/8193) +- Add `test_connection` method to GitHub provider [(#8248)](https://github.com/prowler-cloud/prowler/pull/8248) ### Changed +- Refactor the Azure Defender get security contact configuration method to use the API REST endpoint instead of the SDK [(#8241)](https://github.com/prowler-cloud/prowler/pull/8241) ### Fixed +- Title & description wording for `iam_user_accesskey_unused` check for AWS provider [(#8233)](https://github.com/prowler-cloud/prowler/pull/8233) - Add GitHub provider to lateral panel in documentation and change -h environment variable output [(#8246)](https://github.com/prowler-cloud/prowler/pull/8246) +- Show `m365_identity_type` and `m365_identity_id` in cloud reports [(#8247)](https://github.com/prowler-cloud/prowler/pull/8247) +- Ensure `is_service_role` only returns `True` for service roles [(#8274)](https://github.com/prowler-cloud/prowler/pull/8274) +- Update DynamoDB check metadata to fix broken link [(#8273)](https://github.com/prowler-cloud/prowler/pull/8273) +- Show correct count of findings in Dashboard Security Posture page [(#8270)](https://github.com/prowler-cloud/prowler/pull/8270) +- Add Check's metadata service name validator [(#8289)](https://github.com/prowler-cloud/prowler/pull/8289) +- Use subscription ID in Azure mutelist [(#8290)](https://github.com/prowler-cloud/prowler/pull/8290) +- `ServiceName` field in Network Firewall checks metadata [(#8280)](https://github.com/prowler-cloud/prowler/pull/8280) +- Update `entra_users_mfa_capable` check to use the correct resource name and ID [(#8288)](https://github.com/prowler-cloud/prowler/pull/8288) +- Handle multiple services and severities while listing checks [(#8302)](https://github.com/prowler-cloud/prowler/pull/8302) +- Handle `tenant_id` for M365 Mutelist [(#8306)](https://github.com/prowler-cloud/prowler/pull/8306) + +--- + +## [v5.8.2] (Prowler 5.8.2) + +### Fixed +- Fix error in Dashboard Overview page when reading CSV files [(#8257)](https://github.com/prowler-cloud/prowler/pull/8257) --- diff --git a/prowler/config/config.py b/prowler/config/config.py index 3311f552a9..e020997cbd 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -12,7 +12,7 @@ from prowler.lib.logger import logger timestamp = datetime.today() timestamp_utc = datetime.now(timezone.utc).replace(tzinfo=timezone.utc) -prowler_version = "5.9.0" +prowler_version = "5.10.0" html_logo_url = "https://github.com/prowler-cloud/prowler/" square_logo_img = "https://prowler.com/wp-content/uploads/logo-html.png" aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png" diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index 37d3388e6b..c43212e7a7 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -430,6 +430,10 @@ azure: # TODO: create common config shodan_api_key: null + # Configurable minimal risk level for attack path notifications + # azure.defender_attack_path_notifications_properly_configured + defender_attack_path_minimal_risk_level: "High" + # Azure App Service # azure.app_ensure_php_version_is_latest php_latest_version: "8.2" diff --git a/prowler/lib/check/check.py b/prowler/lib/check/check.py index bce9db8378..8cd3097342 100644 --- a/prowler/lib/check/check.py +++ b/prowler/lib/check/check.py @@ -635,7 +635,13 @@ def execute( is_finding_muted_args["account_name"] = ( global_provider.identity.account_name ) + elif global_provider.type == "m365": + is_finding_muted_args["tenant_id"] = global_provider.identity.tenant_id for finding in check_findings: + if global_provider.type == "azure": + is_finding_muted_args["subscription_id"] = ( + global_provider.identity.subscriptions.get(finding.subscription) + ) is_finding_muted_args["finding"] = finding finding.muted = global_provider.mutelist.is_finding_muted( **is_finding_muted_args diff --git a/prowler/lib/check/checks_loader.py b/prowler/lib/check/checks_loader.py index f45b0e917d..3c7e065fc0 100644 --- a/prowler/lib/check/checks_loader.py +++ b/prowler/lib/check/checks_loader.py @@ -66,16 +66,15 @@ def load_checks_to_execute( checks_to_execute.update(check_severities[severity]) if service_list: + checks_from_services = set() for service in service_list: - checks_to_execute = ( - set( - CheckMetadata.list( - bulk_checks_metadata=bulk_checks_metadata, - service=service, - ) - ) - & checks_to_execute + service_checks = CheckMetadata.list( + bulk_checks_metadata=bulk_checks_metadata, + service=service, ) + checks_from_services.update(service_checks) + checks_to_execute = checks_from_services & checks_to_execute + # Handle if there are checks passed using -C/--checks-file elif checks_file: checks_to_execute = parse_checks_from_file(checks_file, provider) diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index bbd5b8ccc1..d0fe6316d7 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -149,6 +149,36 @@ class CheckMetadata(BaseModel): raise ValueError("ResourceType must be a non-empty string") return resource_type + @validator("ServiceName", pre=True, always=True) + def validate_service_name(cls, service_name, values): + if not service_name: + raise ValueError("ServiceName must be a non-empty string") + + check_id = values.get("CheckID") + if check_id: + service_from_check_id = check_id.split("_")[0] + if service_name != service_from_check_id: + raise ValueError( + f"ServiceName {service_name} does not belong to CheckID {check_id}" + ) + if not service_name.islower(): + raise ValueError(f"ServiceName {service_name} must be in lowercase") + + return service_name + + @validator("CheckID", pre=True, always=True) + def valid_check_id(cls, check_id): + if not check_id: + raise ValueError("CheckID must be a non-empty string") + + if check_id: + if "-" in check_id: + raise ValueError( + f"CheckID {check_id} contains a hyphen, which is not allowed" + ) + + return check_id + @staticmethod def get_bulk(provider: str) -> dict[str, "CheckMetadata"]: """ diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index 433f04b041..9cabffec86 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -283,16 +283,14 @@ class Finding(BaseModel): output_data["region"] = check_output.location elif provider.type == "iac": - output_data["auth_method"] = "local" # Until we support remote repos + output_data["auth_method"] = provider.auth_method output_data["account_uid"] = "iac" output_data["account_name"] = "iac" output_data["resource_name"] = check_output.resource_name output_data["resource_uid"] = check_output.resource_name output_data["region"] = check_output.resource_path output_data["resource_line_range"] = check_output.resource_line_range - output_data["framework"] = ( - check_output.check_metadata.ServiceName - ) # TODO: can we get the framework from the check_output? + output_data["framework"] = check_output.check_metadata.ServiceName # check_output Unique ID # TODO: move this to a function diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index 6c54501640..6d43e9aab8 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -710,7 +710,7 @@ class HTML(Output):
  • - IAC path: {provider.scan_path} + {"IAC repository URL: " + provider.scan_repository_url if provider.scan_repository_url else "IAC path: " + provider.scan_path}
@@ -723,7 +723,7 @@ class HTML(Output):
  • - IAC authentication method: local + IAC authentication method: {provider.auth_method}
diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py index fadceea23e..2e70115256 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -55,8 +55,12 @@ def display_summary_table( entity_type = "Tenant Domain" audited_entities = provider.identity.tenant_domain elif provider.type == "iac": - entity_type = "Directory" - audited_entities = provider.scan_path + if provider.scan_repository_url: + entity_type = "Repository" + audited_entities = provider.scan_repository_url + else: + entity_type = "Directory" + audited_entities = provider.scan_path # 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/providers/aws/aws_regions_by_service.json b/prowler/providers/aws/aws_regions_by_service.json index a56854b214..c2e1068fd1 100644 --- a/prowler/providers/aws/aws_regions_by_service.json +++ b/prowler/providers/aws/aws_regions_by_service.json @@ -4024,6 +4024,7 @@ "eu-west-2", "eu-west-3", "il-central-1", + "me-central-1", "me-south-1", "sa-east-1", "us-east-1", @@ -4761,6 +4762,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -4807,6 +4809,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -4853,6 +4856,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -4899,6 +4903,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -4944,6 +4949,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -6145,6 +6151,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -8245,6 +8252,7 @@ "eu-central-1", "eu-north-1", "eu-west-1", + "eu-west-2", "us-east-1", "us-east-2", "us-west-2" @@ -9683,6 +9691,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -9909,6 +9918,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -9918,6 +9928,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", @@ -9931,6 +9943,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -12125,6 +12138,15 @@ ] } }, + "workspaces-instances": { + "regions": { + "aws": [ + "ap-northeast-2" + ], + "aws-cn": [], + "aws-us-gov": [] + } + }, "workspaces-web": { "regions": { "aws": [ diff --git a/prowler/providers/aws/services/apigatewayv2/apigatewayv2_api_access_logging_enabled/apigatewayv2_api_access_logging_enabled.metadata.json b/prowler/providers/aws/services/apigatewayv2/apigatewayv2_api_access_logging_enabled/apigatewayv2_api_access_logging_enabled.metadata.json index f2000ac300..1891fe81d0 100644 --- a/prowler/providers/aws/services/apigatewayv2/apigatewayv2_api_access_logging_enabled/apigatewayv2_api_access_logging_enabled.metadata.json +++ b/prowler/providers/aws/services/apigatewayv2/apigatewayv2_api_access_logging_enabled/apigatewayv2_api_access_logging_enabled.metadata.json @@ -8,7 +8,7 @@ "CheckType": [ "IAM" ], - "ServiceName": "apigateway", + "ServiceName": "apigatewayv2", "SubServiceName": "", "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", "Severity": "medium", diff --git a/prowler/providers/aws/services/apigatewayv2/apigatewayv2_api_authorizers_enabled/apigatewayv2_api_authorizers_enabled.metadata.json b/prowler/providers/aws/services/apigatewayv2/apigatewayv2_api_authorizers_enabled/apigatewayv2_api_authorizers_enabled.metadata.json index 671c13729d..20674a5e9a 100644 --- a/prowler/providers/aws/services/apigatewayv2/apigatewayv2_api_authorizers_enabled/apigatewayv2_api_authorizers_enabled.metadata.json +++ b/prowler/providers/aws/services/apigatewayv2/apigatewayv2_api_authorizers_enabled/apigatewayv2_api_authorizers_enabled.metadata.json @@ -8,7 +8,7 @@ "CheckType": [ "Logging and Monitoring" ], - "ServiceName": "apigateway", + "ServiceName": "apigatewayv2", "SubServiceName": "", "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", "Severity": "medium", diff --git a/prowler/providers/aws/services/documentdb/documentdb_cluster_backup_enabled/documentdb_cluster_backup_enabled.metadata.json b/prowler/providers/aws/services/documentdb/documentdb_cluster_backup_enabled/documentdb_cluster_backup_enabled.metadata.json index cafbcb95a2..eb8b780511 100644 --- a/prowler/providers/aws/services/documentdb/documentdb_cluster_backup_enabled/documentdb_cluster_backup_enabled.metadata.json +++ b/prowler/providers/aws/services/documentdb/documentdb_cluster_backup_enabled/documentdb_cluster_backup_enabled.metadata.json @@ -3,7 +3,7 @@ "CheckID": "documentdb_cluster_backup_enabled", "CheckTitle": "Check if DocumentDB Clusters have backup enabled.", "CheckType": [], - "ServiceName": "DocumentDB", + "ServiceName": "documentdb", "SubServiceName": "", "ResourceIdTemplate": "arn:aws:rds:region:account-id:db-cluster", "Severity": "medium", diff --git a/prowler/providers/aws/services/dynamodb/dynamodb_tables_kms_cmk_encryption_enabled/dynamodb_tables_kms_cmk_encryption_enabled.metadata.json b/prowler/providers/aws/services/dynamodb/dynamodb_tables_kms_cmk_encryption_enabled/dynamodb_tables_kms_cmk_encryption_enabled.metadata.json index db93936b6b..935834773c 100644 --- a/prowler/providers/aws/services/dynamodb/dynamodb_tables_kms_cmk_encryption_enabled/dynamodb_tables_kms_cmk_encryption_enabled.metadata.json +++ b/prowler/providers/aws/services/dynamodb/dynamodb_tables_kms_cmk_encryption_enabled/dynamodb_tables_kms_cmk_encryption_enabled.metadata.json @@ -12,7 +12,7 @@ "ResourceType": "AwsDynamoDbTable", "Description": "Check if DynamoDB table has encryption at rest enabled using CMK KMS.", "Risk": "All user data stored in Amazon DynamoDB is fully encrypted at rest. This functionality helps reduce the operational burden and complexity involved in protecting sensitive data.", - "RelatedUrl": "https://docs.aws.amazon.com/amazondynamodbdb/latest/developerguide/EncryptionAtRest.html", + "RelatedUrl": "https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/EncryptionAtRest.html", "Remediation": { "Code": { "CLI": "", @@ -22,7 +22,7 @@ }, "Recommendation": { "Text": "Specify an encryption key when you create a new table or switch the encryption keys on an existing table by using the AWS Management Console.", - "Url": "https://docs.aws.amazon.com/amazondynamodbdb/latest/developerguide/EncryptionAtRest.html" + "Url": "https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/EncryptionAtRest.html" } }, "Categories": [ diff --git a/prowler/providers/aws/services/iam/iam_service.py b/prowler/providers/aws/services/iam/iam_service.py index 27af7b8019..d56f6e446b 100644 --- a/prowler/providers/aws/services/iam/iam_service.py +++ b/prowler/providers/aws/services/iam/iam_service.py @@ -13,38 +13,28 @@ from prowler.providers.aws.lib.service.service import AWSService def is_service_role(role): try: - if "Statement" in role["AssumeRolePolicyDocument"]: - if isinstance(role["AssumeRolePolicyDocument"]["Statement"], list): - for statement in role["AssumeRolePolicyDocument"]["Statement"]: - if ( - statement["Effect"] == "Allow" - and ( - "sts:AssumeRole" in statement["Action"] - or "sts:*" in statement["Action"] - or "*" in statement["Action"] - ) - # This is what defines a service role - and "Service" in statement["Principal"] - ): - return True - else: - statement = role["AssumeRolePolicyDocument"]["Statement"] - if ( - statement["Effect"] == "Allow" - and ( - "sts:AssumeRole" in statement["Action"] - or "sts:*" in statement["Action"] - or "*" in statement["Action"] - ) - # This is what defines a service role - and "Service" in statement["Principal"] - ): - return True + statements = role.get("AssumeRolePolicyDocument", {}).get("Statement", []) + if not isinstance(statements, list): + statements = [statements] + + for statement in statements: + if statement.get("Effect") != "Allow" or not any( + action in statement.get("Action", []) + for action in ("sts:AssumeRole", "sts:*", "*") + ): + return False + + principal = statement.get("Principal", {}) + if set(principal.keys()) != {"Service"}: + return False + + return True + except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - return False + return False class IAM(AWSService): diff --git a/prowler/providers/aws/services/iam/iam_user_accesskey_unused/iam_user_accesskey_unused.metadata.json b/prowler/providers/aws/services/iam/iam_user_accesskey_unused/iam_user_accesskey_unused.metadata.json index aa6e776b04..87f737030f 100644 --- a/prowler/providers/aws/services/iam/iam_user_accesskey_unused/iam_user_accesskey_unused.metadata.json +++ b/prowler/providers/aws/services/iam/iam_user_accesskey_unused/iam_user_accesskey_unused.metadata.json @@ -1,7 +1,7 @@ { "Provider": "aws", "CheckID": "iam_user_accesskey_unused", - "CheckTitle": "Ensure User Access Keys unused are disabled", + "CheckTitle": "Ensure unused User Access Keys are disabled", "CheckType": [ "Software and Configuration Checks" ], @@ -10,7 +10,7 @@ "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", "Severity": "medium", "ResourceType": "AwsIamUser", - "Description": "Ensure User Access Keys unused are disabled", + "Description": "Ensure unused User Access Keys are disabled", "Risk": "To increase the security of your AWS account, remove IAM user credentials (that is, passwords and access keys) that are not needed. For example, when users leave your organization or no longer need AWS access.", "RelatedUrl": "", "Remediation": { diff --git a/prowler/providers/aws/services/networkfirewall/networkfirewall_deletion_protection/networkfirewall_deletion_protection.metadata.json b/prowler/providers/aws/services/networkfirewall/networkfirewall_deletion_protection/networkfirewall_deletion_protection.metadata.json index 4346c2ee8d..9811d5eede 100644 --- a/prowler/providers/aws/services/networkfirewall/networkfirewall_deletion_protection/networkfirewall_deletion_protection.metadata.json +++ b/prowler/providers/aws/services/networkfirewall/networkfirewall_deletion_protection/networkfirewall_deletion_protection.metadata.json @@ -3,7 +3,7 @@ "CheckID": "networkfirewall_deletion_protection", "CheckTitle": "Ensure that Deletion Protection safety feature is enabled for your Amazon VPC network firewalls.", "CheckType": [], - "ServiceName": "network-firewall", + "ServiceName": "networkfirewall", "SubServiceName": "", "ResourceIdTemplate": "arn:partition:network-firewall::account-id:firewall/firewall-name", "Severity": "medium", diff --git a/prowler/providers/aws/services/networkfirewall/networkfirewall_in_all_vpc/networkfirewall_in_all_vpc.metadata.json b/prowler/providers/aws/services/networkfirewall/networkfirewall_in_all_vpc/networkfirewall_in_all_vpc.metadata.json index 7806387317..16b33ebe00 100644 --- a/prowler/providers/aws/services/networkfirewall/networkfirewall_in_all_vpc/networkfirewall_in_all_vpc.metadata.json +++ b/prowler/providers/aws/services/networkfirewall/networkfirewall_in_all_vpc/networkfirewall_in_all_vpc.metadata.json @@ -3,7 +3,7 @@ "CheckID": "networkfirewall_in_all_vpc", "CheckTitle": "Ensure all VPCs have Network Firewall enabled", "CheckType": [], - "ServiceName": "network-firewall", + "ServiceName": "networkfirewall", "SubServiceName": "", "ResourceIdTemplate": "arn:partition:network-firewall::account-id:firewall/firewall-name", "Severity": "medium", diff --git a/prowler/providers/aws/services/networkfirewall/networkfirewall_logging_enabled/networkfirewall_logging_enabled.metadata.json b/prowler/providers/aws/services/networkfirewall/networkfirewall_logging_enabled/networkfirewall_logging_enabled.metadata.json index e5a687ed90..b94d35dad4 100644 --- a/prowler/providers/aws/services/networkfirewall/networkfirewall_logging_enabled/networkfirewall_logging_enabled.metadata.json +++ b/prowler/providers/aws/services/networkfirewall/networkfirewall_logging_enabled/networkfirewall_logging_enabled.metadata.json @@ -5,7 +5,7 @@ "CheckType": [ "Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53" ], - "ServiceName": "network-firewall", + "ServiceName": "networkfirewall", "SubServiceName": "", "ResourceIdTemplate": "arn:partition:network-firewall::account-id:firewall/firewall-name", "Severity": "medium", diff --git a/prowler/providers/aws/services/networkfirewall/networkfirewall_multi_az/networkfirewall_multi_az.metadata.json b/prowler/providers/aws/services/networkfirewall/networkfirewall_multi_az/networkfirewall_multi_az.metadata.json index 38591ee033..304e2e7b94 100644 --- a/prowler/providers/aws/services/networkfirewall/networkfirewall_multi_az/networkfirewall_multi_az.metadata.json +++ b/prowler/providers/aws/services/networkfirewall/networkfirewall_multi_az/networkfirewall_multi_az.metadata.json @@ -5,7 +5,7 @@ "CheckType": [ "Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls" ], - "ServiceName": "network-firewall", + "ServiceName": "networkfirewall", "SubServiceName": "", "ResourceIdTemplate": "arn:partition:network-firewall::account-id:firewall/firewall-name", "Severity": "medium", diff --git a/prowler/providers/aws/services/networkfirewall/networkfirewall_policy_default_action_fragmented_packets/networkfirewall_policy_default_action_fragmented_packets.metadata.json b/prowler/providers/aws/services/networkfirewall/networkfirewall_policy_default_action_fragmented_packets/networkfirewall_policy_default_action_fragmented_packets.metadata.json index 983201b0c5..d2072b8aac 100644 --- a/prowler/providers/aws/services/networkfirewall/networkfirewall_policy_default_action_fragmented_packets/networkfirewall_policy_default_action_fragmented_packets.metadata.json +++ b/prowler/providers/aws/services/networkfirewall/networkfirewall_policy_default_action_fragmented_packets/networkfirewall_policy_default_action_fragmented_packets.metadata.json @@ -5,7 +5,7 @@ "CheckType": [ "Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls" ], - "ServiceName": "network-firewall", + "ServiceName": "networkfirewall", "SubServiceName": "", "ResourceIdTemplate": "arn:partition:network-firewall::account-id:firewall/firewall-name", "Severity": "medium", diff --git a/prowler/providers/aws/services/networkfirewall/networkfirewall_policy_default_action_full_packets/networkfirewall_policy_default_action_full_packets.metadata.json b/prowler/providers/aws/services/networkfirewall/networkfirewall_policy_default_action_full_packets/networkfirewall_policy_default_action_full_packets.metadata.json index 118f5c19c2..05398528c3 100644 --- a/prowler/providers/aws/services/networkfirewall/networkfirewall_policy_default_action_full_packets/networkfirewall_policy_default_action_full_packets.metadata.json +++ b/prowler/providers/aws/services/networkfirewall/networkfirewall_policy_default_action_full_packets/networkfirewall_policy_default_action_full_packets.metadata.json @@ -5,7 +5,7 @@ "CheckType": [ "Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53 Controls" ], - "ServiceName": "network-firewall", + "ServiceName": "networkfirewall", "SubServiceName": "", "ResourceIdTemplate": "arn:partition:network-firewall::account-id:firewall/firewall-name", "Severity": "medium", diff --git a/prowler/providers/aws/services/networkfirewall/networkfirewall_policy_rule_group_associated/networkfirewall_policy_rule_group_associated.metadata.json b/prowler/providers/aws/services/networkfirewall/networkfirewall_policy_rule_group_associated/networkfirewall_policy_rule_group_associated.metadata.json index 34f204f637..a06393e1cf 100644 --- a/prowler/providers/aws/services/networkfirewall/networkfirewall_policy_rule_group_associated/networkfirewall_policy_rule_group_associated.metadata.json +++ b/prowler/providers/aws/services/networkfirewall/networkfirewall_policy_rule_group_associated/networkfirewall_policy_rule_group_associated.metadata.json @@ -5,7 +5,7 @@ "CheckType": [ "Software and Configuration Checks/Industry and Regulatory Standards/NIST 800-53" ], - "ServiceName": "network-firewall", + "ServiceName": "networkfirewall", "SubServiceName": "", "ResourceIdTemplate": "arn:partition:network-firewall::account-id:firewall-policy/policy-name", "Severity": "medium", diff --git a/prowler/providers/aws/services/ssmincidents/ssmincidents_enabled_with_plans/ssmincidents_enabled_with_plans.metadata.json b/prowler/providers/aws/services/ssmincidents/ssmincidents_enabled_with_plans/ssmincidents_enabled_with_plans.metadata.json index f3d8508fe4..2d840a27eb 100644 --- a/prowler/providers/aws/services/ssmincidents/ssmincidents_enabled_with_plans/ssmincidents_enabled_with_plans.metadata.json +++ b/prowler/providers/aws/services/ssmincidents/ssmincidents_enabled_with_plans/ssmincidents_enabled_with_plans.metadata.json @@ -3,7 +3,7 @@ "CheckID": "ssmincidents_enabled_with_plans", "CheckTitle": "Ensure SSM Incidents is enabled with response plans.", "CheckType": [], - "ServiceName": "ssm", + "ServiceName": "ssmincidents", "SubServiceName": "", "ResourceIdTemplate": "arn:aws:ssm:region:account-id:document/document-name", "Severity": "low", diff --git a/prowler/providers/aws/services/trustedadvisor/trustedadvisor_premium_support_plan_subscribed/trustedadvisor_premium_support_plan_subscribed.metadata.json b/prowler/providers/aws/services/trustedadvisor/trustedadvisor_premium_support_plan_subscribed/trustedadvisor_premium_support_plan_subscribed.metadata.json index c76338760b..2235cb6e85 100644 --- a/prowler/providers/aws/services/trustedadvisor/trustedadvisor_premium_support_plan_subscribed/trustedadvisor_premium_support_plan_subscribed.metadata.json +++ b/prowler/providers/aws/services/trustedadvisor/trustedadvisor_premium_support_plan_subscribed/trustedadvisor_premium_support_plan_subscribed.metadata.json @@ -3,7 +3,7 @@ "CheckID": "trustedadvisor_premium_support_plan_subscribed", "CheckTitle": "Check if a Premium support plan is subscribed", "CheckType": [], - "ServiceName": "support", + "ServiceName": "trustedadvisor", "SubServiceName": "", "ResourceIdTemplate": "arn:aws:iam::AWS_ACCOUNT_NUMBER:root", "Severity": "low", diff --git a/prowler/providers/azure/lib/mutelist/mutelist.py b/prowler/providers/azure/lib/mutelist/mutelist.py index 159a3cbb77..90ad609a1a 100644 --- a/prowler/providers/azure/lib/mutelist/mutelist.py +++ b/prowler/providers/azure/lib/mutelist/mutelist.py @@ -7,9 +7,16 @@ class AzureMutelist(Mutelist): def is_finding_muted( self, finding: Check_Report_Azure, + subscription_id: str, ) -> bool: return self.is_muted( - finding.subscription, + subscription_id, # support Azure Subscription ID in mutelist + finding.check_metadata.CheckID, + finding.location, + finding.resource_name, + unroll_dict(unroll_tags(finding.resource_tags)), + ) or self.is_muted( + finding.subscription, # support Azure Subscription Name in mutelist finding.check_metadata.CheckID, finding.location, finding.resource_name, diff --git a/prowler/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact.py b/prowler/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact.py index 2e7fa7c9d3..8c69f4437a 100644 --- a/prowler/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact.py +++ b/prowler/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact.py @@ -1,5 +1,3 @@ -import re - from prowler.lib.check.models import Check, Check_Report_Azure from prowler.providers.azure.services.defender.defender_client import defender_client @@ -10,21 +8,17 @@ class defender_additional_email_configured_with_a_security_contact(Check): for ( subscription_name, - security_contacts, - ) in defender_client.security_contacts.items(): - for contact in security_contacts.values(): - report = Check_Report_Azure(metadata=self.metadata(), resource=contact) - report.status = "PASS" + security_contact_configurations, + ) in defender_client.security_contact_configurations.items(): + for contact_configuration in security_contact_configurations.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=contact_configuration + ) report.subscription = subscription_name - report.status_extended = f"There is another correct email configured for subscription {subscription_name}." - emails = contact.emails.split(";") - - for email in emails: - if re.fullmatch( - r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", email - ): - break + if len(contact_configuration.emails) > 0: + report.status = "PASS" + report.status_extended = f"There is another correct email configured for subscription {subscription_name}." else: report.status = "FAIL" report.status_extended = f"There is not another correct email configured for subscription {subscription_name}." diff --git a/prowler/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/__init__.py b/prowler/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured.metadata.json b/prowler/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured.metadata.json new file mode 100644 index 0000000000..553cc4e072 --- /dev/null +++ b/prowler/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "defender_attack_path_notifications_properly_configured", + "CheckTitle": "Ensure that email notifications for attack paths are enabled with minimal risk level", + "CheckType": [], + "ServiceName": "defender", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AzureEmailNotifications", + "Description": "Ensure that Microsoft Defender for Cloud is configured to send email notifications for attack paths identified in the Azure subscription with an appropriate minimal risk level.", + "Risk": "If attack path notifications are not enabled, security teams may not be promptly informed about exploitable attack sequences, increasing the risk of delayed mitigation and potential breaches.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable attack path email notifications in Microsoft Defender for Cloud to ensure that security teams are notified when potential attack paths are identified. Configure the minimal risk level as appropriate for your organization.", + "Url": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/configure-email-notifications" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured.py b/prowler/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured.py new file mode 100644 index 0000000000..aeacaacac4 --- /dev/null +++ b/prowler/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured.py @@ -0,0 +1,51 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.defender.defender_client import defender_client + + +class defender_attack_path_notifications_properly_configured(Check): + """ + Ensure that email notifications for attack paths are enabled. + + This check evaluates whether Microsoft Defender for Cloud is configured to send email notifications for attack paths in each Azure subscription. + - PASS: Notifications are enabled for attack paths with a risk level set (not None) and equal or higher than the configured minimum. + - FAIL: Notifications are not enabled for attack paths in the subscription or the risk level is too low. + """ + + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + # Get the minimal risk level from config, default to 'High' + risk_levels = ["Low", "Medium", "High", "Critical"] + min_risk_level = defender_client.audit_config.get( + "defender_attack_path_minimal_risk_level", "High" + ) + if min_risk_level not in risk_levels: + min_risk_level = "High" + min_risk_index = risk_levels.index(min_risk_level) + + for ( + subscription_name, + security_contact_configurations, + ) in defender_client.security_contact_configurations.items(): + for contact_configuration in security_contact_configurations.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=contact_configuration + ) + report.subscription = subscription_name + actual_risk_level = getattr( + contact_configuration, "attack_path_minimal_risk_level", None + ) + if not actual_risk_level or actual_risk_level not in risk_levels: + report.status = "FAIL" + report.status_extended = f"Attack path notifications are not enabled in subscription {subscription_name} for security contact {contact_configuration.name}." + else: + actual_risk_index = risk_levels.index(actual_risk_level) + if actual_risk_index <= min_risk_index: + report.status = "PASS" + report.status_extended = f"Attack path notifications are enabled with minimal risk level {actual_risk_level} in subscription {subscription_name} for security contact {contact_configuration.name}." + else: + report.status = "FAIL" + report.status_extended = f"Attack path notifications are enabled with minimal risk level {actual_risk_level} in subscription {subscription_name} for security contact {contact_configuration.name}." + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.py b/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.py index 34eaab9624..e0ad128e05 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.py +++ b/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.py @@ -8,20 +8,22 @@ class defender_ensure_notify_alerts_severity_is_high(Check): for ( subscription_name, - security_contacts, - ) in defender_client.security_contacts.items(): - for contact in security_contacts.values(): - report = Check_Report_Azure(metadata=self.metadata(), resource=contact) + security_contact_configurations, + ) in defender_client.security_contact_configurations.items(): + for contact_configuration in security_contact_configurations.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=contact_configuration + ) report.subscription = subscription_name report.status = "FAIL" report.status_extended = f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {subscription_name}." if ( - contact.alert_notifications_minimal_severity != "Critical" - and contact.alert_notifications_minimal_severity != "" + contact_configuration.alert_minimal_severity + and contact_configuration.alert_minimal_severity != "Critical" ): report.status = "PASS" - report.status_extended = f"Notifications are enabled for alerts with a minimum severity of high or lower ({contact.alert_notifications_minimal_severity}) in subscription {subscription_name}." + report.status_extended = f"Notifications are enabled for alerts with a minimum severity of high or lower ({contact_configuration.alert_minimal_severity}) in subscription {subscription_name}." findings.append(report) diff --git a/prowler/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners.py b/prowler/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners.py index be18d2bfb1..ce041464b4 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners.py +++ b/prowler/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners.py @@ -8,19 +8,20 @@ class defender_ensure_notify_emails_to_owners(Check): for ( subscription_name, - security_contacts, - ) in defender_client.security_contacts.items(): - for contact in security_contacts.values(): - report = Check_Report_Azure(metadata=self.metadata(), resource=contact) - report.subscription = subscription_name - report.status = "PASS" - report.status_extended = ( - f"The Owner role is notified for subscription {subscription_name}." + security_contact_configurations, + ) in defender_client.security_contact_configurations.items(): + for contact_configuration in security_contact_configurations.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=contact_configuration ) + report.subscription = subscription_name if ( - contact.notified_roles_state != "On" - or "Owner" not in contact.notified_roles + contact_configuration.notifications_by_role.state + and "Owner" in contact_configuration.notifications_by_role.roles ): + report.status = "PASS" + report.status_extended = f"The Owner role is notified for subscription {subscription_name}." + else: report.status = "FAIL" report.status_extended = f"The Owner role is not notified for subscription {subscription_name}." diff --git a/prowler/providers/azure/services/defender/defender_service.py b/prowler/providers/azure/services/defender/defender_service.py index 2f6eaeb05d..f39dfdf484 100644 --- a/prowler/providers/azure/services/defender/defender_service.py +++ b/prowler/providers/azure/services/defender/defender_service.py @@ -1,11 +1,8 @@ from datetime import timedelta -from typing import Dict +from typing import Dict, Optional -from azure.core.exceptions import ( - ClientAuthenticationError, - HttpResponseError, - ResourceNotFoundError, -) +import requests +from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError from azure.mgmt.security import SecurityCenter from pydantic.v1 import BaseModel @@ -22,7 +19,11 @@ class Defender(AzureService): self.auto_provisioning_settings = self._get_auto_provisioning_settings() self.assessments = self._get_assessments() self.settings = self._get_settings() - self.security_contacts = self._get_security_contacts() + self.security_contact_configurations = self._get_security_contacts( + token=provider.session.get_token( + "https://management.azure.com/.default" + ).token + ) self.iot_security_solutions = self._get_iot_security_solutions() def _get_pricings(self): @@ -149,48 +150,70 @@ class Defender(AzureService): ) return settings - def _get_security_contacts(self): + def _get_security_contacts(self, token: str) -> dict[str, dict]: + """ + Get all security contacts configuration for all subscriptions. + + Args: + token: The authentication token to make the request. + + Returns: + A dictionary of security contacts for all subscriptions. + """ logger.info("Defender - Getting security contacts...") security_contacts = {} - for subscription_name, client in self.clients.items(): + for subscription_name, subscription_id in self.subscriptions.items(): try: - security_contacts.update({subscription_name: {}}) - # TODO: List all security contacts. For now, the list method is not working. - security_contact_default = client.security_contacts.get("default") - security_contacts[subscription_name].update( - { - security_contact_default.name: SecurityContacts( - resource_id=security_contact_default.id, - name=getattr(security_contact_default, "name", "default") - or "default", - emails=security_contact_default.emails, - phone=security_contact_default.phone, - alert_notifications_minimal_severity=security_contact_default.alert_notifications.minimal_severity, - alert_notifications_state=security_contact_default.alert_notifications.state, - notified_roles=security_contact_default.notifications_by_role.roles, - notified_roles_state=security_contact_default.notifications_by_role.state, - ) - } - ) - except HttpResponseError as error: - if error.status_code == 404: - security_contacts[subscription_name].update( - { - "default": SecurityContacts( - resource_id=f"/subscriptions/{self.subscriptions[subscription_name]}/providers/Microsoft.Security/securityContacts/default", - name="default", - emails="", - phone="", - alert_notifications_minimal_severity="", - alert_notifications_state="", - notified_roles=[""], - notified_roles_state="", - ) - } + url = f"https://management.azure.com/subscriptions/{subscription_id}/providers/Microsoft.Security/securityContacts?api-version=2023-12-01-preview" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + response = requests.get(url, headers=headers) + response.raise_for_status() + contact_configurations = response.json().get("value", []) + security_contacts[subscription_name] = {} + for contact_configuration in contact_configurations: + props = contact_configuration.get("properties", {}) + + # Map notificationsByRole.state from "On"/"Off" to boolean + notifications_by_role_state = props.get( + "notificationsByRole", {} + ).get("state", "Off") + notifications_by_role_state_bool = ( + notifications_by_role_state.lower() == "on" ) - else: - logger.error( - f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + notifications_by_role_roles = props.get( + "notificationsByRole", {} + ).get("roles", []) + + # Extract minimalRiskLevel and minimalSeverity from notificationsSources + attack_path_minimal_risk_level = None + alert_minimal_severity = None + for source in props.get("notificationsSources", []): + if source.get("sourceType") == "AttackPath": + value = source.get("minimalRiskLevel") + if value is not None: + attack_path_minimal_risk_level = value + elif source.get("sourceType") == "Alert": + value = source.get("minimalSeverity") + if value is not None: + alert_minimal_severity = value + + security_contacts[subscription_name][ + contact_configuration.get("name", "default") + ] = SecurityContactConfiguration( + id=contact_configuration.get("id", ""), + name=contact_configuration.get("name", "default"), + enabled=props.get("isEnabled", False), + emails=props.get("emails", "").split(";"), + phone=props.get("phone", ""), + notifications_by_role=NotificationsByRole( + state=notifications_by_role_state_bool, + roles=notifications_by_role_roles, + ), + attack_path_minimal_risk_level=attack_path_minimal_risk_level, + alert_minimal_severity=alert_minimal_severity, ) except Exception as error: logger.error( @@ -252,15 +275,42 @@ class Setting(BaseModel): enabled: bool -class SecurityContacts(BaseModel): - resource_id: str +class NotificationsByRole(BaseModel): + """ + Defines whether to send email notifications from Microsoft Defender for Cloud to persons with specific RBAC roles on the subscription. + + Attributes: + state: Whether notifications by role are enabled. + roles: List of Azure roles (e.g., 'Owner', 'Admin') to be notified. + """ + + state: bool + roles: list[str] + + +class SecurityContactConfiguration(BaseModel): + """ + Represents the configuration of an Azure Security Center security contact. + + Attributes: + id: The unique resource ID of the security contact. + name: The name of the security contact (usually 'default'). + enabled: Whether the security contact is enabled. If enabled, the security contact will receive notifications, otherwise it will not. + emails: List of email addresses to notify. + phone: Contact phone number. + notifications_by_role: Defines whether to send email notifications from Microsoft Defender for Cloud to persons with specific RBAC roles on the subscription. + attack_path_minimal_risk_level: Minimal risk level for Attack Path notifications (e.g., 'Critical'). + alert_minimal_severity: Minimal severity for Alert notifications (e.g., 'Medium'). + """ + + id: str name: str - emails: str - phone: str - alert_notifications_minimal_severity: str - alert_notifications_state: str - notified_roles: list[str] - notified_roles_state: str + enabled: bool + emails: list[str] + phone: Optional[str] = None + notifications_by_role: NotificationsByRole + attack_path_minimal_risk_level: Optional[str] = None + alert_minimal_severity: Optional[str] = None class IoTSecuritySolution(BaseModel): diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 841bd0f1ed..47f8d6c4d8 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -246,10 +246,14 @@ class Provider(ABC): elif "iac" in provider_class_name.lower(): provider_class( scan_path=arguments.scan_path, + scan_repository_url=arguments.scan_repository_url, frameworks=arguments.frameworks, exclude_path=arguments.exclude_path, config_path=arguments.config_file, fixer_config=fixer_config, + github_username=arguments.github_username, + personal_access_token=arguments.personal_access_token, + oauth_app_token=arguments.oauth_app_token, ) except TypeError as error: diff --git a/prowler/providers/gcp/services/compute/compute_firewall_rdp_access_from_the_internet_allowed/compute_firewall_rdp_access_from_the_internet_allowed.metadata.json b/prowler/providers/gcp/services/compute/compute_firewall_rdp_access_from_the_internet_allowed/compute_firewall_rdp_access_from_the_internet_allowed.metadata.json index 20cc3034f8..5539ed4878 100644 --- a/prowler/providers/gcp/services/compute/compute_firewall_rdp_access_from_the_internet_allowed/compute_firewall_rdp_access_from_the_internet_allowed.metadata.json +++ b/prowler/providers/gcp/services/compute/compute_firewall_rdp_access_from_the_internet_allowed/compute_firewall_rdp_access_from_the_internet_allowed.metadata.json @@ -3,7 +3,7 @@ "CheckID": "compute_firewall_rdp_access_from_the_internet_allowed", "CheckTitle": "Ensure That RDP Access Is Restricted From the Internet", "CheckType": [], - "ServiceName": "networking", + "ServiceName": "compute", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "critical", diff --git a/prowler/providers/gcp/services/compute/compute_firewall_ssh_access_from_the_internet_allowed/compute_firewall_ssh_access_from_the_internet_allowed.metadata.json b/prowler/providers/gcp/services/compute/compute_firewall_ssh_access_from_the_internet_allowed/compute_firewall_ssh_access_from_the_internet_allowed.metadata.json index 410b436a61..f91101feb0 100644 --- a/prowler/providers/gcp/services/compute/compute_firewall_ssh_access_from_the_internet_allowed/compute_firewall_ssh_access_from_the_internet_allowed.metadata.json +++ b/prowler/providers/gcp/services/compute/compute_firewall_ssh_access_from_the_internet_allowed/compute_firewall_ssh_access_from_the_internet_allowed.metadata.json @@ -3,7 +3,7 @@ "CheckID": "compute_firewall_ssh_access_from_the_internet_allowed", "CheckTitle": "Ensure That SSH Access Is Restricted From the Internet", "CheckType": [], - "ServiceName": "networking", + "ServiceName": "compute", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "critical", diff --git a/prowler/providers/github/exceptions/exceptions.py b/prowler/providers/github/exceptions/exceptions.py index f7058d96ea..b49cce8ebe 100644 --- a/prowler/providers/github/exceptions/exceptions.py +++ b/prowler/providers/github/exceptions/exceptions.py @@ -30,6 +30,10 @@ class GithubBaseException(ProwlerException): "message": "Github invalid App Key or App ID for GitHub APP login", "remediation": "Check user and password and ensure they are properly set up as in your Github account.", }, + (5006, "GithubInvalidProviderIdError"): { + "message": "The provided provider ID does not match with the authenticated user or accessible organizations", + "remediation": "Check the provider ID and ensure it matches the authenticated user or an organization you have access to.", + }, } def __init__(self, code, file=None, original_exception=None, message=None): @@ -93,3 +97,10 @@ class GithubInvalidCredentialsError(GithubCredentialsError): super().__init__( 5005, file=file, original_exception=original_exception, message=message ) + + +class GithubInvalidProviderIdError(GithubCredentialsError): + def __init__(self, file=None, original_exception=None, message=None): + super().__init__( + 5006, file=file, original_exception=original_exception, message=message + ) diff --git a/prowler/providers/github/github_provider.py b/prowler/providers/github/github_provider.py index f217066929..df6c8ee046 100644 --- a/prowler/providers/github/github_provider.py +++ b/prowler/providers/github/github_provider.py @@ -14,11 +14,12 @@ from prowler.config.config import ( 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 +from prowler.providers.common.models import Audit_Metadata, Connection from prowler.providers.common.provider import Provider from prowler.providers.github.exceptions.exceptions import ( GithubEnvironmentVariableError, GithubInvalidCredentialsError, + GithubInvalidProviderIdError, GithubInvalidTokenError, GithubSetUpIdentityError, GithubSetUpSessionError, @@ -122,14 +123,28 @@ class GithubProvider(Provider): """ logger.info("Instantiating GitHub Provider...") - self._session = self.setup_session( + self._session = GithubProvider.setup_session( personal_access_token, oauth_app_token, github_app_id, github_app_key, ) - self._identity = self.setup_identity() + # Set the authentication method + if personal_access_token: + self._auth_method = "Personal Access Token" + elif oauth_app_token: + self._auth_method = "OAuth App Token" + elif github_app_id and github_app_key: + self._auth_method = "GitHub App Token" + elif environ.get("GITHUB_PERSONAL_ACCESS_TOKEN", ""): + self._auth_method = "Environment Variable for Personal Access Token" + elif environ.get("GITHUB_OAUTH_APP_TOKEN", ""): + self._auth_method = "Environment Variable for OAuth App Token" + elif environ.get("GITHUB_APP_ID", "") and environ.get("GITHUB_APP_KEY", ""): + self._auth_method = "Environment Variables for GitHub App Key and ID" + + self._identity = GithubProvider.setup_identity(self._session) # Audit Config if config_content: @@ -195,12 +210,13 @@ class GithubProvider(Provider): """ return self._mutelist + @staticmethod def setup_session( - self, personal_access_token: str = None, oauth_app_token: str = None, github_app_id: int = 0, github_app_key: str = None, + github_app_key_content: str = None, ) -> GithubSession: """ Returns the GitHub headers responsible authenticating API calls. @@ -210,7 +226,7 @@ class GithubProvider(Provider): oauth_app_token (str): GitHub OAuth App token. github_app_id (int): GitHub App ID. github_app_key (str): GitHub App key. - + github_app_key_content (str): GitHub App key content. Returns: GithubSession: Authenticated session token for API requests. """ @@ -223,18 +239,17 @@ class GithubProvider(Provider): # Ensure that at least one authentication method is selected. Default to environment variable for PAT if none is provided. if personal_access_token: session_token = personal_access_token - self._auth_method = "Personal Access Token" elif oauth_app_token: session_token = oauth_app_token - self._auth_method = "OAuth App Token" - elif github_app_id and github_app_key: + elif github_app_id and (github_app_key or github_app_key_content): app_id = github_app_id - with open(github_app_key, "r") as rsa_key: - app_key = rsa_key.read() - - self._auth_method = "GitHub App Token" + if github_app_key: + with open(github_app_key, "r") as rsa_key: + app_key = rsa_key.read() + else: + app_key = format_rsa_key(github_app_key_content) else: # PAT @@ -242,8 +257,6 @@ class GithubProvider(Provider): "Looking for GITHUB_PERSONAL_ACCESS_TOKEN environment variable as user has not provided any token...." ) session_token = environ.get("GITHUB_PERSONAL_ACCESS_TOKEN", "") - if session_token: - self._auth_method = "Environment Variable for Personal Access Token" if not session_token: # OAUTH @@ -251,8 +264,6 @@ class GithubProvider(Provider): "Looking for GITHUB_OAUTH_APP_TOKEN environment variable as user has not provided any token...." ) session_token = environ.get("GITHUB_OAUTH_APP_TOKEN", "") - if session_token: - self._auth_method = "Environment Variable for OAuth App Token" if not session_token: # APP @@ -260,14 +271,12 @@ class GithubProvider(Provider): "Looking for GITHUB_APP_ID and GITHUB_APP_KEY environment variables as user has not provided any token...." ) app_id = environ.get("GITHUB_APP_ID", "") - app_key = format_rsa_key(environ.get(r"GITHUB_APP_KEY", "")) + app_key = format_rsa_key(environ.get("GITHUB_APP_KEY", "")) if app_id and app_key: - self._auth_method = ( - "Environment Variables for GitHub App Key and ID" - ) + pass - if not self._auth_method: + if not session_token and not (app_id and app_key): raise GithubEnvironmentVariableError( file=os.path.basename(__file__), message="No authentication method selected and not environment variables were found.", @@ -289,8 +298,9 @@ class GithubProvider(Provider): original_exception=error, ) + @staticmethod def setup_identity( - self, + session: GithubSession, ) -> Union[GithubIdentityInfo, GithubAppIdentityInfo]: """ Returns the GitHub identity information @@ -298,12 +308,11 @@ class GithubProvider(Provider): Returns: GithubIdentityInfo | GithubAppIdentityInfo: An instance of GithubIdentityInfo or GithubAppIdentityInfo containing the identity information. """ - credentials = self.session try: retry_config = GithubRetry(total=3) - if credentials.token: - auth = Auth.Token(credentials.token) + if session.token: + auth = Auth.Token(session.token) g = Github(auth=auth, retry=retry_config) try: identity = GithubIdentityInfo( @@ -318,8 +327,8 @@ class GithubProvider(Provider): original_exception=error, ) - elif credentials.id != 0 and credentials.key: - auth = Auth.AppAuth(credentials.id, credentials.key) + elif session.id != 0 and session.key: + auth = Auth.AppAuth(session.id, session.key) gi = GithubIntegration(auth=auth, retry=retry_config) try: identity = GithubAppIdentityInfo(app_id=gi.get_app().id) @@ -360,3 +369,160 @@ class GithubProvider(Provider): f"{Style.BRIGHT}Using the GitHub credentials below:{Style.RESET_ALL}" ) print_boxes(report_lines, report_title) + + @staticmethod + def validate_provider_id( + session: GithubSession, + provider_id: str, + ) -> None: + """ + Validate that the provider ID (username or organization) is accessible with the given credentials. + + Args: + session (GithubSession): The GitHub session with authentication. + provider_id (str): The provider ID to validate (username or organization name). + + Raises: + GithubInvalidProviderIdError: If the provider ID is not accessible with the given credentials. + + Examples: + >>> GithubProvider.validate_provider_id(session, "my-username") + >>> GithubProvider.validate_provider_id(session, "my-organization") + """ + try: + retry_config = GithubRetry(total=3) + + if session.token: + # For Personal Access Token and OAuth App Token + auth = Auth.Token(session.token) + g = Github(auth=auth, retry=retry_config) + + # First check if the provider ID is the authenticated user + authenticated_user = g.get_user() + if authenticated_user.login == provider_id: + return + + # Then check if the provider ID is an organization the token has access to + try: + g.get_organization(provider_id) + return + except Exception: + # Organization doesn't exist or the token doesn't have access to it + pass + + raise GithubInvalidProviderIdError( + file=os.path.basename(__file__), + message=f"The provider ID '{provider_id}' is not accessible with the provided credentials. " + f"Authenticated user: {authenticated_user.login}", + ) + + elif session.id != 0 and session.key: + # For GitHub App + auth = Auth.AppAuth(session.id, session.key) + gi = GithubIntegration(auth=auth, retry=retry_config) + + # Check if the provider ID is in the app's installations + for installation in gi.get_installations(): + try: + # Check if the installation id is the username or organization id + account_login = installation.raw_data.get("account", {}).get( + "login" + ) + if account_login == provider_id: + return + except Exception: + continue + + raise GithubInvalidProviderIdError( + file=os.path.basename(__file__), + message=f"The provider ID '{provider_id}' is not accessible with the provided GitHub App credentials.", + ) + + except GithubInvalidProviderIdError: + # Re-raise the specific exception + raise + except Exception as error: + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + raise GithubInvalidProviderIdError( + file=os.path.basename(__file__), + original_exception=error, + message=f"Error validating provider ID '{provider_id}'", + ) + + @staticmethod + def test_connection( + personal_access_token: str = "", + oauth_app_token: str = "", + github_app_key: str = "", + github_app_key_content: str = "", + github_app_id: int = 0, + raise_on_exception: bool = True, + provider_id: str = None, + ) -> Connection: + """Test connection to GitHub. + + Test the connection to GitHub using the provided credentials. + + Args: + personal_access_token (str): GitHub personal access token. + oauth_app_token (str): GitHub OAuth App token. + github_app_key (str): GitHub App key. + github_app_key_content (str): GitHub App key content. + github_app_id (int): GitHub App ID. + raise_on_exception (bool): Flag indicating whether to raise an exception if the connection fails. + provider_id (str): The provider ID, in this case it's the GitHub organization/username. + + Returns: + Connection: Connection object with success status or error information. + + Raises: + Exception: If failed to test the connection to GitHub. + GithubEnvironmentVariableError: If environment variables are missing. + GithubInvalidTokenError: If the provided token is invalid. + GithubInvalidCredentialsError: If the provided App credentials are invalid. + GithubSetUpSessionError: If there is an error setting up the session. + GithubSetUpIdentityError: If there is an error setting up the identity. + GithubInvalidProviderIdError: If the provided provider ID is not accessible with the given credentials. + + Examples: + >>> GithubProvider.test_connection(personal_access_token="ghp_xxxxxxxxxxxxxxxx") + Connection(is_connected=True) + >>> GithubProvider.test_connection(github_app_id=12345, github_app_key="/path/to/key.pem") + Connection(is_connected=True) + >>> GithubProvider.test_connection(provider_id="my-org") + Connection(is_connected=True) + """ + try: + # Set up the GitHub session + session = GithubProvider.setup_session( + personal_access_token=personal_access_token, + oauth_app_token=oauth_app_token, + github_app_id=github_app_id, + github_app_key=github_app_key, + github_app_key_content=github_app_key_content, + ) + + # Set up the identity to test the connection + GithubProvider.setup_identity(session) + + # Validate provider ID if provided + if provider_id: + GithubProvider.validate_provider_id(session, provider_id) + + return Connection(is_connected=True) + except GithubInvalidProviderIdError as provider_id_error: + logger.critical( + f"{provider_id_error.__class__.__name__}[{provider_id_error.__traceback__.tb_lineno}]: {provider_id_error}" + ) + if raise_on_exception: + raise provider_id_error + return Connection(error=provider_id_error) + 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/github/lib/arguments/arguments.py b/prowler/providers/github/lib/arguments/arguments.py index 530be5b012..9ba2415cdd 100644 --- a/prowler/providers/github/lib/arguments/arguments.py +++ b/prowler/providers/github/lib/arguments/arguments.py @@ -31,6 +31,7 @@ def init_parser(self): ) github_auth_subparser.add_argument( "--github-app-key", + "--github-app-key-path", nargs="?", help="GitHub App Key Path to log in against GitHub", default=None, diff --git a/prowler/providers/iac/iac_provider.py b/prowler/providers/iac/iac_provider.py index 1abd00f4c5..69c8db3fdd 100644 --- a/prowler/providers/iac/iac_provider.py +++ b/prowler/providers/iac/iac_provider.py @@ -1,7 +1,11 @@ import json +import shutil import sys +import tempfile +from os import environ from typing import List +from alive_progress import alive_bar from checkov.ansible.runner import Runner as AnsibleRunner from checkov.argo_workflows.runner import Runner as ArgoWorkflowsRunner from checkov.arm.runner import Runner as ArmRunner @@ -35,6 +39,7 @@ from checkov.terraform.runner import Runner as TerraformRunner from checkov.terraform_json.runner import TerraformJsonRunner from checkov.yaml_doc.runner import Runner as YamlDocRunner from colorama import Fore, Style +from dulwich import porcelain from prowler.config.config import ( default_config_file_path, @@ -54,21 +59,56 @@ class IacProvider(Provider): def __init__( self, scan_path: str = ".", + scan_repository_url: str = None, frameworks: list[str] = ["all"], exclude_path: list[str] = [], config_path: str = None, config_content: dict = None, fixer_config: dict = {}, + github_username: str = None, + personal_access_token: str = None, + oauth_app_token: str = None, ): logger.info("Instantiating IAC Provider...") self.scan_path = scan_path + self.scan_repository_url = scan_repository_url self.frameworks = frameworks self.exclude_path = exclude_path self.region = "global" self.audited_account = "local-iac" self._session = None self._identity = "prowler" + self._auth_method = "No auth" + + if scan_repository_url: + oauth_app_token = oauth_app_token or environ.get("GITHUB_OAUTH_APP_TOKEN") + github_username = github_username or environ.get("GITHUB_USERNAME") + personal_access_token = personal_access_token or environ.get( + "GITHUB_PERSONAL_ACCESS_TOKEN" + ) + + if oauth_app_token: + self.oauth_app_token = oauth_app_token + self.github_username = None + self.personal_access_token = None + self._auth_method = "OAuth App Token" + logger.info("Using OAuth App Token for GitHub authentication") + elif github_username and personal_access_token: + self.github_username = github_username + self.personal_access_token = personal_access_token + self.oauth_app_token = None + self._auth_method = "Personal Access Token" + logger.info( + "Using GitHub username and personal access token for authentication" + ) + else: + self.github_username = None + self.personal_access_token = None + self.oauth_app_token = None + logger.debug( + "No GitHub authentication method provided; proceeding without authentication." + ) # Audit Config if config_content: @@ -97,6 +137,10 @@ class IacProvider(Provider): Provider.set_global_provider(self) + @property + def auth_method(self): + return self._auth_method + @property def type(self): return self._type @@ -183,8 +227,72 @@ class IacProvider(Provider): ) sys.exit(1) + def _clone_repository( + self, + repository_url: str, + github_username: str = None, + personal_access_token: str = None, + oauth_app_token: str = None, + ) -> str: + """ + Clone a git repository to a temporary directory, supporting GitHub authentication. + """ + try: + if github_username and personal_access_token: + repository_url = repository_url.replace( + "https://github.com/", + f"https://{github_username}:{personal_access_token}@github.com/", + ) + elif oauth_app_token: + repository_url = repository_url.replace( + "https://github.com/", + f"https://oauth2:{oauth_app_token}@github.com/", + ) + + temporary_directory = tempfile.mkdtemp() + logger.info( + f"Cloning repository {repository_url} into {temporary_directory}..." + ) + with alive_bar( + ctrl_c=False, + bar="blocks", + spinner="classic", + stats=False, + enrich_print=False, + ) as bar: + try: + bar.title = f"-> Cloning {repository_url}..." + porcelain.clone(repository_url, temporary_directory, depth=1) + bar.title = "-> Repository cloned successfully!" + except Exception as clone_error: + bar.title = "-> Cloning failed!" + raise clone_error + return temporary_directory + except Exception as error: + logger.critical( + f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" + ) + def run(self) -> List[CheckReportIAC]: - return self.run_scan(self.scan_path, self.frameworks, self.exclude_path) + temp_dir = None + if self.scan_repository_url: + scan_dir = temp_dir = self._clone_repository( + self.scan_repository_url, + getattr(self, "github_username", None), + getattr(self, "personal_access_token", None), + getattr(self, "oauth_app_token", None), + ) + else: + scan_dir = self.scan_path + + try: + reports = self.run_scan(scan_dir, self.frameworks, self.exclude_path) + finally: + if temp_dir: + logger.info(f"Removing temporary directory {temp_dir}...") + shutil.rmtree(temp_dir) + + return reports def run_scan( self, directory: str, frameworks: list[str], exclude_path: list[str] @@ -249,15 +357,32 @@ class IacProvider(Provider): sys.exit(1) def print_credentials(self): - report_lines = [ - f"Directory: {Fore.YELLOW}{self.scan_path}{Style.RESET_ALL}", - ] + if self.scan_repository_url: + report_title = ( + f"{Style.BRIGHT}Scanning remote IaC repository:{Style.RESET_ALL}" + ) + report_lines = [ + f"Repository: {Fore.YELLOW}{self.scan_repository_url}{Style.RESET_ALL}", + ] + else: + report_title = ( + f"{Style.BRIGHT}Scanning local IaC directory:{Style.RESET_ALL}" + ) + report_lines = [ + f"Directory: {Fore.YELLOW}{self.scan_path}{Style.RESET_ALL}", + ] + if self.exclude_path: report_lines.append( f"Excluded paths: {Fore.YELLOW}{', '.join(self.exclude_path)}{Style.RESET_ALL}" ) + report_lines.append( f"Frameworks: {Fore.YELLOW}{', '.join(self.frameworks)}{Style.RESET_ALL}" ) - report_title = f"{Style.BRIGHT}Scanning local IaC directory:{Style.RESET_ALL}" + + report_lines.append( + f"Authentication method: {Fore.YELLOW}{self.auth_method}{Style.RESET_ALL}" + ) + print_boxes(report_lines, report_title) diff --git a/prowler/providers/iac/lib/arguments/arguments.py b/prowler/providers/iac/lib/arguments/arguments.py index bc478bc4d1..6adadeca86 100644 --- a/prowler/providers/iac/lib/arguments/arguments.py +++ b/prowler/providers/iac/lib/arguments/arguments.py @@ -44,8 +44,17 @@ def init_parser(self): "-P", dest="scan_path", default=".", - help="Path to the folder containing your infrastructure-as-code files. Default: current directory", + help="Path to the folder containing your infrastructure-as-code files. Default: current directory. Mutually exclusive with --scan-repository-url.", ) + + iac_scan_subparser.add_argument( + "--scan-repository-url", + "-R", + dest="scan_repository_url", + default=None, + help="URL to the repository containing your infrastructure-as-code files. Mutually exclusive with --scan-path.", + ) + iac_scan_subparser.add_argument( "--frameworks", "-f", @@ -63,3 +72,38 @@ def init_parser(self): default=[], help="Comma-separated list of paths to exclude from the scan. Default: none", ) + + iac_scan_subparser.add_argument( + "--github-username", + dest="github_username", + nargs="?", + default=None, + help="GitHub username for authenticated repository cloning (used with --personal-access-token). If not provided, will use GITHUB_USERNAME env var.", + ) + iac_scan_subparser.add_argument( + "--personal-access-token", + dest="personal_access_token", + nargs="?", + default=None, + help="GitHub personal access token for authenticated repository cloning (used with --github-username). If not provided, will use GITHUB_PERSONAL_ACCESS_TOKEN env var.", + ) + iac_scan_subparser.add_argument( + "--oauth-app-token", + dest="oauth_app_token", + nargs="?", + default=None, + help="GitHub OAuth app token for authenticated repository cloning. If not provided, will use GITHUB_OAUTH_APP_TOKEN env var.", + ) + + +def validate_arguments(arguments): + scan_path = getattr(arguments, "scan_path", None) + scan_repository_url = getattr(arguments, "scan_repository_url", None) + if scan_path and scan_repository_url: + # If scan_path is set to default ("."), allow scan_repository_url + if scan_path != ".": + return ( + False, + "--scan-path (-P) and --scan-repository-url (-R) are mutually exclusive. Please specify only one.", + ) + return (True, "") diff --git a/prowler/providers/kubernetes/services/controllermanager/controllermanager_bind_address/controllermanager_bind_address.metadata.json b/prowler/providers/kubernetes/services/controllermanager/controllermanager_bind_address/controllermanager_bind_address.metadata.json index f42494cb8c..5345f7625e 100644 --- a/prowler/providers/kubernetes/services/controllermanager/controllermanager_bind_address/controllermanager_bind_address.metadata.json +++ b/prowler/providers/kubernetes/services/controllermanager/controllermanager_bind_address/controllermanager_bind_address.metadata.json @@ -3,7 +3,7 @@ "CheckID": "controllermanager_bind_address", "CheckTitle": "Ensure that the --bind-address argument is set to 127.0.0.1", "CheckType": [], - "ServiceName": "controller-manager", + "ServiceName": "controllermanager", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", diff --git a/prowler/providers/kubernetes/services/controllermanager/controllermanager_disable_profiling/controllermanager_disable_profiling.metadata.json b/prowler/providers/kubernetes/services/controllermanager/controllermanager_disable_profiling/controllermanager_disable_profiling.metadata.json index dd1a0163b0..461c69f9ae 100644 --- a/prowler/providers/kubernetes/services/controllermanager/controllermanager_disable_profiling/controllermanager_disable_profiling.metadata.json +++ b/prowler/providers/kubernetes/services/controllermanager/controllermanager_disable_profiling/controllermanager_disable_profiling.metadata.json @@ -3,7 +3,7 @@ "CheckID": "controllermanager_disable_profiling", "CheckTitle": "Ensure that the --profiling argument is set to false", "CheckType": [], - "ServiceName": "controller-manager", + "ServiceName": "controllermanager", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", diff --git a/prowler/providers/kubernetes/services/controllermanager/controllermanager_garbage_collection/controllermanager_garbage_collection.metadata.json b/prowler/providers/kubernetes/services/controllermanager/controllermanager_garbage_collection/controllermanager_garbage_collection.metadata.json index 6119f109f9..19ff18ce7d 100644 --- a/prowler/providers/kubernetes/services/controllermanager/controllermanager_garbage_collection/controllermanager_garbage_collection.metadata.json +++ b/prowler/providers/kubernetes/services/controllermanager/controllermanager_garbage_collection/controllermanager_garbage_collection.metadata.json @@ -3,7 +3,7 @@ "CheckID": "controllermanager_garbage_collection", "CheckTitle": "Ensure that the --terminated-pod-gc-threshold argument is set as appropriate", "CheckType": [], - "ServiceName": "controller-manager", + "ServiceName": "controllermanager", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", diff --git a/prowler/providers/kubernetes/services/controllermanager/controllermanager_root_ca_file_set/controllermanager_root_ca_file_set.metadata.json b/prowler/providers/kubernetes/services/controllermanager/controllermanager_root_ca_file_set/controllermanager_root_ca_file_set.metadata.json index b93487ce10..012723e5ad 100644 --- a/prowler/providers/kubernetes/services/controllermanager/controllermanager_root_ca_file_set/controllermanager_root_ca_file_set.metadata.json +++ b/prowler/providers/kubernetes/services/controllermanager/controllermanager_root_ca_file_set/controllermanager_root_ca_file_set.metadata.json @@ -3,7 +3,7 @@ "CheckID": "controllermanager_root_ca_file_set", "CheckTitle": "Ensure that the --root-ca-file argument is set as appropriate", "CheckType": [], - "ServiceName": "controller-manager", + "ServiceName": "controllermanager", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", diff --git a/prowler/providers/kubernetes/services/controllermanager/controllermanager_rotate_kubelet_server_cert/controllermanager_rotate_kubelet_server_cert.metadata.json b/prowler/providers/kubernetes/services/controllermanager/controllermanager_rotate_kubelet_server_cert/controllermanager_rotate_kubelet_server_cert.metadata.json index b2cf02dc36..7c84c18fa3 100644 --- a/prowler/providers/kubernetes/services/controllermanager/controllermanager_rotate_kubelet_server_cert/controllermanager_rotate_kubelet_server_cert.metadata.json +++ b/prowler/providers/kubernetes/services/controllermanager/controllermanager_rotate_kubelet_server_cert/controllermanager_rotate_kubelet_server_cert.metadata.json @@ -3,7 +3,7 @@ "CheckID": "controllermanager_rotate_kubelet_server_cert", "CheckTitle": "Ensure that the RotateKubeletServerCertificate argument is set to true", "CheckType": [], - "ServiceName": "controller-manager", + "ServiceName": "controllermanager", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", diff --git a/prowler/providers/kubernetes/services/controllermanager/controllermanager_service_account_credentials/controllermanager_service_account_credentials.metadata.json b/prowler/providers/kubernetes/services/controllermanager/controllermanager_service_account_credentials/controllermanager_service_account_credentials.metadata.json index 05f80dc973..26562a0ab2 100644 --- a/prowler/providers/kubernetes/services/controllermanager/controllermanager_service_account_credentials/controllermanager_service_account_credentials.metadata.json +++ b/prowler/providers/kubernetes/services/controllermanager/controllermanager_service_account_credentials/controllermanager_service_account_credentials.metadata.json @@ -3,7 +3,7 @@ "CheckID": "controllermanager_service_account_credentials", "CheckTitle": "Ensure that the --use-service-account-credentials argument is set to true", "CheckType": [], - "ServiceName": "controller-manager", + "ServiceName": "controllermanager", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", diff --git a/prowler/providers/kubernetes/services/controllermanager/controllermanager_service_account_private_key_file/controllermanager_service_account_private_key_file.metadata.json b/prowler/providers/kubernetes/services/controllermanager/controllermanager_service_account_private_key_file/controllermanager_service_account_private_key_file.metadata.json index 38a5838e97..b0e008447d 100644 --- a/prowler/providers/kubernetes/services/controllermanager/controllermanager_service_account_private_key_file/controllermanager_service_account_private_key_file.metadata.json +++ b/prowler/providers/kubernetes/services/controllermanager/controllermanager_service_account_private_key_file/controllermanager_service_account_private_key_file.metadata.json @@ -3,7 +3,7 @@ "CheckID": "controllermanager_service_account_private_key_file", "CheckTitle": "Ensure that the --service-account-private-key-file argument is set as appropriate", "CheckType": [], - "ServiceName": "controller-manager", + "ServiceName": "controllermanager", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", diff --git a/prowler/providers/kubernetes/services/rbac/rbac_cluster_admin_usage/rbac_cluster_admin_usage.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_cluster_admin_usage/rbac_cluster_admin_usage.metadata.json index b7d185dfb4..576ef4ab30 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_cluster_admin_usage/rbac_cluster_admin_usage.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_cluster_admin_usage/rbac_cluster_admin_usage.metadata.json @@ -3,7 +3,7 @@ "CheckID": "rbac_cluster_admin_usage", "CheckTitle": "Ensure that the cluster-admin role is only used where required", "CheckType": [], - "ServiceName": "RBAC", + "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_csr_approval_access/rbac_minimize_csr_approval_access.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_minimize_csr_approval_access/rbac_minimize_csr_approval_access.metadata.json index 52b6d17c23..0eacde8322 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_csr_approval_access/rbac_minimize_csr_approval_access.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_csr_approval_access/rbac_minimize_csr_approval_access.metadata.json @@ -3,7 +3,7 @@ "CheckID": "rbac_minimize_csr_approval_access", "CheckTitle": "Minimize access to the approval sub-resource of certificatesigningrequests objects", "CheckType": [], - "ServiceName": "RBAC", + "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_node_proxy_subresource_access/rbac_minimize_node_proxy_subresource_access.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_minimize_node_proxy_subresource_access/rbac_minimize_node_proxy_subresource_access.metadata.json index 51706ec30d..d5b847273e 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_node_proxy_subresource_access/rbac_minimize_node_proxy_subresource_access.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_node_proxy_subresource_access/rbac_minimize_node_proxy_subresource_access.metadata.json @@ -3,7 +3,7 @@ "CheckID": "rbac_minimize_node_proxy_subresource_access", "CheckTitle": "Minimize access to the proxy sub-resource of nodes", "CheckType": [], - "ServiceName": "RBAC", + "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_pod_creation_access/rbac_minimize_pod_creation_access.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_minimize_pod_creation_access/rbac_minimize_pod_creation_access.metadata.json index c4873e74bd..659c78192f 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_pod_creation_access/rbac_minimize_pod_creation_access.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_pod_creation_access/rbac_minimize_pod_creation_access.metadata.json @@ -3,7 +3,7 @@ "CheckID": "rbac_minimize_pod_creation_access", "CheckTitle": "Minimize access to create pods", "CheckType": [], - "ServiceName": "RBAC", + "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_pv_creation_access/rbac_minimize_pv_creation_access.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_minimize_pv_creation_access/rbac_minimize_pv_creation_access.metadata.json index e96f7fdaa8..ad3c7c0d09 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_pv_creation_access/rbac_minimize_pv_creation_access.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_pv_creation_access/rbac_minimize_pv_creation_access.metadata.json @@ -3,7 +3,7 @@ "CheckID": "rbac_minimize_pv_creation_access", "CheckTitle": "Minimize access to create persistent volumes", "CheckType": [], - "ServiceName": "RBAC", + "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_secret_access/rbac_minimize_secret_access.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_minimize_secret_access/rbac_minimize_secret_access.metadata.json index fbd16b7aa3..7e0c668d5f 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_secret_access/rbac_minimize_secret_access.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_secret_access/rbac_minimize_secret_access.metadata.json @@ -3,7 +3,7 @@ "CheckID": "rbac_minimize_secret_access", "CheckTitle": "Minimize access to secrets", "CheckType": [], - "ServiceName": "RBAC", + "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_service_account_token_creation/rbac_minimize_service_account_token_creation.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_minimize_service_account_token_creation/rbac_minimize_service_account_token_creation.metadata.json index 1298c680ba..362b938341 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_service_account_token_creation/rbac_minimize_service_account_token_creation.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_service_account_token_creation/rbac_minimize_service_account_token_creation.metadata.json @@ -3,7 +3,7 @@ "CheckID": "rbac_minimize_service_account_token_creation", "CheckTitle": "Minimize access to the service account token creation", "CheckType": [], - "ServiceName": "RBAC", + "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_webhook_config_access/rbac_minimize_webhook_config_access.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_minimize_webhook_config_access/rbac_minimize_webhook_config_access.metadata.json index f9ae0c2775..0276f7f0bd 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_webhook_config_access/rbac_minimize_webhook_config_access.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_webhook_config_access/rbac_minimize_webhook_config_access.metadata.json @@ -3,7 +3,7 @@ "CheckID": "rbac_minimize_webhook_config_access", "CheckTitle": "Minimize access to webhook configuration objects", "CheckType": [], - "ServiceName": "RBAC", + "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", diff --git a/prowler/providers/kubernetes/services/rbac/rbac_minimize_wildcard_use_roles/rbac_minimize_wildcard_use_roles.metadata.json b/prowler/providers/kubernetes/services/rbac/rbac_minimize_wildcard_use_roles/rbac_minimize_wildcard_use_roles.metadata.json index f067e74200..745d1ad82c 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_minimize_wildcard_use_roles/rbac_minimize_wildcard_use_roles.metadata.json +++ b/prowler/providers/kubernetes/services/rbac/rbac_minimize_wildcard_use_roles/rbac_minimize_wildcard_use_roles.metadata.json @@ -3,7 +3,7 @@ "CheckID": "rbac_minimize_wildcard_use_roles", "CheckTitle": "Minimize wildcard use in Roles and ClusterRoles", "CheckType": [], - "ServiceName": "RBAC", + "ServiceName": "rbac", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", diff --git a/prowler/providers/m365/lib/mutelist/mutelist.py b/prowler/providers/m365/lib/mutelist/mutelist.py index a7bf971f3e..44ea5ec7c5 100644 --- a/prowler/providers/m365/lib/mutelist/mutelist.py +++ b/prowler/providers/m365/lib/mutelist/mutelist.py @@ -7,9 +7,10 @@ class M365Mutelist(Mutelist): def is_finding_muted( self, finding: CheckReportM365, + tenant_id: str, ) -> bool: return self.is_muted( - finding.tenant_id, + tenant_id, finding.check_metadata.CheckID, finding.location, finding.resource_name, diff --git a/prowler/providers/m365/m365_provider.py b/prowler/providers/m365/m365_provider.py index 80c65dd3d5..9e550c07d7 100644 --- a/prowler/providers/m365/m365_provider.py +++ b/prowler/providers/m365/m365_provider.py @@ -443,6 +443,7 @@ class M365Provider(Provider): if credentials: if identity and credentials.user: identity.user = credentials.user + identity.identity_type = "Service Principal and User Credentials" test_session = M365PowerShell(credentials, identity) try: if init_modules: @@ -954,13 +955,20 @@ class M365Provider(Provider): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" ) # since that exception is not considered as critical, we keep filling another identity fields - identity.identity_id = ( - getenv("AZURE_CLIENT_ID") or "Unknown user id (Missing AAD permissions)" - ) if sp_env_auth: identity.identity_type = "Service Principal" + identity.identity_id = ( + getenv("AZURE_CLIENT_ID") + or session.credentials[0]._credential.client_id + or "Unknown user id (Missing AAD permissions)" + ) elif env_auth: identity.identity_type = "Service Principal and User Credentials" + identity.identity_id = ( + getenv("AZURE_CLIENT_ID") + or session.credentials[0]._credential.client_id + or "Unknown user id (Missing AAD permissions)" + ) elif browser_auth or az_cli_auth: identity.identity_type = "User" try: @@ -978,6 +986,10 @@ class M365Provider(Provider): logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" ) + else: + # Static Credentials + identity.identity_type = "Service Principal" + identity.identity_id = session._client_id # Retrieve tenant id from the client client = GraphServiceClient(credentials=session) diff --git a/prowler/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/__init__.py b/prowler/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time.metadata.json b/prowler/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time.metadata.json new file mode 100644 index 0000000000..0d15f1f8d7 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time.metadata.json @@ -0,0 +1,33 @@ +{ + "Provider": "m365", + "CheckID": "entra_intune_enrollment_sign_in_frequency_every_time", + "CheckTitle": "Ensure sign-in frequency for Intune Enrollment is set to every time", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Conditional Access Policy", + "Description": "Ensure that Conditional Access policies enforce sign-in frequency to Every time for Microsoft Intune Enrollment Application.", + "Risk": "If not enforced, attackers with compromised credentials may enroll a new device into Intune and gain persistent and elevated access through a bypass of compliance-based Conditional Access rules.", + "RelatedUrl": "https://learn.microsoft.com/en-us/intune/intune-service/fundamentals/deployment-guide-enrollment", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center https://entra.microsoft.com. 2. Click expand Protection > Conditional Access select Policies. 3. Create a new policy by selecting New policy. o Under Users include All users. o Under Target resources select Resources (formerly cloud apps), choose Select resources and add Microsoft Intune Enrollment to the list. o Under Grant select Grant access. o Check either Require multifactor authentication or Require authentication strength. o Under Session check Sign-in frequency and select Every time. 4. Under Enable policy set it to Report-only until the organization is ready to enable it. 5. Click Create", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure a Conditional Access policy that targets Microsoft Intune Enrollment and enforces sign-in frequency to 'Every time'. This ensures that users must reauthenticate for each Intune enrollment action, reducing the risk of unauthorized device enrollment using compromised credentials. Note: Microsoft accounts for a five-minute clock skew when 'every time' is selected, ensuring users are not prompted more frequently than once every five minutes.", + "Url": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-session#sign-in-frequency" + } + }, + "Categories": [ + "e3", + "e5" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time.py b/prowler/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time.py new file mode 100644 index 0000000000..8fc1ddbbc3 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time.py @@ -0,0 +1,70 @@ +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicyState, + SignInFrequencyInterval, +) + + +class entra_intune_enrollment_sign_in_frequency_every_time(Check): + """Ensure sign-in frequency for Intune Enrollment is set to 'Every time'.""" + + def execute(self) -> list[CheckReportM365]: + """Execute the check to ensure that sign-in frequency for Intune Enrollment is set to 'Every time'. + + Returns: + list[CheckReportM365]: A list containing the results of the check. + """ + findings = [] + + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = "No Conditional Access Policy enforces Every Time sign-in frequency for Intune Enrollment." + + for policy in entra_client.conditional_access_policies.values(): + if policy.state == ConditionalAccessPolicyState.DISABLED: + continue + + if ( + "d4ebce55-015a-49b5-a083-c84d1797ae8c" + not in policy.conditions.application_conditions.included_applications + ): + continue + + if ( + "d4ebce55-015a-49b5-a083-c84d1797ae8c" + in policy.conditions.application_conditions.excluded_applications + ): + continue + + if "All" not in policy.conditions.user_conditions.included_users: + continue + + if not policy.session_controls.sign_in_frequency.is_enabled: + continue + + if ( + policy.session_controls.sign_in_frequency.interval + == SignInFrequencyInterval.EVERY_TIME + ): + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + if policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: + report.status = "FAIL" + report.status_extended = f"Conditional Access Policy {policy.display_name} reports Every Time sign-in frequency for Intune Enrollment but does not enforce it." + else: + report.status = "PASS" + report.status_extended = f"Conditional Access Policy {policy.display_name} enforces Every Time sign-in frequency for Intune Enrollment." + break + + 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 6196f1e9cc..8345c6963e 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 @@ -28,9 +28,9 @@ class entra_users_mfa_capable(Check): for user in entra_client.users.values(): report = CheckReportM365( metadata=self.metadata(), - resource={}, - resource_name="Users", - resource_id="users", + resource=user, + resource_name=user.name, + resource_id=user.id, ) if not user.is_mfa_capable: diff --git a/pyproject.toml b/pyproject.toml index 0bc29b065c..48b066fc57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dependencies = [ "dash==3.1.1", "dash-bootstrap-components==2.0.3", "detect-secrets==1.5.0", + "dulwich==0.23.0", "google-api-python-client==2.163.0", "google-auth-httplib2>=0.1,<0.3", "jsonschema==4.23.0", @@ -70,7 +71,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"} name = "prowler" readme = "README.md" requires-python = ">3.9.1,<3.13" -version = "5.9.0" +version = "5.10.0" [project.scripts] prowler = "prowler.__main__:prowler" diff --git a/tests/config/config_test.py b/tests/config/config_test.py index 465d16a05a..ad08922c2e 100644 --- a/tests/config/config_test.py +++ b/tests/config/config_test.py @@ -321,6 +321,7 @@ config_azure = { "python_latest_version": "3.12", "java_latest_version": "17", "recommended_minimal_tls_versions": ["1.2", "1.3"], + "defender_attack_path_minimal_risk_level": "High", } config_gcp = {"shodan_api_key": None, "max_unused_account_days": 30} diff --git a/tests/config/fixtures/config.yaml b/tests/config/fixtures/config.yaml index b93d38fda6..fea3a4d7ed 100644 --- a/tests/config/fixtures/config.yaml +++ b/tests/config/fixtures/config.yaml @@ -376,6 +376,8 @@ azure: # azure.network_public_ip_shodan # TODO: create common config shodan_api_key: null + # Configurable minimal risk level for attack path notifications + defender_attack_path_minimal_risk_level: "High" # Azure App Service # azure.app_ensure_php_version_is_latest diff --git a/tests/lib/check/check_loader_test.py b/tests/lib/check/check_loader_test.py index 69ea5715f3..d61c7149d5 100644 --- a/tests/lib/check/check_loader_test.py +++ b/tests/lib/check/check_loader_test.py @@ -14,6 +14,11 @@ S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME_CUSTOM_ALIAS = ( S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_SEVERITY = "medium" S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME_SERVICE = "s3" +IAM_USER_NO_MFA_NAME = "iam_user_no_mfa" +IAM_USER_NO_MFA_NAME_CUSTOM_ALIAS = "iam_user_no_mfa" +IAM_USER_NO_MFA_NAME_SERVICE = "iam" +IAM_USER_NO_MFA_SEVERITY = "high" + CLOUDTRAIL_THREAT_DETECTION_ENUMERATION_NAME = "cloudtrail_threat_detection_enumeration" @@ -54,6 +59,40 @@ class TestCheckLoader: Compliance=[], ) + def get_custom_check_iam_metadata(self): + return CheckMetadata( + Provider="aws", + CheckID=IAM_USER_NO_MFA_NAME, + CheckTitle="Check IAM User No MFA.", + CheckType=["Data Protection"], + CheckAliases=[IAM_USER_NO_MFA_NAME_CUSTOM_ALIAS], + ServiceName=IAM_USER_NO_MFA_NAME_SERVICE, + SubServiceName="", + ResourceIdTemplate="arn:partition:iam::account-id:user/user_name", + Severity=IAM_USER_NO_MFA_SEVERITY, + ResourceType="AwsIamUser", + Description="Check IAM User No MFA.", + Risk="IAM users should have Multi-Factor Authentication (MFA) enabled.", + RelatedUrl="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html", + Remediation=Remediation( + Code=Code( + NativeIaC="", + Terraform="https://docs.prowler.com/checks/aws/iam-policies/bc_aws_iam_20#terraform", + CLI="aws iam create-virtual-mfa-device --user-name --serial-number ", + Other="https://github.com/cloudmatos/matos/tree/master/remediations/aws/iam/iam/enable-mfa", + ), + Recommendation=Recommendation( + Text="You can enable MFA for your IAM user to prevent unauthorized access to your AWS account.", + Url="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_enable_virtual.html", + ), + ), + Categories=[], + DependsOn=[], + RelatedTo=[], + Notes="", + Compliance=[], + ) + def get_threat_detection_check_metadata(self): return CheckMetadata( Provider="aws", @@ -130,6 +169,24 @@ class TestCheckLoader: provider=self.provider, ) + def test_load_checks_to_execute_with_severities_and_services_multiple(self): + bulk_checks_metatada = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata(), + IAM_USER_NO_MFA_NAME: self.get_custom_check_iam_metadata(), + } + service_list = ["s3", "iam"] + severities = ["medium", "high"] + + assert { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME, + IAM_USER_NO_MFA_NAME, + } == load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metatada, + service_list=service_list, + severities=severities, + provider=self.provider, + ) + def test_load_checks_to_execute_with_severities_and_services_not_within_severity( self, ): diff --git a/tests/lib/check/compliance_check_test.py b/tests/lib/check/compliance_check_test.py index b6789b3832..f7a58f04d0 100644 --- a/tests/lib/check/compliance_check_test.py +++ b/tests/lib/check/compliance_check_test.py @@ -193,7 +193,7 @@ class TestCompliance: CheckID="accessanalyzer_enabled", CheckTitle="Check 1", CheckType=["type1"], - ServiceName="service1", + ServiceName="accessanalyzer", SubServiceName="subservice1", ResourceIdTemplate="template1", Severity="high", @@ -221,7 +221,7 @@ class TestCompliance: CheckID="iam_user_mfa_enabled_console_access", CheckTitle="Check 2", CheckType=["type2"], - ServiceName="service2", + ServiceName="iam", SubServiceName="subservice2", ResourceIdTemplate="template2", Severity="medium", diff --git a/tests/lib/check/models_test.py b/tests/lib/check/models_test.py index 70be6795ca..83b294f171 100644 --- a/tests/lib/check/models_test.py +++ b/tests/lib/check/models_test.py @@ -8,7 +8,7 @@ mock_metadata = CheckMetadata( CheckID="accessanalyzer_enabled", CheckTitle="Check 1", CheckType=["type1"], - ServiceName="service1", + ServiceName="accessanalyzer", SubServiceName="subservice1", ResourceIdTemplate="template1", Severity="high", @@ -211,7 +211,7 @@ class TestCheckMetada: bulk_metadata = CheckMetadata.get_bulk(provider="aws") result = CheckMetadata.list( - bulk_checks_metadata=bulk_metadata, service="service1" + bulk_checks_metadata=bulk_metadata, service="accessanalyzer" ) # Assertions diff --git a/tests/lib/outputs/asff/asff_test.py b/tests/lib/outputs/asff/asff_test.py index 2da25bc3d7..d8ba65acf1 100644 --- a/tests/lib/outputs/asff/asff_test.py +++ b/tests/lib/outputs/asff/asff_test.py @@ -524,7 +524,7 @@ class TestASFF: expected_asff = [ { "SchemaVersion": "2018-10-08", - "Id": "prowler-test-check-id-123456789012-eu-west-1-1aa220687", + "Id": "prowler-service_test_check_id-123456789012-eu-west-1-1aa220687", "ProductArn": "arn:aws:securityhub:eu-west-1::product/prowler/prowler", "RecordState": "ACTIVE", "ProductFields": { @@ -532,14 +532,14 @@ class TestASFF: "ProviderVersion": prowler_version, "ProwlerResourceName": "test-arn", }, - "GeneratorId": "prowler-test-check-id", + "GeneratorId": "prowler-service_test_check_id", "AwsAccountId": "123456789012", "Types": ["test-type"], "FirstObservedAt": timestamp, "UpdatedAt": timestamp, "CreatedAt": timestamp, "Severity": {"Label": "HIGH"}, - "Title": "test-check-id", + "Title": "service_test_check_id", "Description": "This is a test", "Resources": [ { diff --git a/tests/lib/outputs/compliance/aws_well_architected/aws_well_architected_test.py b/tests/lib/outputs/compliance/aws_well_architected/aws_well_architected_test.py index b4a1856ac6..a23113d4d4 100644 --- a/tests/lib/outputs/compliance/aws_well_architected/aws_well_architected_test.py +++ b/tests/lib/outputs/compliance/aws_well_architected/aws_well_architected_test.py @@ -85,7 +85,7 @@ class TestAWSWellArchitected: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert output_data.Muted is False # Test manual check output_data_manual = output.data[1] @@ -171,5 +171,5 @@ class TestAWSWellArchitected: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_NAME;REQUIREMENTS_ATTRIBUTES_WELLARCHITECTEDQUESTIONID;REQUIREMENTS_ATTRIBUTES_WELLARCHITECTEDPRACTICEID;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;REQUIREMENTS_ATTRIBUTES_ASSESSMENTMETHOD;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_IMPLEMENTATIONGUIDANCEURL;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME\r\naws;Best Practices for AWS Well-Architected Framework Security Pillar. The focus of this framework is the security pillar of the AWS Well-Architected Framework. It provides guidance to help you apply best practices, current recommendations in the design, delivery, and maintenance of secure AWS workloads.;123456789012;eu-west-1;{datetime.now()};SEC01-BP01;Establish common guardrails and isolation between environments (such as production, development, and test) and workloads through a multi-account strategy. Account-level separation is strongly recommended, as it provides a strong isolation boundary for security, billing, and access.;SEC01-BP01 Separate workloads using accounts;securely-operate;sec_securely_operate_multi_accounts;Security foundations;AWS account management and separation;High;Automated;Establish common guardrails and isolation between environments (such as production, development, and test) and workloads through a multi-account strategy. Account-level separation is strongly recommended, as it provides a strong isolation boundary for security, billing, and access.;https://docs.aws.amazon.com/wellarchitected/latest/security-pillar/sec_securely_operate_multi_accounts.html#implementation-guidance.;PASS;;;test-check-id;False;\r\naws;Best Practices for AWS Well-Architected Framework Security Pillar. The focus of this framework is the security pillar of the AWS Well-Architected Framework. It provides guidance to help you apply best practices, current recommendations in the design, delivery, and maintenance of secure AWS workloads.;;;{datetime.now()};SEC01-BP02;Establish common guardrails and isolation between environments (such as production, development, and test) and workloads through a multi-account strategy. Account-level separation is strongly recommended, as it provides a strong isolation boundary for security, billing, and access.;SEC01-BP01 Separate workloads using accounts;securely-operate;sec_securely_operate_multi_accounts;Security foundations;AWS account management and separation;High;Automated;Establish common guardrails and isolation between environments (such as production, development, and test) and workloads through a multi-account strategy. Account-level separation is strongly recommended, as it provides a strong isolation boundary for security, billing, and access.;https://docs.aws.amazon.com/wellarchitected/latest/security-pillar/sec_securely_operate_multi_accounts.html#implementation-guidance.;MANUAL;Manual check;manual_check;manual;False;Manual check\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_NAME;REQUIREMENTS_ATTRIBUTES_WELLARCHITECTEDQUESTIONID;REQUIREMENTS_ATTRIBUTES_WELLARCHITECTEDPRACTICEID;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;REQUIREMENTS_ATTRIBUTES_ASSESSMENTMETHOD;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_IMPLEMENTATIONGUIDANCEURL;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME\r\naws;Best Practices for AWS Well-Architected Framework Security Pillar. The focus of this framework is the security pillar of the AWS Well-Architected Framework. It provides guidance to help you apply best practices, current recommendations in the design, delivery, and maintenance of secure AWS workloads.;123456789012;eu-west-1;{datetime.now()};SEC01-BP01;Establish common guardrails and isolation between environments (such as production, development, and test) and workloads through a multi-account strategy. Account-level separation is strongly recommended, as it provides a strong isolation boundary for security, billing, and access.;SEC01-BP01 Separate workloads using accounts;securely-operate;sec_securely_operate_multi_accounts;Security foundations;AWS account management and separation;High;Automated;Establish common guardrails and isolation between environments (such as production, development, and test) and workloads through a multi-account strategy. Account-level separation is strongly recommended, as it provides a strong isolation boundary for security, billing, and access.;https://docs.aws.amazon.com/wellarchitected/latest/security-pillar/sec_securely_operate_multi_accounts.html#implementation-guidance.;PASS;;;service_test_check_id;False;\r\naws;Best Practices for AWS Well-Architected Framework Security Pillar. The focus of this framework is the security pillar of the AWS Well-Architected Framework. It provides guidance to help you apply best practices, current recommendations in the design, delivery, and maintenance of secure AWS workloads.;;;{datetime.now()};SEC01-BP02;Establish common guardrails and isolation between environments (such as production, development, and test) and workloads through a multi-account strategy. Account-level separation is strongly recommended, as it provides a strong isolation boundary for security, billing, and access.;SEC01-BP01 Separate workloads using accounts;securely-operate;sec_securely_operate_multi_accounts;Security foundations;AWS account management and separation;High;Automated;Establish common guardrails and isolation between environments (such as production, development, and test) and workloads through a multi-account strategy. Account-level separation is strongly recommended, as it provides a strong isolation boundary for security, billing, and access.;https://docs.aws.amazon.com/wellarchitected/latest/security-pillar/sec_securely_operate_multi_accounts.html#implementation-guidance.;MANUAL;Manual check;manual_check;manual;False;Manual check\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/cis/cis_aws_test.py b/tests/lib/outputs/compliance/cis/cis_aws_test.py index 0951bd053d..5fb373054f 100644 --- a/tests/lib/outputs/compliance/cis/cis_aws_test.py +++ b/tests/lib/outputs/compliance/cis/cis_aws_test.py @@ -76,7 +76,7 @@ class TestAWSCIS: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert output_data.Muted is False # Test manual check output_data_manual = output.data[1] @@ -155,5 +155,5 @@ class TestAWSCIS: mock_file.seek(0) content = mock_file.read() - expected_csv = f'PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;REQUIREMENTS_ATTRIBUTES_REFERENCES;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;The CIS Benchmark for CIS Amazon Web Services Foundations Benchmark, v1.4.0, Level 1 and 2 provides prescriptive guidance for configuring security options for a subset of Amazon Web Services. It has an emphasis on foundational, testable, and architecture agnostic settings;123456789012;eu-west-1;{datetime.now()};2.1.3;Ensure MFA Delete is enabled on S3 buckets;2. Storage;2.1. Simple Storage Service (S3);Level 1;Automated;Once MFA Delete is enabled on your sensitive and classified S3 bucket it requires the user to have two forms of authentication.;Adding MFA delete to an S3 bucket, requires additional authentication when you change the version state of your bucket or you delete and object version adding another layer of security in the event your security credentials are compromised or unauthorized access is granted.;;"Perform the steps below to enable MFA delete on an S3 bucket.\n\nNote:\n-You cannot enable MFA Delete using the AWS Management Console. You must use the AWS CLI or API.\n-You must use your \'root\' account to enable MFA Delete on S3 buckets.\n\n**From Command line:**\n\n1. Run the s3api put-bucket-versioning command\n\n```\naws s3api put-bucket-versioning --profile my-root-profile --bucket Bucket_Name --versioning-configuration Status=Enabled,MFADelete=Enabled --mfa “arn:aws:iam::aws_account_id:mfa/root-account-mfa-device passcode”\n```";"Perform the steps below to confirm MFA delete is configured on an S3 Bucket\n\n**From Console:**\n\n1. Login to the S3 console at `https://console.aws.amazon.com/s3/`\n\n2. Click the `Check` box next to the Bucket name you want to confirm\n\n3. In the window under `Properties`\n\n4. Confirm that Versioning is `Enabled`\n\n5. Confirm that MFA Delete is `Enabled`\n\n**From Command Line:**\n\n1. Run the `get-bucket-versioning`\n```\naws s3api get-bucket-versioning --bucket my-bucket\n```\n\nOutput example:\n```\n \n Enabled\n Enabled \n\n```\n\nIf the Console or the CLI output does not show Versioning and MFA Delete `enabled` refer to the remediation below.";;;https://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html#MultiFactorAuthenticationDelete:https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMFADelete.html:https://aws.amazon.com/blogs/security/securing-access-to-aws-using-mfa-part-3/:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_lost-or-broken.html;PASS;;;;test-check-id;False\r\naws;The CIS Benchmark for CIS Amazon Web Services Foundations Benchmark, v1.4.0, Level 1 and 2 provides prescriptive guidance for configuring security options for a subset of Amazon Web Services. It has an emphasis on foundational, testable, and architecture agnostic settings;;;{datetime.now()};2.1.4;Ensure MFA Delete is enabled on S3 buckets;2. Storage;2.1. Simple Storage Service (S3);Level 1;Automated;Once MFA Delete is enabled on your sensitive and classified S3 bucket it requires the user to have two forms of authentication.;Adding MFA delete to an S3 bucket, requires additional authentication when you change the version state of your bucket or you delete and object version adding another layer of security in the event your security credentials are compromised or unauthorized access is granted.;;"Perform the steps below to enable MFA delete on an S3 bucket.\n\nNote:\n-You cannot enable MFA Delete using the AWS Management Console. You must use the AWS CLI or API.\n-You must use your \'root\' account to enable MFA Delete on S3 buckets.\n\n**From Command line:**\n\n1. Run the s3api put-bucket-versioning command\n\n```\naws s3api put-bucket-versioning --profile my-root-profile --bucket Bucket_Name --versioning-configuration Status=Enabled,MFADelete=Enabled --mfa “arn:aws:iam::aws_account_id:mfa/root-account-mfa-device passcode”\n```";"Perform the steps below to confirm MFA delete is configured on an S3 Bucket\n\n**From Console:**\n\n1. Login to the S3 console at `https://console.aws.amazon.com/s3/`\n\n2. Click the `Check` box next to the Bucket name you want to confirm\n\n3. In the window under `Properties`\n\n4. Confirm that Versioning is `Enabled`\n\n5. Confirm that MFA Delete is `Enabled`\n\n**From Command Line:**\n\n1. Run the `get-bucket-versioning`\n```\naws s3api get-bucket-versioning --bucket my-bucket\n```\n\nOutput example:\n```\n \n Enabled\n Enabled \n\n```\n\nIf the Console or the CLI output does not show Versioning and MFA Delete `enabled` refer to the remediation below.";;;https://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html#MultiFactorAuthenticationDelete:https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMFADelete.html:https://aws.amazon.com/blogs/security/securing-access-to-aws-using-mfa-part-3/:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_lost-or-broken.html;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n' + expected_csv = f'PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;REQUIREMENTS_ATTRIBUTES_REFERENCES;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;The CIS Benchmark for CIS Amazon Web Services Foundations Benchmark, v1.4.0, Level 1 and 2 provides prescriptive guidance for configuring security options for a subset of Amazon Web Services. It has an emphasis on foundational, testable, and architecture agnostic settings;123456789012;eu-west-1;{datetime.now()};2.1.3;Ensure MFA Delete is enabled on S3 buckets;2. Storage;2.1. Simple Storage Service (S3);Level 1;Automated;Once MFA Delete is enabled on your sensitive and classified S3 bucket it requires the user to have two forms of authentication.;Adding MFA delete to an S3 bucket, requires additional authentication when you change the version state of your bucket or you delete and object version adding another layer of security in the event your security credentials are compromised or unauthorized access is granted.;;"Perform the steps below to enable MFA delete on an S3 bucket.\n\nNote:\n-You cannot enable MFA Delete using the AWS Management Console. You must use the AWS CLI or API.\n-You must use your \'root\' account to enable MFA Delete on S3 buckets.\n\n**From Command line:**\n\n1. Run the s3api put-bucket-versioning command\n\n```\naws s3api put-bucket-versioning --profile my-root-profile --bucket Bucket_Name --versioning-configuration Status=Enabled,MFADelete=Enabled --mfa “arn:aws:iam::aws_account_id:mfa/root-account-mfa-device passcode”\n```";"Perform the steps below to confirm MFA delete is configured on an S3 Bucket\n\n**From Console:**\n\n1. Login to the S3 console at `https://console.aws.amazon.com/s3/`\n\n2. Click the `Check` box next to the Bucket name you want to confirm\n\n3. In the window under `Properties`\n\n4. Confirm that Versioning is `Enabled`\n\n5. Confirm that MFA Delete is `Enabled`\n\n**From Command Line:**\n\n1. Run the `get-bucket-versioning`\n```\naws s3api get-bucket-versioning --bucket my-bucket\n```\n\nOutput example:\n```\n \n Enabled\n Enabled \n\n```\n\nIf the Console or the CLI output does not show Versioning and MFA Delete `enabled` refer to the remediation below.";;;https://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html#MultiFactorAuthenticationDelete:https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMFADelete.html:https://aws.amazon.com/blogs/security/securing-access-to-aws-using-mfa-part-3/:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_lost-or-broken.html;PASS;;;;service_test_check_id;False\r\naws;The CIS Benchmark for CIS Amazon Web Services Foundations Benchmark, v1.4.0, Level 1 and 2 provides prescriptive guidance for configuring security options for a subset of Amazon Web Services. It has an emphasis on foundational, testable, and architecture agnostic settings;;;{datetime.now()};2.1.4;Ensure MFA Delete is enabled on S3 buckets;2. Storage;2.1. Simple Storage Service (S3);Level 1;Automated;Once MFA Delete is enabled on your sensitive and classified S3 bucket it requires the user to have two forms of authentication.;Adding MFA delete to an S3 bucket, requires additional authentication when you change the version state of your bucket or you delete and object version adding another layer of security in the event your security credentials are compromised or unauthorized access is granted.;;"Perform the steps below to enable MFA delete on an S3 bucket.\n\nNote:\n-You cannot enable MFA Delete using the AWS Management Console. You must use the AWS CLI or API.\n-You must use your \'root\' account to enable MFA Delete on S3 buckets.\n\n**From Command line:**\n\n1. Run the s3api put-bucket-versioning command\n\n```\naws s3api put-bucket-versioning --profile my-root-profile --bucket Bucket_Name --versioning-configuration Status=Enabled,MFADelete=Enabled --mfa “arn:aws:iam::aws_account_id:mfa/root-account-mfa-device passcode”\n```";"Perform the steps below to confirm MFA delete is configured on an S3 Bucket\n\n**From Console:**\n\n1. Login to the S3 console at `https://console.aws.amazon.com/s3/`\n\n2. Click the `Check` box next to the Bucket name you want to confirm\n\n3. In the window under `Properties`\n\n4. Confirm that Versioning is `Enabled`\n\n5. Confirm that MFA Delete is `Enabled`\n\n**From Command Line:**\n\n1. Run the `get-bucket-versioning`\n```\naws s3api get-bucket-versioning --bucket my-bucket\n```\n\nOutput example:\n```\n \n Enabled\n Enabled \n\n```\n\nIf the Console or the CLI output does not show Versioning and MFA Delete `enabled` refer to the remediation below.";;;https://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html#MultiFactorAuthenticationDelete:https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMFADelete.html:https://aws.amazon.com/blogs/security/securing-access-to-aws-using-mfa-part-3/:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_lost-or-broken.html;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n' assert content == expected_csv diff --git a/tests/lib/outputs/compliance/cis/cis_azure_test.py b/tests/lib/outputs/compliance/cis/cis_azure_test.py index 10489f1d29..3073ac631a 100644 --- a/tests/lib/outputs/compliance/cis/cis_azure_test.py +++ b/tests/lib/outputs/compliance/cis/cis_azure_test.py @@ -91,7 +91,7 @@ class TestAzureCIS: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert output_data.Muted is False # Test manual check output_data_manual = output.data[1] @@ -183,5 +183,5 @@ class TestAzureCIS: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;SUBSCRIPTIONID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;REQUIREMENTS_ATTRIBUTES_REFERENCES;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\nazure;The CIS Azure Foundations Benchmark provides prescriptive guidance for configuring security options for a subset of Azure with an emphasis on foundational, testable, and architecture agnostic settings.;{AZURE_SUBSCRIPTION_ID};;{datetime.now()};2.1.3;Ensure That Microsoft Defender for Databases Is Set To 'On';2. Defender;2.1 Microsoft Defender for Cloud;Level 2;Manual;Turning on Microsoft Defender for Databases enables threat detection for the instances running your database software. This provides threat intelligence, anomaly detection, and behavior analytics in the Azure Microsoft Defender for Cloud. Instead of being enabled on services like Platform as a Service (PaaS), this implementation will run within your instances as Infrastructure as a Service (IaaS) on the Operating Systems hosting your databases.;Enabling Microsoft Defender for Azure SQL Databases allows your organization more granular control of the infrastructure running your database software. Instead of waiting on Microsoft release updates or other similar processes, you can manage them yourself. Threat detection is provided by the Microsoft Security Response Center (MSRC).;Running Defender on Infrastructure as a service (IaaS) may incur increased costs associated with running the service and the instance it is on. Similarly, you will need qualified personnel to maintain the operating system and software updates. If it is not maintained, security patches will not be applied and it may be open to vulnerabilities.;From Azure Portal 1. Go to Microsoft Defender for Cloud 2. Select Environment Settings 3. Click on the subscription name 4. Select Defender plans 5. Set Databases Status to On 6. Select Save Review the chosen pricing tier. For the Azure Databases resource review the different plan information and choose one that fits the needs of your organization. From Azure CLI Run the following commands: az security pricing create -n 'SqlServers' --tier 'Standard' az security pricing create -n 'SqlServerVirtualMachines' --tier 'Standard' az security pricing create -n 'OpenSourceRelationalDatabases' --tier 'Standard' az security pricing create -n 'CosmosDbs' --tier 'Standard' From Azure PowerShell Run the following commands: Set-AzSecurityPricing -Name 'SqlServers' -PricingTier 'Standard' Set-AzSecurityPricing -Name 'SqlServerVirtualMachines' -PricingTier 'Standard' Set-AzSecurityPricing -Name 'OpenSourceRelationalDatabases' -PricingTier 'Standard' Set-AzSecurityPricing -Name 'CosmosDbs' -PricingTier 'Standard';From Azure Portal 1. Go to Microsoft Defender for Cloud 2. Select Environment Settings 3. Click on the subscription name 4. Select Defender plans 5. Ensure Databases Status is set to On 6. Review the chosen pricing tier From Azure CLI Ensure the output of the below commands is Standard az security pricing show -n 'SqlServers' az security pricing show -n 'SqlServerVirtualMachines' az security pricing show -n 'OpenSourceRelationalDatabases' az security pricing show -n 'CosmosDbs' If the output of any of the above commands shows pricingTier with a value of Free, the setting is out of compliance. From PowerShell Connect-AzAccount Get-AzSecurityPricing |select-object Name,PricingTier |where-object {{$_.Name -match 'Sql' -or $_.Name -match 'Cosmos' -or $_.Name -match 'OpenSource'}} Ensure the output shows Standard for each database type under the PricingTier column. Any that show Free are considered out of compliance.;;By default, Microsoft Defender plan is off.;https://docs.microsoft.com/en-us/azure/azure-sql/database/azure-defender-for-sql?view=azuresql:https://docs.microsoft.com/en-us/azure/defender-for-cloud/quickstart-enable-database-protections:https://docs.microsoft.com/en-us/azure/defender-for-cloud/defender-for-databases-usage:https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/list:https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-logging-threat-detection#lt-1-enable-threat-detection-capabilities;PASS;;;;test-check-id;False\r\nazure;The CIS Azure Foundations Benchmark provides prescriptive guidance for configuring security options for a subset of Azure with an emphasis on foundational, testable, and architecture agnostic settings.;;;{datetime.now()};2.1.4;Ensure That Microsoft Defender for Databases Is Set To 'On';2. Defender;2.1 Microsoft Defender for Cloud;Level 2;Manual;Turning on Microsoft Defender for Databases enables threat detection for the instances running your database software. This provides threat intelligence, anomaly detection, and behavior analytics in the Azure Microsoft Defender for Cloud. Instead of being enabled on services like Platform as a Service (PaaS), this implementation will run within your instances as Infrastructure as a Service (IaaS) on the Operating Systems hosting your databases.;Enabling Microsoft Defender for Azure SQL Databases allows your organization more granular control of the infrastructure running your database software. Instead of waiting on Microsoft release updates or other similar processes, you can manage them yourself. Threat detection is provided by the Microsoft Security Response Center (MSRC).;Running Defender on Infrastructure as a service (IaaS) may incur increased costs associated with running the service and the instance it is on. Similarly, you will need qualified personnel to maintain the operating system and software updates. If it is not maintained, security patches will not be applied and it may be open to vulnerabilities.;From Azure Portal 1. Go to Microsoft Defender for Cloud 2. Select Environment Settings 3. Click on the subscription name 4. Select Defender plans 5. Set Databases Status to On 6. Select Save Review the chosen pricing tier. For the Azure Databases resource review the different plan information and choose one that fits the needs of your organization. From Azure CLI Run the following commands: az security pricing create -n 'SqlServers' --tier 'Standard' az security pricing create -n 'SqlServerVirtualMachines' --tier 'Standard' az security pricing create -n 'OpenSourceRelationalDatabases' --tier 'Standard' az security pricing create -n 'CosmosDbs' --tier 'Standard' From Azure PowerShell Run the following commands: Set-AzSecurityPricing -Name 'SqlServers' -PricingTier 'Standard' Set-AzSecurityPricing -Name 'SqlServerVirtualMachines' -PricingTier 'Standard' Set-AzSecurityPricing -Name 'OpenSourceRelationalDatabases' -PricingTier 'Standard' Set-AzSecurityPricing -Name 'CosmosDbs' -PricingTier 'Standard';From Azure Portal 1. Go to Microsoft Defender for Cloud 2. Select Environment Settings 3. Click on the subscription name 4. Select Defender plans 5. Ensure Databases Status is set to On 6. Review the chosen pricing tier From Azure CLI Ensure the output of the below commands is Standard az security pricing show -n 'SqlServers' az security pricing show -n 'SqlServerVirtualMachines' az security pricing show -n 'OpenSourceRelationalDatabases' az security pricing show -n 'CosmosDbs' If the output of any of the above commands shows pricingTier with a value of Free, the setting is out of compliance. From PowerShell Connect-AzAccount Get-AzSecurityPricing |select-object Name,PricingTier |where-object {{$_.Name -match 'Sql' -or $_.Name -match 'Cosmos' -or $_.Name -match 'OpenSource'}} Ensure the output shows Standard for each database type under the PricingTier column. Any that show Free are considered out of compliance.;;By default, Microsoft Defender plan is off.;https://docs.microsoft.com/en-us/azure/azure-sql/database/azure-defender-for-sql?view=azuresql:https://docs.microsoft.com/en-us/azure/defender-for-cloud/quickstart-enable-database-protections:https://docs.microsoft.com/en-us/azure/defender-for-cloud/defender-for-databases-usage:https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/list:https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-logging-threat-detection#lt-1-enable-threat-detection-capabilities;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;SUBSCRIPTIONID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;REQUIREMENTS_ATTRIBUTES_REFERENCES;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\nazure;The CIS Azure Foundations Benchmark provides prescriptive guidance for configuring security options for a subset of Azure with an emphasis on foundational, testable, and architecture agnostic settings.;{AZURE_SUBSCRIPTION_ID};;{datetime.now()};2.1.3;Ensure That Microsoft Defender for Databases Is Set To 'On';2. Defender;2.1 Microsoft Defender for Cloud;Level 2;Manual;Turning on Microsoft Defender for Databases enables threat detection for the instances running your database software. This provides threat intelligence, anomaly detection, and behavior analytics in the Azure Microsoft Defender for Cloud. Instead of being enabled on services like Platform as a Service (PaaS), this implementation will run within your instances as Infrastructure as a Service (IaaS) on the Operating Systems hosting your databases.;Enabling Microsoft Defender for Azure SQL Databases allows your organization more granular control of the infrastructure running your database software. Instead of waiting on Microsoft release updates or other similar processes, you can manage them yourself. Threat detection is provided by the Microsoft Security Response Center (MSRC).;Running Defender on Infrastructure as a service (IaaS) may incur increased costs associated with running the service and the instance it is on. Similarly, you will need qualified personnel to maintain the operating system and software updates. If it is not maintained, security patches will not be applied and it may be open to vulnerabilities.;From Azure Portal 1. Go to Microsoft Defender for Cloud 2. Select Environment Settings 3. Click on the subscription name 4. Select Defender plans 5. Set Databases Status to On 6. Select Save Review the chosen pricing tier. For the Azure Databases resource review the different plan information and choose one that fits the needs of your organization. From Azure CLI Run the following commands: az security pricing create -n 'SqlServers' --tier 'Standard' az security pricing create -n 'SqlServerVirtualMachines' --tier 'Standard' az security pricing create -n 'OpenSourceRelationalDatabases' --tier 'Standard' az security pricing create -n 'CosmosDbs' --tier 'Standard' From Azure PowerShell Run the following commands: Set-AzSecurityPricing -Name 'SqlServers' -PricingTier 'Standard' Set-AzSecurityPricing -Name 'SqlServerVirtualMachines' -PricingTier 'Standard' Set-AzSecurityPricing -Name 'OpenSourceRelationalDatabases' -PricingTier 'Standard' Set-AzSecurityPricing -Name 'CosmosDbs' -PricingTier 'Standard';From Azure Portal 1. Go to Microsoft Defender for Cloud 2. Select Environment Settings 3. Click on the subscription name 4. Select Defender plans 5. Ensure Databases Status is set to On 6. Review the chosen pricing tier From Azure CLI Ensure the output of the below commands is Standard az security pricing show -n 'SqlServers' az security pricing show -n 'SqlServerVirtualMachines' az security pricing show -n 'OpenSourceRelationalDatabases' az security pricing show -n 'CosmosDbs' If the output of any of the above commands shows pricingTier with a value of Free, the setting is out of compliance. From PowerShell Connect-AzAccount Get-AzSecurityPricing |select-object Name,PricingTier |where-object {{$_.Name -match 'Sql' -or $_.Name -match 'Cosmos' -or $_.Name -match 'OpenSource'}} Ensure the output shows Standard for each database type under the PricingTier column. Any that show Free are considered out of compliance.;;By default, Microsoft Defender plan is off.;https://docs.microsoft.com/en-us/azure/azure-sql/database/azure-defender-for-sql?view=azuresql:https://docs.microsoft.com/en-us/azure/defender-for-cloud/quickstart-enable-database-protections:https://docs.microsoft.com/en-us/azure/defender-for-cloud/defender-for-databases-usage:https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/list:https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-logging-threat-detection#lt-1-enable-threat-detection-capabilities;PASS;;;;service_test_check_id;False\r\nazure;The CIS Azure Foundations Benchmark provides prescriptive guidance for configuring security options for a subset of Azure with an emphasis on foundational, testable, and architecture agnostic settings.;;;{datetime.now()};2.1.4;Ensure That Microsoft Defender for Databases Is Set To 'On';2. Defender;2.1 Microsoft Defender for Cloud;Level 2;Manual;Turning on Microsoft Defender for Databases enables threat detection for the instances running your database software. This provides threat intelligence, anomaly detection, and behavior analytics in the Azure Microsoft Defender for Cloud. Instead of being enabled on services like Platform as a Service (PaaS), this implementation will run within your instances as Infrastructure as a Service (IaaS) on the Operating Systems hosting your databases.;Enabling Microsoft Defender for Azure SQL Databases allows your organization more granular control of the infrastructure running your database software. Instead of waiting on Microsoft release updates or other similar processes, you can manage them yourself. Threat detection is provided by the Microsoft Security Response Center (MSRC).;Running Defender on Infrastructure as a service (IaaS) may incur increased costs associated with running the service and the instance it is on. Similarly, you will need qualified personnel to maintain the operating system and software updates. If it is not maintained, security patches will not be applied and it may be open to vulnerabilities.;From Azure Portal 1. Go to Microsoft Defender for Cloud 2. Select Environment Settings 3. Click on the subscription name 4. Select Defender plans 5. Set Databases Status to On 6. Select Save Review the chosen pricing tier. For the Azure Databases resource review the different plan information and choose one that fits the needs of your organization. From Azure CLI Run the following commands: az security pricing create -n 'SqlServers' --tier 'Standard' az security pricing create -n 'SqlServerVirtualMachines' --tier 'Standard' az security pricing create -n 'OpenSourceRelationalDatabases' --tier 'Standard' az security pricing create -n 'CosmosDbs' --tier 'Standard' From Azure PowerShell Run the following commands: Set-AzSecurityPricing -Name 'SqlServers' -PricingTier 'Standard' Set-AzSecurityPricing -Name 'SqlServerVirtualMachines' -PricingTier 'Standard' Set-AzSecurityPricing -Name 'OpenSourceRelationalDatabases' -PricingTier 'Standard' Set-AzSecurityPricing -Name 'CosmosDbs' -PricingTier 'Standard';From Azure Portal 1. Go to Microsoft Defender for Cloud 2. Select Environment Settings 3. Click on the subscription name 4. Select Defender plans 5. Ensure Databases Status is set to On 6. Review the chosen pricing tier From Azure CLI Ensure the output of the below commands is Standard az security pricing show -n 'SqlServers' az security pricing show -n 'SqlServerVirtualMachines' az security pricing show -n 'OpenSourceRelationalDatabases' az security pricing show -n 'CosmosDbs' If the output of any of the above commands shows pricingTier with a value of Free, the setting is out of compliance. From PowerShell Connect-AzAccount Get-AzSecurityPricing |select-object Name,PricingTier |where-object {{$_.Name -match 'Sql' -or $_.Name -match 'Cosmos' -or $_.Name -match 'OpenSource'}} Ensure the output shows Standard for each database type under the PricingTier column. Any that show Free are considered out of compliance.;;By default, Microsoft Defender plan is off.;https://docs.microsoft.com/en-us/azure/azure-sql/database/azure-defender-for-sql?view=azuresql:https://docs.microsoft.com/en-us/azure/defender-for-cloud/quickstart-enable-database-protections:https://docs.microsoft.com/en-us/azure/defender-for-cloud/defender-for-databases-usage:https://docs.microsoft.com/en-us/azure/security-center/security-center-detection-capabilities:https://docs.microsoft.com/en-us/rest/api/securitycenter/pricings/list:https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-logging-threat-detection#lt-1-enable-threat-detection-capabilities;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/cis/cis_gcp_test.py b/tests/lib/outputs/compliance/cis/cis_gcp_test.py index 1cb07ee684..0bd3f249e6 100644 --- a/tests/lib/outputs/compliance/cis/cis_gcp_test.py +++ b/tests/lib/outputs/compliance/cis/cis_gcp_test.py @@ -84,7 +84,7 @@ class TestGCPCIS: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert output_data.Muted is False # Test manual check output_data_manual = output.data[1] @@ -175,5 +175,5 @@ class TestGCPCIS: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;PROJECTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_REFERENCES;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\ngcp;This CIS Benchmark is the product of a community consensus process and consists of secure configuration guidelines developed for Google Cloud Computing Platform;123456789012;;{datetime.now()};2.13;Ensure That Microsoft Defender for Databases Is Set To 'On';2. Logging;2.1. Logging and Monitoring;Level 1;Automated;GCP Cloud Asset Inventory is services that provides a historical view of GCP resources and IAM policies through a time-series database. The information recorded includes metadata on Google Cloud resources, metadata on policies set on Google Cloud projects or resources, and runtime information gathered within a Google Cloud resource.;The GCP resources and IAM policies captured by GCP Cloud Asset Inventory enables security analysis, resource change tracking, and compliance auditing. It is recommended GCP Cloud Asset Inventory be enabled for all GCP projects.;;**From Google Cloud Console** Enable the Cloud Asset API: 1. Go to `API & Services/Library` by visiting https://console.cloud.google.com/apis/library(https://console.cloud.google.com/apis/library) 2. Search for `Cloud Asset API` and select the result for _Cloud Asset API_ 3. Click the `ENABLE` button. **From Google Cloud CLI** Enable the Cloud Asset API: 1. Enable the Cloud Asset API through the services interface: ``` gcloud services enable cloudasset.googleapis.com ```;**From Google Cloud Console** Ensure that the Cloud Asset API is enabled: 1. Go to `API & Services/Library` by visiting https://console.cloud.google.com/apis/library(https://console.cloud.google.com/apis/library) 2. Search for `Cloud Asset API` and select the result for _Cloud Asset API_ 3. Ensure that `API Enabled` is displayed. **From Google Cloud CLI** Ensure that the Cloud Asset API is enabled: 1. Query enabled services: ``` gcloud services list --enabled --filter=name:cloudasset.googleapis.com ``` If the API is listed, then it is enabled. If the response is `Listed 0 items` the API is not enabled.;Additional info - Cloud Asset Inventory only keeps a five-week history of Google Cloud asset metadata. If a longer history is desired, automation to export the history to Cloud Storage or BigQuery should be evaluated.;https://cloud.google.com/asset-inventory/docs;PASS;;;;test-check-id;False\r\ngcp;This CIS Benchmark is the product of a community consensus process and consists of secure configuration guidelines developed for Google Cloud Computing Platform;;;{datetime.now()};2.14;Ensure That Microsoft Defender for Databases Is Set To 'On';2. Logging;;Level 1;Automated;GCP Cloud Asset Inventory is services that provides a historical view of GCP resources and IAM policies through a time-series database. The information recorded includes metadata on Google Cloud resources, metadata on policies set on Google Cloud projects or resources, and runtime information gathered within a Google Cloud resource.;The GCP resources and IAM policies captured by GCP Cloud Asset Inventory enables security analysis, resource change tracking, and compliance auditing. It is recommended GCP Cloud Asset Inventory be enabled for all GCP projects.;;**From Google Cloud Console** Enable the Cloud Asset API: 1. Go to `API & Services/Library` by visiting https://console.cloud.google.com/apis/library(https://console.cloud.google.com/apis/library) 2. Search for `Cloud Asset API` and select the result for _Cloud Asset API_ 3. Click the `ENABLE` button. **From Google Cloud CLI** Enable the Cloud Asset API: 1. Enable the Cloud Asset API through the services interface: ``` gcloud services enable cloudasset.googleapis.com ```;**From Google Cloud Console** Ensure that the Cloud Asset API is enabled: 1. Go to `API & Services/Library` by visiting https://console.cloud.google.com/apis/library(https://console.cloud.google.com/apis/library) 2. Search for `Cloud Asset API` and select the result for _Cloud Asset API_ 3. Ensure that `API Enabled` is displayed. **From Google Cloud CLI** Ensure that the Cloud Asset API is enabled: 1. Query enabled services: ``` gcloud services list --enabled --filter=name:cloudasset.googleapis.com ``` If the API is listed, then it is enabled. If the response is `Listed 0 items` the API is not enabled.;Additional info - Cloud Asset Inventory only keeps a five-week history of Google Cloud asset metadata. If a longer history is desired, automation to export the history to Cloud Storage or BigQuery should be evaluated.;https://cloud.google.com/asset-inventory/docs;MANUAL;Manual check;manual_check;Manual check;manual;False" + expected_csv = f"PROVIDER;DESCRIPTION;PROJECTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_REFERENCES;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\ngcp;This CIS Benchmark is the product of a community consensus process and consists of secure configuration guidelines developed for Google Cloud Computing Platform;123456789012;;{datetime.now()};2.13;Ensure That Microsoft Defender for Databases Is Set To 'On';2. Logging;2.1. Logging and Monitoring;Level 1;Automated;GCP Cloud Asset Inventory is services that provides a historical view of GCP resources and IAM policies through a time-series database. The information recorded includes metadata on Google Cloud resources, metadata on policies set on Google Cloud projects or resources, and runtime information gathered within a Google Cloud resource.;The GCP resources and IAM policies captured by GCP Cloud Asset Inventory enables security analysis, resource change tracking, and compliance auditing. It is recommended GCP Cloud Asset Inventory be enabled for all GCP projects.;;**From Google Cloud Console** Enable the Cloud Asset API: 1. Go to `API & Services/Library` by visiting https://console.cloud.google.com/apis/library(https://console.cloud.google.com/apis/library) 2. Search for `Cloud Asset API` and select the result for _Cloud Asset API_ 3. Click the `ENABLE` button. **From Google Cloud CLI** Enable the Cloud Asset API: 1. Enable the Cloud Asset API through the services interface: ``` gcloud services enable cloudasset.googleapis.com ```;**From Google Cloud Console** Ensure that the Cloud Asset API is enabled: 1. Go to `API & Services/Library` by visiting https://console.cloud.google.com/apis/library(https://console.cloud.google.com/apis/library) 2. Search for `Cloud Asset API` and select the result for _Cloud Asset API_ 3. Ensure that `API Enabled` is displayed. **From Google Cloud CLI** Ensure that the Cloud Asset API is enabled: 1. Query enabled services: ``` gcloud services list --enabled --filter=name:cloudasset.googleapis.com ``` If the API is listed, then it is enabled. If the response is `Listed 0 items` the API is not enabled.;Additional info - Cloud Asset Inventory only keeps a five-week history of Google Cloud asset metadata. If a longer history is desired, automation to export the history to Cloud Storage or BigQuery should be evaluated.;https://cloud.google.com/asset-inventory/docs;PASS;;;;service_test_check_id;False\r\ngcp;This CIS Benchmark is the product of a community consensus process and consists of secure configuration guidelines developed for Google Cloud Computing Platform;;;{datetime.now()};2.14;Ensure That Microsoft Defender for Databases Is Set To 'On';2. Logging;;Level 1;Automated;GCP Cloud Asset Inventory is services that provides a historical view of GCP resources and IAM policies through a time-series database. The information recorded includes metadata on Google Cloud resources, metadata on policies set on Google Cloud projects or resources, and runtime information gathered within a Google Cloud resource.;The GCP resources and IAM policies captured by GCP Cloud Asset Inventory enables security analysis, resource change tracking, and compliance auditing. It is recommended GCP Cloud Asset Inventory be enabled for all GCP projects.;;**From Google Cloud Console** Enable the Cloud Asset API: 1. Go to `API & Services/Library` by visiting https://console.cloud.google.com/apis/library(https://console.cloud.google.com/apis/library) 2. Search for `Cloud Asset API` and select the result for _Cloud Asset API_ 3. Click the `ENABLE` button. **From Google Cloud CLI** Enable the Cloud Asset API: 1. Enable the Cloud Asset API through the services interface: ``` gcloud services enable cloudasset.googleapis.com ```;**From Google Cloud Console** Ensure that the Cloud Asset API is enabled: 1. Go to `API & Services/Library` by visiting https://console.cloud.google.com/apis/library(https://console.cloud.google.com/apis/library) 2. Search for `Cloud Asset API` and select the result for _Cloud Asset API_ 3. Ensure that `API Enabled` is displayed. **From Google Cloud CLI** Ensure that the Cloud Asset API is enabled: 1. Query enabled services: ``` gcloud services list --enabled --filter=name:cloudasset.googleapis.com ``` If the API is listed, then it is enabled. If the response is `Listed 0 items` the API is not enabled.;Additional info - Cloud Asset Inventory only keeps a five-week history of Google Cloud asset metadata. If a longer history is desired, automation to export the history to Cloud Storage or BigQuery should be evaluated.;https://cloud.google.com/asset-inventory/docs;MANUAL;Manual check;manual_check;Manual check;manual;False" assert expected_csv in content diff --git a/tests/lib/outputs/compliance/cis/cis_kubernetes_test.py b/tests/lib/outputs/compliance/cis/cis_kubernetes_test.py index 0873f2beeb..2528fefdf0 100644 --- a/tests/lib/outputs/compliance/cis/cis_kubernetes_test.py +++ b/tests/lib/outputs/compliance/cis/cis_kubernetes_test.py @@ -91,7 +91,7 @@ class TestKubernetesCIS: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert output_data.Muted is False # Test manual check output_data_manual = output.data[1] @@ -186,5 +186,5 @@ class TestKubernetesCIS: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;CONTEXT;NAMESPACE;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_REFERENCES;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\nkubernetes;This CIS Kubernetes Benchmark provides prescriptive guidance for establishing a secure configuration posture for Kubernetes v1.27.;test-cluster;test-namespace;{datetime.now()};1.1.3;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1. Control Plane;1.1 Control Plane Node Configuration Files;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;https://kubernetes.io/docs/admin/kube-apiserver/;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;PASS;;;;test-check-id;False\r\nkubernetes;This CIS Kubernetes Benchmark provides prescriptive guidance for establishing a secure configuration posture for Kubernetes v1.27.;;;{datetime.now()};1.1.4;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1.1 Control Plane Node Configuration Files;;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;https://kubernetes.io/docs/admin/kube-apiserver/;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;CONTEXT;NAMESPACE;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_REFERENCES;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\nkubernetes;This CIS Kubernetes Benchmark provides prescriptive guidance for establishing a secure configuration posture for Kubernetes v1.27.;test-cluster;test-namespace;{datetime.now()};1.1.3;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1. Control Plane;1.1 Control Plane Node Configuration Files;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;https://kubernetes.io/docs/admin/kube-apiserver/;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;PASS;;;;service_test_check_id;False\r\nkubernetes;This CIS Kubernetes Benchmark provides prescriptive guidance for establishing a secure configuration posture for Kubernetes v1.27.;;;{datetime.now()};1.1.4;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1.1 Control Plane Node Configuration Files;;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;https://kubernetes.io/docs/admin/kube-apiserver/;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/cis/cis_m365_test.py b/tests/lib/outputs/compliance/cis/cis_m365_test.py index 31b6fb0a1c..a0f7a033ff 100644 --- a/tests/lib/outputs/compliance/cis/cis_m365_test.py +++ b/tests/lib/outputs/compliance/cis/cis_m365_test.py @@ -88,7 +88,7 @@ class TestM365CIS: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert output_data.Muted is False # Test manual check output_data_manual = output.data[1] @@ -181,6 +181,6 @@ class TestM365CIS: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;TENANTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;REQUIREMENTS_ATTRIBUTES_REFERENCES;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\nm365;The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for configuring security options for Microsoft 365 with an emphasis on foundational, testable, and architecture agnostic settings.;00000000-0000-0000-0000-000000000000;global;{datetime.now()};2.1.3;Ensure MFA Delete is enabled on S3 buckets;2.1. Simple Storage Service (S3);;Level 1;Automated;Once MFA Delete is enabled on your sensitive and classified S3 bucket it requires the user to have two forms of authentication.;Adding MFA delete to an S3 bucket, requires additional authentication when you change the version state of your bucket or you delete and object version adding another layer of security in the event your security credentials are compromised or unauthorized access is granted.;;Perform the steps below to enable MFA delete on an S3 bucket.Note:-You cannot enable MFA Delete using the AWS Management Console. You must use the AWS CLI or API.-You must use your 'root' account to enable MFA Delete on S3 buckets.**From Command line:**1. Run the s3api put-bucket-versioning command aws s3api put-bucket-versioning --profile my-root-profile --bucket Bucket_Name --versioning-configuration Status=Enabled,MFADelete=Enabled --mfa arn:aws:iam::aws_account_id:mfa/root-account-mfa-device passcode;Perform the steps below to confirm MFA delete is configured on an S3 Bucket**From Console:**1. Login to the S3 console at `https://console.aws.amazon.com/s3/`2. Click the `Check` box next to the Bucket name you want to confirm3. In the window under `Properties`4. Confirm that Versioning is `Enabled`5. Confirm that MFA Delete is `Enabled`**From Command Line:**1. Run the `get-bucket-versioning aws s3api get-bucket-versioning --bucket my-bucket Output example: Enabled Enabled\ If the Console or the CLI output does not show Versioning and MFA Delete `enabled` refer to the remediation below.;;By default, MFA Delete is not enabled on S3 buckets.;https://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html#MultiFactorAuthenticationDelete:https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMFADelete.html:https://aws.amazon.com/blogs/security/securing-access-to-aws-using-mfa-part-3/:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_lost-or-broken.html;PASS;;;;test-check-id;False\r\nm365;The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for configuring security options for Microsoft 365 with an emphasis on foundational, testable, and architecture agnostic settings.;00000000-0000-0000-0000-000000000000;global;{datetime.now()};2.1.4;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1.1 Control Plane Node Configuration Files;;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;https://kubernetes.io/docs/admin/kube-apiserver/;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;TENANTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;REQUIREMENTS_ATTRIBUTES_REFERENCES;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\nm365;The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for configuring security options for Microsoft 365 with an emphasis on foundational, testable, and architecture agnostic settings.;00000000-0000-0000-0000-000000000000;global;{datetime.now()};2.1.3;Ensure MFA Delete is enabled on S3 buckets;2.1. Simple Storage Service (S3);;Level 1;Automated;Once MFA Delete is enabled on your sensitive and classified S3 bucket it requires the user to have two forms of authentication.;Adding MFA delete to an S3 bucket, requires additional authentication when you change the version state of your bucket or you delete and object version adding another layer of security in the event your security credentials are compromised or unauthorized access is granted.;;Perform the steps below to enable MFA delete on an S3 bucket.Note:-You cannot enable MFA Delete using the AWS Management Console. You must use the AWS CLI or API.-You must use your 'root' account to enable MFA Delete on S3 buckets.**From Command line:**1. Run the s3api put-bucket-versioning command aws s3api put-bucket-versioning --profile my-root-profile --bucket Bucket_Name --versioning-configuration Status=Enabled,MFADelete=Enabled --mfa arn:aws:iam::aws_account_id:mfa/root-account-mfa-device passcode;Perform the steps below to confirm MFA delete is configured on an S3 Bucket**From Console:**1. Login to the S3 console at `https://console.aws.amazon.com/s3/`2. Click the `Check` box next to the Bucket name you want to confirm3. In the window under `Properties`4. Confirm that Versioning is `Enabled`5. Confirm that MFA Delete is `Enabled`**From Command Line:**1. Run the `get-bucket-versioning aws s3api get-bucket-versioning --bucket my-bucket Output example: Enabled Enabled\ If the Console or the CLI output does not show Versioning and MFA Delete `enabled` refer to the remediation below.;;By default, MFA Delete is not enabled on S3 buckets.;https://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html#MultiFactorAuthenticationDelete:https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMFADelete.html:https://aws.amazon.com/blogs/security/securing-access-to-aws-using-mfa-part-3/:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_lost-or-broken.html;PASS;;;;service_test_check_id;False\r\nm365;The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for configuring security options for Microsoft 365 with an emphasis on foundational, testable, and architecture agnostic settings.;00000000-0000-0000-0000-000000000000;global;{datetime.now()};2.1.4;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1.1 Control Plane Node Configuration Files;;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;https://kubernetes.io/docs/admin/kube-apiserver/;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/ens/ens_aws_test.py b/tests/lib/outputs/compliance/ens/ens_aws_test.py index 0e7ac4e88e..1a2fbe1c46 100644 --- a/tests/lib/outputs/compliance/ens/ens_aws_test.py +++ b/tests/lib/outputs/compliance/ens/ens_aws_test.py @@ -66,7 +66,7 @@ class TestAWSENS: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert output_data.Muted is False # Test manual check output_data_manual = output.data[1] @@ -133,5 +133,5 @@ class TestAWSENS: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_IDGRUPOCONTROL;REQUIREMENTS_ATTRIBUTES_MARCO;REQUIREMENTS_ATTRIBUTES_CATEGORIA;REQUIREMENTS_ATTRIBUTES_DESCRIPCIONCONTROL;REQUIREMENTS_ATTRIBUTES_NIVEL;REQUIREMENTS_ATTRIBUTES_TIPO;REQUIREMENTS_ATTRIBUTES_DIMENSIONES;REQUIREMENTS_ATTRIBUTES_MODOEJECUCION;REQUIREMENTS_ATTRIBUTES_DEPENDENCIAS;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME\r\naws;The accreditation scheme of the ENS (National Security Scheme) has been developed by the Ministry of Finance and Public Administrations and the CCN (National Cryptological Center). This includes the basic principles and minimum requirements necessary for the adequate protection of information.;123456789012;eu-west-1;{datetime.now()};op.exp.8.aws.ct.3;Registro de actividad;op.exp.8;operacional;explotación;Habilitar la validación de archivos en todos los trails, evitando así que estos se vean modificados o eliminados.;alto;requisito;trazabilidad;automático;;PASS;;;test-check-id;False;\r\naws;The accreditation scheme of the ENS (National Security Scheme) has been developed by the Ministry of Finance and Public Administrations and the CCN (National Cryptological Center). This includes the basic principles and minimum requirements necessary for the adequate protection of information.;;;{datetime.now()};op.exp.8.aws.ct.4;Registro de actividad;op.exp.8;operacional;explotación;Habilitar la validación de archivos en todos los trails, evitando así que estos se vean modificados o eliminados.;alto;requisito;trazabilidad;automático;;MANUAL;Manual check;manual_check;manual;False;Manual check\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_IDGRUPOCONTROL;REQUIREMENTS_ATTRIBUTES_MARCO;REQUIREMENTS_ATTRIBUTES_CATEGORIA;REQUIREMENTS_ATTRIBUTES_DESCRIPCIONCONTROL;REQUIREMENTS_ATTRIBUTES_NIVEL;REQUIREMENTS_ATTRIBUTES_TIPO;REQUIREMENTS_ATTRIBUTES_DIMENSIONES;REQUIREMENTS_ATTRIBUTES_MODOEJECUCION;REQUIREMENTS_ATTRIBUTES_DEPENDENCIAS;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME\r\naws;The accreditation scheme of the ENS (National Security Scheme) has been developed by the Ministry of Finance and Public Administrations and the CCN (National Cryptological Center). This includes the basic principles and minimum requirements necessary for the adequate protection of information.;123456789012;eu-west-1;{datetime.now()};op.exp.8.aws.ct.3;Registro de actividad;op.exp.8;operacional;explotación;Habilitar la validación de archivos en todos los trails, evitando así que estos se vean modificados o eliminados.;alto;requisito;trazabilidad;automático;;PASS;;;service_test_check_id;False;\r\naws;The accreditation scheme of the ENS (National Security Scheme) has been developed by the Ministry of Finance and Public Administrations and the CCN (National Cryptological Center). This includes the basic principles and minimum requirements necessary for the adequate protection of information.;;;{datetime.now()};op.exp.8.aws.ct.4;Registro de actividad;op.exp.8;operacional;explotación;Habilitar la validación de archivos en todos los trails, evitando así que estos se vean modificados o eliminados.;alto;requisito;trazabilidad;automático;;MANUAL;Manual check;manual_check;manual;False;Manual check\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/ens/ens_azure_test.py b/tests/lib/outputs/compliance/ens/ens_azure_test.py index 7b138758cd..de54182b31 100644 --- a/tests/lib/outputs/compliance/ens/ens_azure_test.py +++ b/tests/lib/outputs/compliance/ens/ens_azure_test.py @@ -69,7 +69,7 @@ class TestAzureENS: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert output_data.Muted is False # Test manual check output_data_manual = output.data[1] @@ -140,5 +140,5 @@ class TestAzureENS: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;SUBSCRIPTIONID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_IDGRUPOCONTROL;REQUIREMENTS_ATTRIBUTES_MARCO;REQUIREMENTS_ATTRIBUTES_CATEGORIA;REQUIREMENTS_ATTRIBUTES_DESCRIPCIONCONTROL;REQUIREMENTS_ATTRIBUTES_NIVEL;REQUIREMENTS_ATTRIBUTES_TIPO;REQUIREMENTS_ATTRIBUTES_DIMENSIONES;REQUIREMENTS_ATTRIBUTES_MODOEJECUCION;REQUIREMENTS_ATTRIBUTES_DEPENDENCIAS;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME\r\nazure;The accreditation scheme of the ENS (National Security Scheme) has been developed by the Ministry of Finance and Public Administrations and the CCN (National Cryptological Center). This includes the basic principles and minimum requirements necessary for the adequate protection of information.;123456789012;global;{datetime.now()};op.exp.8.azure.ct.3;Registro de actividad;op.exp.8;operacional;explotación;Habilitar la validación de archivos en todos los trails, evitando así que estos se vean modificados o eliminados.;alto;requisito;trazabilidad;automático;;PASS;;;test-check-id;False;\r\nazure;The accreditation scheme of the ENS (National Security Scheme) has been developed by the Ministry of Finance and Public Administrations and the CCN (National Cryptological Center). This includes the basic principles and minimum requirements necessary for the adequate protection of information.;;;{datetime.now()};op.exp.8.azure.ct.4;Registro de actividad;op.exp.8;operacional;explotación;Habilitar la validación de archivos en todos los trails, evitando así que estos se vean modificados o eliminados.;alto;requisito;trazabilidad;automático;;MANUAL;Manual check;manual_check;manual;False;Manual check\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;SUBSCRIPTIONID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_IDGRUPOCONTROL;REQUIREMENTS_ATTRIBUTES_MARCO;REQUIREMENTS_ATTRIBUTES_CATEGORIA;REQUIREMENTS_ATTRIBUTES_DESCRIPCIONCONTROL;REQUIREMENTS_ATTRIBUTES_NIVEL;REQUIREMENTS_ATTRIBUTES_TIPO;REQUIREMENTS_ATTRIBUTES_DIMENSIONES;REQUIREMENTS_ATTRIBUTES_MODOEJECUCION;REQUIREMENTS_ATTRIBUTES_DEPENDENCIAS;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME\r\nazure;The accreditation scheme of the ENS (National Security Scheme) has been developed by the Ministry of Finance and Public Administrations and the CCN (National Cryptological Center). This includes the basic principles and minimum requirements necessary for the adequate protection of information.;123456789012;global;{datetime.now()};op.exp.8.azure.ct.3;Registro de actividad;op.exp.8;operacional;explotación;Habilitar la validación de archivos en todos los trails, evitando así que estos se vean modificados o eliminados.;alto;requisito;trazabilidad;automático;;PASS;;;service_test_check_id;False;\r\nazure;The accreditation scheme of the ENS (National Security Scheme) has been developed by the Ministry of Finance and Public Administrations and the CCN (National Cryptological Center). This includes the basic principles and minimum requirements necessary for the adequate protection of information.;;;{datetime.now()};op.exp.8.azure.ct.4;Registro de actividad;op.exp.8;operacional;explotación;Habilitar la validación de archivos en todos los trails, evitando así que estos se vean modificados o eliminados.;alto;requisito;trazabilidad;automático;;MANUAL;Manual check;manual_check;manual;False;Manual check\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/ens/ens_gcp_test.py b/tests/lib/outputs/compliance/ens/ens_gcp_test.py index 8082553738..14d1537f74 100644 --- a/tests/lib/outputs/compliance/ens/ens_gcp_test.py +++ b/tests/lib/outputs/compliance/ens/ens_gcp_test.py @@ -69,7 +69,7 @@ class TestGCPENS: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert output_data.Muted is False # Test manual check output_data_manual = output.data[1] @@ -140,5 +140,5 @@ class TestGCPENS: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;PROJECTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_IDGRUPOCONTROL;REQUIREMENTS_ATTRIBUTES_MARCO;REQUIREMENTS_ATTRIBUTES_CATEGORIA;REQUIREMENTS_ATTRIBUTES_DESCRIPCIONCONTROL;REQUIREMENTS_ATTRIBUTES_NIVEL;REQUIREMENTS_ATTRIBUTES_TIPO;REQUIREMENTS_ATTRIBUTES_DIMENSIONES;REQUIREMENTS_ATTRIBUTES_MODOEJECUCION;REQUIREMENTS_ATTRIBUTES_DEPENDENCIAS;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME\r\ngcp;The accreditation scheme of the ENS (National Security Scheme) has been developed by the Ministry of Finance and Public Administrations and the CCN (National Cryptological Center). This includes the basic principles and minimum requirements necessary for the adequate protection of information.;123456789012;global;{datetime.now()};op.exp.8.gcp.ct.3;Registro de actividad;op.exp.8;operacional;explotación;Habilitar la validación de archivos en todos los trails, evitando así que estos se vean modificados o eliminados.;alto;requisito;trazabilidad;automático;;PASS;;;test-check-id;False;\r\ngcp;The accreditation scheme of the ENS (National Security Scheme) has been developed by the Ministry of Finance and Public Administrations and the CCN (National Cryptological Center). This includes the basic principles and minimum requirements necessary for the adequate protection of information.;;;{datetime.now()};op.exp.8.gcp.ct.4;Registro de actividad;op.exp.8;operacional;explotación;Habilitar la validación de archivos en todos los trails, evitando así que estos se vean modificados o eliminados.;alto;requisito;trazabilidad;automático;;MANUAL;Manual check;manual_check;manual;False;Manual check\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;PROJECTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_IDGRUPOCONTROL;REQUIREMENTS_ATTRIBUTES_MARCO;REQUIREMENTS_ATTRIBUTES_CATEGORIA;REQUIREMENTS_ATTRIBUTES_DESCRIPCIONCONTROL;REQUIREMENTS_ATTRIBUTES_NIVEL;REQUIREMENTS_ATTRIBUTES_TIPO;REQUIREMENTS_ATTRIBUTES_DIMENSIONES;REQUIREMENTS_ATTRIBUTES_MODOEJECUCION;REQUIREMENTS_ATTRIBUTES_DEPENDENCIAS;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME\r\ngcp;The accreditation scheme of the ENS (National Security Scheme) has been developed by the Ministry of Finance and Public Administrations and the CCN (National Cryptological Center). This includes the basic principles and minimum requirements necessary for the adequate protection of information.;123456789012;global;{datetime.now()};op.exp.8.gcp.ct.3;Registro de actividad;op.exp.8;operacional;explotación;Habilitar la validación de archivos en todos los trails, evitando así que estos se vean modificados o eliminados.;alto;requisito;trazabilidad;automático;;PASS;;;service_test_check_id;False;\r\ngcp;The accreditation scheme of the ENS (National Security Scheme) has been developed by the Ministry of Finance and Public Administrations and the CCN (National Cryptological Center). This includes the basic principles and minimum requirements necessary for the adequate protection of information.;;;{datetime.now()};op.exp.8.gcp.ct.4;Registro de actividad;op.exp.8;operacional;explotación;Habilitar la validación de archivos en todos los trails, evitando así que estos se vean modificados o eliminados.;alto;requisito;trazabilidad;automático;;MANUAL;Manual check;manual_check;manual;False;Manual check\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/fixtures.py b/tests/lib/outputs/compliance/fixtures.py index 3dcf26f2c4..1c81ad9b78 100644 --- a/tests/lib/outputs/compliance/fixtures.py +++ b/tests/lib/outputs/compliance/fixtures.py @@ -24,7 +24,7 @@ CIS_1_4_AWS = Compliance( Description="The CIS Benchmark for CIS Amazon Web Services Foundations Benchmark, v1.4.0, Level 1 and 2 provides prescriptive guidance for configuring security options for a subset of Amazon Web Services. It has an emphasis on foundational, testable, and architecture agnostic settings", Requirements=[ Compliance_Requirement( - Checks=["test-check-id"], + Checks=["service_test_check_id"], Id="2.1.3", Description="Ensure MFA Delete is enabled on S3 buckets", Attributes=[ @@ -73,7 +73,7 @@ CIS_2_0_AZURE = Compliance( Description="The CIS Azure Foundations Benchmark provides prescriptive guidance for configuring security options for a subset of Azure with an emphasis on foundational, testable, and architecture agnostic settings.", Requirements=[ Compliance_Requirement( - Checks=["test-check-id"], + Checks=["service_test_check_id"], Id="2.1.3", Description="Ensure That Microsoft Defender for Databases Is Set To 'On'", Attributes=[ diff --git a/tests/lib/outputs/compliance/generic/generic_aws_test.py b/tests/lib/outputs/compliance/generic/generic_aws_test.py index c9c037e5ca..a05e45a434 100644 --- a/tests/lib/outputs/compliance/generic/generic_aws_test.py +++ b/tests/lib/outputs/compliance/generic/generic_aws_test.py @@ -56,7 +56,7 @@ class TestAWSGenericCompliance: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert output_data.Muted is False # Test manual check output_data_manual = output.data[1] @@ -117,5 +117,5 @@ class TestAWSGenericCompliance: mock_file.seek(0) content = mock_file.read() - 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\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;;;test-check-id;False;\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\r\n" + 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\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;\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\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/iso27001/iso27001_aws_test.py b/tests/lib/outputs/compliance/iso27001/iso27001_aws_test.py index 068ea69120..83029d0152 100644 --- a/tests/lib/outputs/compliance/iso27001/iso27001_aws_test.py +++ b/tests/lib/outputs/compliance/iso27001/iso27001_aws_test.py @@ -43,7 +43,7 @@ class TestAWSISO27001: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert output_data.Muted is False # Test manual check output_data_manual = output.data[1] @@ -90,5 +90,5 @@ class TestAWSISO27001: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_NAME;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_CATEGORY;REQUIREMENTS_ATTRIBUTES_OBJETIVE_ID;REQUIREMENTS_ATTRIBUTES_OBJETIVE_NAME;REQUIREMENTS_ATTRIBUTES_CHECK_SUMMARY;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME\r\naws;ISO (the International Organization for Standardization) and IEC (the International Electrotechnical Commission) form the specialized system for worldwide standardization. National bodies that are members of ISO or IEC participate in the development of International Standards through technical committees established by the respective organization to deal with particular fields of technical activity. ISO and IEC technical committees collaborate in fields of mutual interest. Other international organizations, governmental and non-governmental, in liaison with ISO and IEC, also take part in the work.;123456789012;eu-west-1;{datetime.now()};A.10.1;Cryptographic Controls;Setup Encryption at rest for RDS instances;A.10 Cryptography;A.10.1;Cryptographic Controls;Setup Encryption at rest for RDS instances;PASS;;;test-check-id;False;\r\naws;ISO (the International Organization for Standardization) and IEC (the International Electrotechnical Commission) form the specialized system for worldwide standardization. National bodies that are members of ISO or IEC participate in the development of International Standards through technical committees established by the respective organization to deal with particular fields of technical activity. ISO and IEC technical committees collaborate in fields of mutual interest. Other international organizations, governmental and non-governmental, in liaison with ISO and IEC, also take part in the work.;;;{datetime.now()};A.10.2;Cryptographic Controls;Setup Encryption at rest for RDS instances;A.10 Cryptography;A.10.1;Cryptographic Controls;Setup Encryption at rest for RDS instances;MANUAL;Manual check;manual_check;manual;False;Manual check\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_NAME;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_CATEGORY;REQUIREMENTS_ATTRIBUTES_OBJETIVE_ID;REQUIREMENTS_ATTRIBUTES_OBJETIVE_NAME;REQUIREMENTS_ATTRIBUTES_CHECK_SUMMARY;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME\r\naws;ISO (the International Organization for Standardization) and IEC (the International Electrotechnical Commission) form the specialized system for worldwide standardization. National bodies that are members of ISO or IEC participate in the development of International Standards through technical committees established by the respective organization to deal with particular fields of technical activity. ISO and IEC technical committees collaborate in fields of mutual interest. Other international organizations, governmental and non-governmental, in liaison with ISO and IEC, also take part in the work.;123456789012;eu-west-1;{datetime.now()};A.10.1;Cryptographic Controls;Setup Encryption at rest for RDS instances;A.10 Cryptography;A.10.1;Cryptographic Controls;Setup Encryption at rest for RDS instances;PASS;;;service_test_check_id;False;\r\naws;ISO (the International Organization for Standardization) and IEC (the International Electrotechnical Commission) form the specialized system for worldwide standardization. National bodies that are members of ISO or IEC participate in the development of International Standards through technical committees established by the respective organization to deal with particular fields of technical activity. ISO and IEC technical committees collaborate in fields of mutual interest. Other international organizations, governmental and non-governmental, in liaison with ISO and IEC, also take part in the work.;;;{datetime.now()};A.10.2;Cryptographic Controls;Setup Encryption at rest for RDS instances;A.10 Cryptography;A.10.1;Cryptographic Controls;Setup Encryption at rest for RDS instances;MANUAL;Manual check;manual_check;manual;False;Manual check\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws_test.py b/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws_test.py index 95fcb63998..f23ec6a5ec 100644 --- a/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws_test.py +++ b/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws_test.py @@ -60,7 +60,7 @@ class TestAWSKISAISMSP: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert output_data.Muted is False # Test manual check output_data_manual = output.data[1] @@ -123,5 +123,5 @@ class TestAWSKISAISMSP: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_NAME;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_DOMAIN;REQUIREMENTS_ATTRIBUTES_SUBDOMAIN;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_AUDITCHECKLIST;REQUIREMENTS_ATTRIBUTES_RELATEDREGULATIONS;REQUIREMENTS_ATTRIBUTES_AUDITEVIDENCE;REQUIREMENTS_ATTRIBUTES_NONCOMPLIANCECASES;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;The ISMS-P certification, established by KISA Korea Internet & Security Agency;123456789012;eu-west-1;{datetime.now()};2.5.3;User Authentication;User access to information systems;2. Protection Measure Requirements;2.5. Authentication and Authorization Management;2.5.3 User Authentication;['Is access to information systems and personal information controlled through secure authentication?', 'Are login attempt limitations enforced?'];['Personal Information Protection Act, Article 29', 'Standards for Ensuring the Safety of Personal Information, Article 5'];['Login screen for information systems', 'Login failure message screen'];['Case 1: Insufficient authentication when accessing information systems externally.', 'Case 2: No limitation on login failure attempts.'];PASS;;;;test-check-id;False\r\naws;The ISMS-P certification, established by KISA Korea Internet & Security Agency;;;{datetime.now()};2.5.4;User Authentication;User access to information systems;2. Protection Measure Requirements;2.5. Authentication and Authorization Management;2.5.3 User Authentication;['Is access to information systems and personal information controlled through secure authentication?', 'Are login attempt limitations enforced?'];['Personal Information Protection Act, Article 29', 'Standards for Ensuring the Safety of Personal Information, Article 5'];['Login screen for information systems', 'Login failure message screen'];['Case 1: Insufficient authentication when accessing information systems externally.', 'Case 2: No limitation on login failure attempts.'];MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_NAME;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_DOMAIN;REQUIREMENTS_ATTRIBUTES_SUBDOMAIN;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_AUDITCHECKLIST;REQUIREMENTS_ATTRIBUTES_RELATEDREGULATIONS;REQUIREMENTS_ATTRIBUTES_AUDITEVIDENCE;REQUIREMENTS_ATTRIBUTES_NONCOMPLIANCECASES;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;The ISMS-P certification, established by KISA Korea Internet & Security Agency;123456789012;eu-west-1;{datetime.now()};2.5.3;User Authentication;User access to information systems;2. Protection Measure Requirements;2.5. Authentication and Authorization Management;2.5.3 User Authentication;['Is access to information systems and personal information controlled through secure authentication?', 'Are login attempt limitations enforced?'];['Personal Information Protection Act, Article 29', 'Standards for Ensuring the Safety of Personal Information, Article 5'];['Login screen for information systems', 'Login failure message screen'];['Case 1: Insufficient authentication when accessing information systems externally.', 'Case 2: No limitation on login failure attempts.'];PASS;;;;service_test_check_id;False\r\naws;The ISMS-P certification, established by KISA Korea Internet & Security Agency;;;{datetime.now()};2.5.4;User Authentication;User access to information systems;2. Protection Measure Requirements;2.5. Authentication and Authorization Management;2.5.3 User Authentication;['Is access to information systems and personal information controlled through secure authentication?', 'Are login attempt limitations enforced?'];['Personal Information Protection Act, Article 29', 'Standards for Ensuring the Safety of Personal Information, Article 5'];['Login screen for information systems', 'Login failure message screen'];['Case 1: Insufficient authentication when accessing information systems externally.', 'Case 2: No limitation on login failure attempts.'];MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_aws_test.py b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_aws_test.py index 327643490c..17ce08f572 100644 --- a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_aws_test.py +++ b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_aws_test.py @@ -62,7 +62,7 @@ class TestAWSMITREAttack: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert not output_data.Muted # Test manual check output_data_manual = output.data[1] @@ -129,5 +129,5 @@ class TestAWSMITREAttack: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_NAME;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_TACTICS;REQUIREMENTS_SUBTECHNIQUES;REQUIREMENTS_PLATFORMS;REQUIREMENTS_TECHNIQUEURL;REQUIREMENTS_ATTRIBUTES_SERVICES;REQUIREMENTS_ATTRIBUTES_CATEGORIES;REQUIREMENTS_ATTRIBUTES_VALUES;REQUIREMENTS_ATTRIBUTES_COMMENTS;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME\r\naws;MITRE ATT&CK® is a globally-accessible knowledge base of adversary tactics and techniques based on real-world observations. The ATT&CK knowledge base is used as a foundation for the development of specific threat models and methodologies in the private sector, in government, and in the cybersecurity product and service community.;123456789012;eu-west-1;{datetime.now()};T1190;Exploit Public-Facing Application;Adversaries may attempt to exploit a weakness in an Internet-facing host or system to initially access a network. The weakness in the system can be a software bug, a temporary glitch, or a misconfiguration.;Initial Access;;Containers | IaaS | Linux | Network | Windows | macOS;https://attack.mitre.org/techniques/T1190/;AWS CloudEndure Disaster Recovery;Respond;Significant;AWS CloudEndure Disaster Recovery enables the replication and recovery of servers into AWS Cloud. In the event that a public-facing application or server is compromised, AWS CloudEndure can be used to provision an instance of the server from a previous point in time within minutes. As a result, this mapping is given a score of Significant.;PASS;;;test-check-id;False;\r\naws;MITRE ATT&CK® is a globally-accessible knowledge base of adversary tactics and techniques based on real-world observations. The ATT&CK knowledge base is used as a foundation for the development of specific threat models and methodologies in the private sector, in government, and in the cybersecurity product and service community.;;;{datetime.now()};T1193;Exploit Public-Facing Application;Adversaries may attempt to exploit a weakness in an Internet-facing host or system to initially access a network. The weakness in the system can be a software bug, a temporary glitch, or a misconfiguration.;Initial Access;;Containers | IaaS | Linux | Network | Windows | macOS;https://attack.mitre.org/techniques/T1190/;AWS CloudEndure Disaster Recovery;Respond;Significant;AWS CloudEndure Disaster Recovery enables the replication and recovery of servers into AWS Cloud. In the event that a public-facing application or server is compromised, AWS CloudEndure can be used to provision an instance of the server from a previous point in time within minutes. As a result, this mapping is given a score of Significant.;MANUAL;Manual check;manual_check;manual;False;Manual check\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_NAME;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_TACTICS;REQUIREMENTS_SUBTECHNIQUES;REQUIREMENTS_PLATFORMS;REQUIREMENTS_TECHNIQUEURL;REQUIREMENTS_ATTRIBUTES_SERVICES;REQUIREMENTS_ATTRIBUTES_CATEGORIES;REQUIREMENTS_ATTRIBUTES_VALUES;REQUIREMENTS_ATTRIBUTES_COMMENTS;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME\r\naws;MITRE ATT&CK® is a globally-accessible knowledge base of adversary tactics and techniques based on real-world observations. The ATT&CK knowledge base is used as a foundation for the development of specific threat models and methodologies in the private sector, in government, and in the cybersecurity product and service community.;123456789012;eu-west-1;{datetime.now()};T1190;Exploit Public-Facing Application;Adversaries may attempt to exploit a weakness in an Internet-facing host or system to initially access a network. The weakness in the system can be a software bug, a temporary glitch, or a misconfiguration.;Initial Access;;Containers | IaaS | Linux | Network | Windows | macOS;https://attack.mitre.org/techniques/T1190/;AWS CloudEndure Disaster Recovery;Respond;Significant;AWS CloudEndure Disaster Recovery enables the replication and recovery of servers into AWS Cloud. In the event that a public-facing application or server is compromised, AWS CloudEndure can be used to provision an instance of the server from a previous point in time within minutes. As a result, this mapping is given a score of Significant.;PASS;;;service_test_check_id;False;\r\naws;MITRE ATT&CK® is a globally-accessible knowledge base of adversary tactics and techniques based on real-world observations. The ATT&CK knowledge base is used as a foundation for the development of specific threat models and methodologies in the private sector, in government, and in the cybersecurity product and service community.;;;{datetime.now()};T1193;Exploit Public-Facing Application;Adversaries may attempt to exploit a weakness in an Internet-facing host or system to initially access a network. The weakness in the system can be a software bug, a temporary glitch, or a misconfiguration.;Initial Access;;Containers | IaaS | Linux | Network | Windows | macOS;https://attack.mitre.org/techniques/T1190/;AWS CloudEndure Disaster Recovery;Respond;Significant;AWS CloudEndure Disaster Recovery enables the replication and recovery of servers into AWS Cloud. In the event that a public-facing application or server is compromised, AWS CloudEndure can be used to provision an instance of the server from a previous point in time within minutes. As a result, this mapping is given a score of Significant.;MANUAL;Manual check;manual_check;manual;False;Manual check\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_azure_test.py b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_azure_test.py index fb9992382e..01c547641c 100644 --- a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_azure_test.py +++ b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_azure_test.py @@ -76,7 +76,7 @@ class TestAzureMITREAttack: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert not output_data.Muted # Test manual check output_data_manual = output.data[1] @@ -154,5 +154,5 @@ class TestAzureMITREAttack: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;SUBSCRIPTIONID;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_NAME;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_TACTICS;REQUIREMENTS_SUBTECHNIQUES;REQUIREMENTS_PLATFORMS;REQUIREMENTS_TECHNIQUEURL;REQUIREMENTS_ATTRIBUTES_SERVICES;REQUIREMENTS_ATTRIBUTES_CATEGORIES;REQUIREMENTS_ATTRIBUTES_VALUES;REQUIREMENTS_ATTRIBUTES_COMMENTS;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME;LOCATION\r\nazure;MITRE ATT&CK® is a globally-accessible knowledge base of adversary tactics and techniques based on real-world observations. The ATT&CK knowledge base is used as a foundation for the development of specific threat models and methodologies in the private sector, in government, and in the cybersecurity product and service community.;{AZURE_SUBSCRIPTION_ID};{datetime.now()};T1190;Exploit Public-Facing Application;Adversaries may attempt to exploit a weakness in an Internet-facing host or system to initially access a network. The weakness in the system can be a software bug, a temporary glitch, or a misconfiguration.;Initial Access;;Containers | IaaS | Linux | Network | Windows | macOS;https://attack.mitre.org/techniques/T1190/;Azure SQL Database;Detect;Minimal;This control may alert on usage of faulty SQL statements. This generates an alert for a possible SQL injection by an application. Alerts may not be generated on usage of valid SQL statements by attackers for malicious purposes.;PASS;;;test-check-id;False;;\r\nazure;MITRE ATT&CK® is a globally-accessible knowledge base of adversary tactics and techniques based on real-world observations. The ATT&CK knowledge base is used as a foundation for the development of specific threat models and methodologies in the private sector, in government, and in the cybersecurity product and service community.;;{datetime.now()};T1191;Exploit Public-Facing Application;Adversaries may attempt to exploit a weakness in an Internet-facing host or system to initially access a network. The weakness in the system can be a software bug, a temporary glitch, or a misconfiguration.;Initial Access;;Containers | IaaS | Linux | Network | Windows | macOS;https://attack.mitre.org/techniques/T1190/;Azure SQL Database;Detect;Minimal;This control may alert on usage of faulty SQL statements. This generates an alert for a possible SQL injection by an application. Alerts may not be generated on usage of valid SQL statements by attackers for malicious purposes.;MANUAL;Manual check;manual_check;manual;False;Manual check;\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;SUBSCRIPTIONID;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_NAME;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_TACTICS;REQUIREMENTS_SUBTECHNIQUES;REQUIREMENTS_PLATFORMS;REQUIREMENTS_TECHNIQUEURL;REQUIREMENTS_ATTRIBUTES_SERVICES;REQUIREMENTS_ATTRIBUTES_CATEGORIES;REQUIREMENTS_ATTRIBUTES_VALUES;REQUIREMENTS_ATTRIBUTES_COMMENTS;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME;LOCATION\r\nazure;MITRE ATT&CK® is a globally-accessible knowledge base of adversary tactics and techniques based on real-world observations. The ATT&CK knowledge base is used as a foundation for the development of specific threat models and methodologies in the private sector, in government, and in the cybersecurity product and service community.;{AZURE_SUBSCRIPTION_ID};{datetime.now()};T1190;Exploit Public-Facing Application;Adversaries may attempt to exploit a weakness in an Internet-facing host or system to initially access a network. The weakness in the system can be a software bug, a temporary glitch, or a misconfiguration.;Initial Access;;Containers | IaaS | Linux | Network | Windows | macOS;https://attack.mitre.org/techniques/T1190/;Azure SQL Database;Detect;Minimal;This control may alert on usage of faulty SQL statements. This generates an alert for a possible SQL injection by an application. Alerts may not be generated on usage of valid SQL statements by attackers for malicious purposes.;PASS;;;service_test_check_id;False;;\r\nazure;MITRE ATT&CK® is a globally-accessible knowledge base of adversary tactics and techniques based on real-world observations. The ATT&CK knowledge base is used as a foundation for the development of specific threat models and methodologies in the private sector, in government, and in the cybersecurity product and service community.;;{datetime.now()};T1191;Exploit Public-Facing Application;Adversaries may attempt to exploit a weakness in an Internet-facing host or system to initially access a network. The weakness in the system can be a software bug, a temporary glitch, or a misconfiguration.;Initial Access;;Containers | IaaS | Linux | Network | Windows | macOS;https://attack.mitre.org/techniques/T1190/;Azure SQL Database;Detect;Minimal;This control may alert on usage of faulty SQL statements. This generates an alert for a possible SQL injection by an application. Alerts may not be generated on usage of valid SQL statements by attackers for malicious purposes.;MANUAL;Manual check;manual_check;manual;False;Manual check;\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_gcp_test.py b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_gcp_test.py index 86b167262b..0742efc8c9 100644 --- a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_gcp_test.py +++ b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_gcp_test.py @@ -70,7 +70,7 @@ class TestGCPMITREAttack: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert not output_data.Muted # Test manual check output_data_manual = output.data[1] @@ -145,5 +145,5 @@ class TestGCPMITREAttack: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;PROJECTID;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_NAME;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_TACTICS;REQUIREMENTS_SUBTECHNIQUES;REQUIREMENTS_PLATFORMS;REQUIREMENTS_TECHNIQUEURL;REQUIREMENTS_ATTRIBUTES_SERVICES;REQUIREMENTS_ATTRIBUTES_CATEGORIES;REQUIREMENTS_ATTRIBUTES_VALUES;REQUIREMENTS_ATTRIBUTES_COMMENTS;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME;LOCATION\r\ngcp;MITRE ATT&CK® is a globally-accessible knowledge base of adversary tactics and techniques based on real-world observations. The ATT&CK knowledge base is used as a foundation for the development of specific threat models and methodologies in the private sector, in government, and in the cybersecurity product and service community.;123456789012;{datetime.now()};T1190;Exploit Public-Facing Application;Adversaries may attempt to exploit a weakness in an Internet-facing host or system to initially access a network. The weakness in the system can be a software bug, a temporary glitch, or a misconfiguration.;Initial Access;;Containers | IaaS | Linux | Network | Windows | macOS;https://attack.mitre.org/techniques/T1190/;Artifact Registry;Protect;Partial;Once this control is deployed, it can detect known vulnerabilities in various Linux OS packages. This information can be used to patch, isolate, or remove vulnerable software and machines. This control does not directly protect against exploitation and is not effective against zero day attacks, vulnerabilities with no available patch, and other end-of-life packages.;PASS;;;test-check-id;False;;\r\ngcp;MITRE ATT&CK® is a globally-accessible knowledge base of adversary tactics and techniques based on real-world observations. The ATT&CK knowledge base is used as a foundation for the development of specific threat models and methodologies in the private sector, in government, and in the cybersecurity product and service community.;;{datetime.now()};T1191;Exploit Public-Facing Application;Adversaries may attempt to exploit a weakness in an Internet-facing host or system to initially access a network. The weakness in the system can be a software bug, a temporary glitch, or a misconfiguration.;Initial Access;;Containers | IaaS | Linux | Network | Windows | macOS;https://attack.mitre.org/techniques/T1190/;Artifact Registry;Protect;Partial;Once this control is deployed, it can detect known vulnerabilities in various Linux OS packages. This information can be used to patch, isolate, or remove vulnerable software and machines. This control does not directly protect against exploitation and is not effective against zero day attacks, vulnerabilities with no available patch, and other end-of-life packages.;MANUAL;Manual check;manual_check;manual;False;Manual check;\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;PROJECTID;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_NAME;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_TACTICS;REQUIREMENTS_SUBTECHNIQUES;REQUIREMENTS_PLATFORMS;REQUIREMENTS_TECHNIQUEURL;REQUIREMENTS_ATTRIBUTES_SERVICES;REQUIREMENTS_ATTRIBUTES_CATEGORIES;REQUIREMENTS_ATTRIBUTES_VALUES;REQUIREMENTS_ATTRIBUTES_COMMENTS;STATUS;STATUSEXTENDED;RESOURCEID;CHECKID;MUTED;RESOURCENAME;LOCATION\r\ngcp;MITRE ATT&CK® is a globally-accessible knowledge base of adversary tactics and techniques based on real-world observations. The ATT&CK knowledge base is used as a foundation for the development of specific threat models and methodologies in the private sector, in government, and in the cybersecurity product and service community.;123456789012;{datetime.now()};T1190;Exploit Public-Facing Application;Adversaries may attempt to exploit a weakness in an Internet-facing host or system to initially access a network. The weakness in the system can be a software bug, a temporary glitch, or a misconfiguration.;Initial Access;;Containers | IaaS | Linux | Network | Windows | macOS;https://attack.mitre.org/techniques/T1190/;Artifact Registry;Protect;Partial;Once this control is deployed, it can detect known vulnerabilities in various Linux OS packages. This information can be used to patch, isolate, or remove vulnerable software and machines. This control does not directly protect against exploitation and is not effective against zero day attacks, vulnerabilities with no available patch, and other end-of-life packages.;PASS;;;service_test_check_id;False;;\r\ngcp;MITRE ATT&CK® is a globally-accessible knowledge base of adversary tactics and techniques based on real-world observations. The ATT&CK knowledge base is used as a foundation for the development of specific threat models and methodologies in the private sector, in government, and in the cybersecurity product and service community.;;{datetime.now()};T1191;Exploit Public-Facing Application;Adversaries may attempt to exploit a weakness in an Internet-facing host or system to initially access a network. The weakness in the system can be a software bug, a temporary glitch, or a misconfiguration.;Initial Access;;Containers | IaaS | Linux | Network | Windows | macOS;https://attack.mitre.org/techniques/T1190/;Artifact Registry;Protect;Partial;Once this control is deployed, it can detect known vulnerabilities in various Linux OS packages. This information can be used to patch, isolate, or remove vulnerable software and machines. This control does not directly protect against exploitation and is not effective against zero day attacks, vulnerabilities with no available patch, and other end-of-life packages.;MANUAL;Manual check;manual_check;manual;False;Manual check;\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws_test.py index b256d349b0..d9480d8bd1 100644 --- a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws_test.py +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws_test.py @@ -70,7 +70,7 @@ class TestProwlerThreatScoreAWS: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert not output_data.Muted # Test manual check output_data_manual = output.data[1] @@ -142,5 +142,5 @@ class TestProwlerThreatScoreAWS: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_TITLE;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_ATTRIBUTEDESCRIPTION;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;REQUIREMENTS_ATTRIBUTES_WEIGHT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;Prowler ThreatScore Compliance Framework for AWS ensures that the AWS account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;123456789012;eu-west-1;{datetime.now()};1.1.1;Ensure MFA is enabled for the 'root' user account;MFA enabled for 'root';1. IAM;1.1 Authentication;The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.;5;1000;PASS;;;;test-check-id;False\r\naws;Prowler ThreatScore Compliance Framework for AWS ensures that the AWS account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;;;{datetime.now()};1.1.2;Ensure hardware MFA is enabled for the 'root' user account;CloudTrail logging enabled;1. IAM;1.1 Authentication;The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.;3;10;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_TITLE;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_ATTRIBUTEDESCRIPTION;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;REQUIREMENTS_ATTRIBUTES_WEIGHT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;Prowler ThreatScore Compliance Framework for AWS ensures that the AWS account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;123456789012;eu-west-1;{datetime.now()};1.1.1;Ensure MFA is enabled for the 'root' user account;MFA enabled for 'root';1. IAM;1.1 Authentication;The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.;5;1000;PASS;;;;service_test_check_id;False\r\naws;Prowler ThreatScore Compliance Framework for AWS ensures that the AWS account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;;;{datetime.now()};1.1.2;Ensure hardware MFA is enabled for the 'root' user account;CloudTrail logging enabled;1. IAM;1.1 Authentication;The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.;3;10;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure_test.py index 6583b66007..813861db8c 100644 --- a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure_test.py +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure_test.py @@ -81,7 +81,7 @@ class TestProwlerThreatScoreAzure: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert not output_data.Muted # Test manual check output_data_manual = output.data[1] @@ -152,5 +152,5 @@ class TestProwlerThreatScoreAzure: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;SUBSCRIPTIONID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_TITLE;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_ATTRIBUTEDESCRIPTION;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;REQUIREMENTS_ATTRIBUTES_WEIGHT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;Prowler ThreatScore Compliance Framework for Azure ensures that the Azure account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;123456789012;eu-west-1;{datetime.now()};1.1.1;Ensure MFA is enabled for the 'root' user account;MFA enabled for 'root';1. IAM;1.1 Authentication;The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.;5;1000;PASS;;;;test-check-id;False\r\nazure;Prowler ThreatScore Compliance Framework for Azure ensures that the Azure account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;;;{datetime.now()};1.1.2;Ensure hardware MFA is enabled for the 'root' user account;CloudTrail logging enabled;1. IAM;1.1 Authentication;The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.;3;10;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;SUBSCRIPTIONID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_TITLE;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_ATTRIBUTEDESCRIPTION;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;REQUIREMENTS_ATTRIBUTES_WEIGHT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;Prowler ThreatScore Compliance Framework for Azure ensures that the Azure account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;123456789012;eu-west-1;{datetime.now()};1.1.1;Ensure MFA is enabled for the 'root' user account;MFA enabled for 'root';1. IAM;1.1 Authentication;The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.;5;1000;PASS;;;;service_test_check_id;False\r\nazure;Prowler ThreatScore Compliance Framework for Azure ensures that the Azure account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;;;{datetime.now()};1.1.2;Ensure hardware MFA is enabled for the 'root' user account;CloudTrail logging enabled;1. IAM;1.1 Authentication;The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.;3;10;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp_test.py index 878c8f8bf6..be31250fe6 100644 --- a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp_test.py +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp_test.py @@ -76,7 +76,7 @@ class TestProwlerThreatScoreGCP: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert not output_data.Muted # Test manual check output_data_manual = output.data[1] @@ -148,6 +148,6 @@ class TestProwlerThreatScoreGCP: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;PROJECTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_TITLE;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_ATTRIBUTEDESCRIPTION;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;REQUIREMENTS_ATTRIBUTES_WEIGHT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;Prowler ThreatScore Compliance Framework for GCP ensures that the GCP account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;123456789012;eu-west-1;{datetime.now()};1.1.1;Ensure MFA is enabled for the 'root' user account;MFA enabled for 'root';1. IAM;1.1 Authentication;The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.;5;1000;PASS;;;;test-check-id;False\r\ngcp;Prowler ThreatScore Compliance Framework for GCP ensures that the GCP account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;;;{datetime.now()};1.1.2;Ensure hardware MFA is enabled for the 'root' user account;CloudTrail logging enabled;1. IAM;1.1 Authentication;The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.;3;10;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;PROJECTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_TITLE;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_ATTRIBUTEDESCRIPTION;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;REQUIREMENTS_ATTRIBUTES_WEIGHT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;Prowler ThreatScore Compliance Framework for GCP ensures that the GCP account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;123456789012;eu-west-1;{datetime.now()};1.1.1;Ensure MFA is enabled for the 'root' user account;MFA enabled for 'root';1. IAM;1.1 Authentication;The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.;5;1000;PASS;;;;service_test_check_id;False\r\ngcp;Prowler ThreatScore Compliance Framework for GCP ensures that the GCP account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;;;{datetime.now()};1.1.2;Ensure hardware MFA is enabled for the 'root' user account;CloudTrail logging enabled;1. IAM;1.1 Authentication;The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.;3;10;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365_test.py index b8ade916d0..047604a3e8 100644 --- a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365_test.py +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365_test.py @@ -78,7 +78,7 @@ class TestProwlerThreatScoreM365: assert output_data.StatusExtended == "" assert output_data.ResourceId == "" assert output_data.ResourceName == "" - assert output_data.CheckId == "test-check-id" + assert output_data.CheckId == "service_test_check_id" assert not output_data.Muted # Test manual check output_data_manual = output.data[1] @@ -150,6 +150,6 @@ class TestProwlerThreatScoreM365: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;TENANTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_TITLE;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_ATTRIBUTEDESCRIPTION;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;REQUIREMENTS_ATTRIBUTES_WEIGHT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;Prowler ThreatScore Compliance Framework for M365 ensures that the M365 account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;123456789012;eu-west-1;{datetime.now()};1.1.1;Ensure MFA is enabled for the 'root' user account;MFA enabled for 'root';1. IAM;1.1 Authentication;The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.;5;1000;PASS;;;;test-check-id;False\r\nm365;Prowler ThreatScore Compliance Framework for M365 ensures that the M365 account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;;;{datetime.now()};1.1.2;Ensure hardware MFA is enabled for the 'root' user account;CloudTrail logging enabled;1. IAM;1.1 Authentication;The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.;3;10;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;TENANTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_TITLE;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_ATTRIBUTEDESCRIPTION;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;REQUIREMENTS_ATTRIBUTES_WEIGHT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;Prowler ThreatScore Compliance Framework for M365 ensures that the M365 account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;123456789012;eu-west-1;{datetime.now()};1.1.1;Ensure MFA is enabled for the 'root' user account;MFA enabled for 'root';1. IAM;1.1 Authentication;The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.;5;1000;PASS;;;;service_test_check_id;False\r\nm365;Prowler ThreatScore Compliance Framework for M365 ensures that the M365 account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;;;{datetime.now()};1.1.2;Ensure hardware MFA is enabled for the 'root' user account;CloudTrail logging enabled;1. IAM;1.1 Authentication;The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.;3;10;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/csv/csv_test.py b/tests/lib/outputs/csv/csv_test.py index 656060179d..4bd3652d61 100644 --- a/tests/lib/outputs/csv/csv_test.py +++ b/tests/lib/outputs/csv/csv_test.py @@ -59,15 +59,15 @@ class TestCSV: assert output_data["ACCOUNT_TAGS"] == "test-tag:test-value" assert output_data["FINDING_UID"] == "test-unique-finding" assert output_data["PROVIDER"] == "aws" - assert output_data["CHECK_ID"] == "test-check-id" - assert output_data["CHECK_TITLE"] == "test-check-id" + assert output_data["CHECK_ID"] == "service_test_check_id" + assert output_data["CHECK_TITLE"] == "service_test_check_id" assert output_data["CHECK_TYPE"] == "test-type" assert isinstance(output_data["STATUS"], str) assert output_data["STATUS"] == "PASS" assert output_data["STATUS_EXTENDED"] == "status-extended" assert isinstance(output_data["MUTED"], bool) assert output_data["MUTED"] is False - assert output_data["SERVICE_NAME"] == "test-service" + assert output_data["SERVICE_NAME"] == "service" assert output_data["SUBSERVICE_NAME"] == "" assert isinstance(output_data["SEVERITY"], str) assert output_data["SEVERITY"] == "high" @@ -113,7 +113,7 @@ class TestCSV: output.batch_write_data_to_file() mock_file.seek(0) - expected_csv = f"AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION\r\nprofile: default;{datetime.now()};123456789012;123456789012;;test-organization-id;test-organization;test-tag:test-value;test-unique-finding;aws;test-check-id;test-check-id;test-type;PASS;;False;test-service;;high;test-resource;;;;;aws;eu-west-1;check description;test-risk;test-url;;;;;;;test-compliance: test-compliance;test-category;test-dependency;test-related-to;test-notes;{prowler_version}\r\n" + expected_csv = f"AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION\r\nprofile: default;{datetime.now()};123456789012;123456789012;;test-organization-id;test-organization;test-tag:test-value;test-unique-finding;aws;service_test_check_id;service_test_check_id;test-type;PASS;;False;service;;high;test-resource;;;;;aws;eu-west-1;check description;test-risk;test-url;;;;;;;test-compliance: test-compliance;test-category;test-dependency;test-related-to;test-notes;{prowler_version}\r\n" content = mock_file.read() assert content == expected_csv @@ -191,7 +191,7 @@ class TestCSV: with patch.object(temp_file, "close", return_value=None): csv.batch_write_data_to_file() - expected_csv = f"AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION\nprofile: default;{datetime.now()};123456789012;123456789012;;test-organization-id;test-organization;test-tag:test-value;test-unique-finding;aws;test-check-id;test-check-id;test-type;PASS;;False;test-service;;high;test-resource;;;;;aws;eu-west-1;check description;test-risk;test-url;;;;;;;test-compliance: test-compliance;test-category;test-dependency;test-related-to;test-notes;{prowler_version}\n" + expected_csv = f"AUTH_METHOD;TIMESTAMP;ACCOUNT_UID;ACCOUNT_NAME;ACCOUNT_EMAIL;ACCOUNT_ORGANIZATION_UID;ACCOUNT_ORGANIZATION_NAME;ACCOUNT_TAGS;FINDING_UID;PROVIDER;CHECK_ID;CHECK_TITLE;CHECK_TYPE;STATUS;STATUS_EXTENDED;MUTED;SERVICE_NAME;SUBSERVICE_NAME;SEVERITY;RESOURCE_TYPE;RESOURCE_UID;RESOURCE_NAME;RESOURCE_DETAILS;RESOURCE_TAGS;PARTITION;REGION;DESCRIPTION;RISK;RELATED_URL;REMEDIATION_RECOMMENDATION_TEXT;REMEDIATION_RECOMMENDATION_URL;REMEDIATION_CODE_NATIVEIAC;REMEDIATION_CODE_TERRAFORM;REMEDIATION_CODE_CLI;REMEDIATION_CODE_OTHER;COMPLIANCE;CATEGORIES;DEPENDS_ON;RELATED_TO;NOTES;PROWLER_VERSION\nprofile: default;{datetime.now()};123456789012;123456789012;;test-organization-id;test-organization;test-tag:test-value;test-unique-finding;aws;service_test_check_id;service_test_check_id;test-type;PASS;;False;service;;high;test-resource;;;;;aws;eu-west-1;check description;test-risk;test-url;;;;;;;test-compliance: test-compliance;test-category;test-dependency;test-related-to;test-notes;{prowler_version}\n" temp_file.seek(0) diff --git a/tests/lib/outputs/finding_test.py b/tests/lib/outputs/finding_test.py index cdd0be701a..f086c493ee 100644 --- a/tests/lib/outputs/finding_test.py +++ b/tests/lib/outputs/finding_test.py @@ -20,11 +20,11 @@ from tests.lib.outputs.fixtures.fixtures import generate_finding_output def mock_check_metadata(provider): return CheckMetadata( Provider=provider, - CheckID="mock_check_id", + CheckID="service_check_id", CheckTitle="mock_check_title", CheckType=[], CheckAliases=[], - ServiceName="mock_service_name", + ServiceName="service", SubServiceName="", ResourceIdTemplate="", Severity="high", @@ -201,11 +201,11 @@ class TestFinding: # Metadata assert finding_output.metadata.Provider == "aws" - assert finding_output.metadata.CheckID == "mock_check_id" + assert finding_output.metadata.CheckID == "service_check_id" assert finding_output.metadata.CheckTitle == "mock_check_title" assert finding_output.metadata.CheckType == [] assert finding_output.metadata.CheckAliases == [] - assert finding_output.metadata.ServiceName == "mock_service_name" + assert finding_output.metadata.ServiceName == "service" assert finding_output.metadata.SubServiceName == "" assert finding_output.metadata.ResourceIdTemplate == "" assert finding_output.metadata.Severity == Severity.high @@ -227,11 +227,11 @@ class TestFinding: # Properties assert finding_output.provider == "aws" - assert finding_output.check_id == "mock_check_id" + assert finding_output.check_id == "service_check_id" assert finding_output.severity == Severity.high.value assert finding_output.status == Status.PASS.value assert finding_output.resource_type == "mock_resource_type" - assert finding_output.service_name == "mock_service_name" + assert finding_output.service_name == "service" assert finding_output.raw == {} def test_generate_output_azure(self): @@ -302,11 +302,11 @@ class TestFinding: # Metadata assert finding_output.metadata.Provider == "azure" - assert finding_output.metadata.CheckID == "mock_check_id" + assert finding_output.metadata.CheckID == "service_check_id" assert finding_output.metadata.CheckTitle == "mock_check_title" assert finding_output.metadata.CheckType == [] assert finding_output.metadata.CheckAliases == [] - assert finding_output.metadata.ServiceName == "mock_service_name" + assert finding_output.metadata.ServiceName == "service" assert finding_output.metadata.SubServiceName == "" assert finding_output.metadata.ResourceIdTemplate == "" assert finding_output.metadata.Severity == Severity.high @@ -397,11 +397,11 @@ class TestFinding: # Metadata assert finding_output.metadata.Provider == "gcp" - assert finding_output.metadata.CheckID == "mock_check_id" + assert finding_output.metadata.CheckID == "service_check_id" assert finding_output.metadata.CheckTitle == "mock_check_title" assert finding_output.metadata.CheckType == [] assert finding_output.metadata.CheckAliases == [] - assert finding_output.metadata.ServiceName == "mock_service_name" + assert finding_output.metadata.ServiceName == "service" assert finding_output.metadata.SubServiceName == "" assert finding_output.metadata.ResourceIdTemplate == "" assert finding_output.metadata.Severity == Severity.high @@ -482,11 +482,11 @@ class TestFinding: # Metadata assert finding_output.metadata.Provider == "kubernetes" - assert finding_output.metadata.CheckID == "mock_check_id" + assert finding_output.metadata.CheckID == "service_check_id" assert finding_output.metadata.CheckTitle == "mock_check_title" assert finding_output.metadata.CheckType == [] assert finding_output.metadata.CheckAliases == [] - assert finding_output.metadata.ServiceName == "mock_service_name" + assert finding_output.metadata.ServiceName == "service" assert finding_output.metadata.SubServiceName == "" assert finding_output.metadata.ResourceIdTemplate == "" assert finding_output.metadata.Severity == Severity.high @@ -506,6 +506,57 @@ class TestFinding: assert finding_output.metadata.Notes == "mock_notes" assert finding_output.metadata.Compliance == [] + def test_generate_output_iac_remote(self): + # Mock provider + provider = MagicMock() + provider.type = "iac" + provider.scan_repository_url = "https://github.com/user/repo" + provider.auth_method = "No auth" + + # Mock check result + check_output = MagicMock() + check_output.file_path = "/path/to/iac/file.tf" + check_output.resource_name = "aws_s3_bucket.example" + check_output.resource_path = "/path/to/iac/file.tf" + check_output.file_line_range = [1, 5] + check_output.resource = { + "resource": "aws_s3_bucket.example", + "value": {}, + } + check_output.resource_details = "test_resource_details" + check_output.status = Status.PASS + check_output.status_extended = "mock_status_extended" + check_output.muted = False + check_output.check_metadata = mock_check_metadata(provider="iac") + check_output.compliance = {} + + # Mock output options + output_options = MagicMock() + output_options.unix_timestamp = False + + # Generate the finding + finding_output = Finding.generate_output(provider, check_output, output_options) + + # Finding + assert isinstance(finding_output, Finding) + assert finding_output.auth_method == "No auth" + assert finding_output.resource_name == "aws_s3_bucket.example" + assert finding_output.resource_uid == "aws_s3_bucket.example" + assert finding_output.region == "/path/to/iac/file.tf" + assert finding_output.status == Status.PASS + assert finding_output.status_extended == "mock_status_extended" + assert finding_output.muted is False + + # Metadata + assert finding_output.metadata.Provider == "iac" + assert finding_output.metadata.CheckID == "service_check_id" + assert finding_output.metadata.CheckTitle == "mock_check_title" + assert finding_output.metadata.CheckType == [] + assert finding_output.metadata.CheckAliases == [] + assert finding_output.metadata.ServiceName == "service" + assert finding_output.metadata.SubServiceName == "" + assert finding_output.metadata.ResourceIdTemplate == "" + def assert_keys_lowercase(self, d): for k, v in d.items(): assert k.islower() @@ -599,10 +650,10 @@ class TestFinding: # Create a dummy check_metadata dict with all required fields check_metadata = { "provider": "test_provider", - "checkid": "check-001", + "checkid": "service_check_001", "checktitle": "Test Check", "checktype": ["type1"], - "servicename": "TestService", + "servicename": "service", "subservicename": "SubService", "severity": "high", "resourcetype": "TestResource", @@ -642,10 +693,10 @@ class TestFinding: # Check that metadata was built correctly meta = finding_obj.metadata assert meta.Provider == "test_provider" - assert meta.CheckID == "check-001" + assert meta.CheckID == "service_check_001" assert meta.CheckTitle == "Test Check" assert meta.CheckType == ["type1"] - assert meta.ServiceName == "TestService" + assert meta.ServiceName == "service" assert meta.SubServiceName == "SubService" assert meta.Severity == "high" assert meta.ResourceType == "TestResource" @@ -667,7 +718,7 @@ class TestFinding: # Check other Finding fields assert ( finding_obj.uid - == "prowler-aws-check-001-123456789012-us-east-1-ResourceName1" + == "prowler-aws-service_check_001-123456789012-us-east-1-ResourceName1" ) assert finding_obj.status == Status("FAIL") assert finding_obj.status_extended == "extended" @@ -838,10 +889,10 @@ class TestFinding: dummy_finding.status_extended = "GCP check extended" check_metadata = { "provider": "gcp", - "checkid": "gcp-check-001", + "checkid": "service_gcp_check_001", "checktitle": "Test GCP Check", "checktype": [], - "servicename": "TestGCPService", + "servicename": "service", "subservicename": "", "severity": "medium", "resourcetype": "GCPResourceType", @@ -918,10 +969,10 @@ class TestFinding: api_finding.status_extended = "K8s check extended" check_metadata = { "provider": "kubernetes", - "checkid": "k8s-check-001", + "checkid": "service_k8s_check_001", "checktitle": "Test K8s Check", "checktype": [], - "servicename": "TestK8sService", + "servicename": "service", "subservicename": "", "severity": "low", "resourcetype": "K8sResourceType", @@ -984,10 +1035,10 @@ class TestFinding: dummy_finding.status_extended = "M365 check extended" check_metadata = { "provider": "m365", - "checkid": "m365-check-001", + "checkid": "service_m365_check_001", "checktitle": "Test M365 Check", "checktype": [], - "servicename": "TestM365Service", + "servicename": "service", "subservicename": "", "severity": "high", "resourcetype": "M365ResourceType", diff --git a/tests/lib/outputs/fixtures/fixtures.py b/tests/lib/outputs/fixtures/fixtures.py index b29bd81fe3..ed4abec39a 100644 --- a/tests/lib/outputs/fixtures/fixtures.py +++ b/tests/lib/outputs/fixtures/fixtures.py @@ -36,9 +36,9 @@ def generate_finding_output( depends_on: list[str] = ["test-dependency"], related_to: list[str] = ["test-related-to"], notes: str = "test-notes", - service_name: str = "test-service", - check_id: str = "test-check-id", - check_title: str = "test-check-id", + service_name: str = "service", + check_id: str = "service_test_check_id", + check_title: str = "service_test_check_id", check_type: list[str] = ["test-type"], ) -> Finding: return Finding( diff --git a/tests/lib/outputs/html/html_test.py b/tests/lib/outputs/html/html_test.py index 9ee634dc0f..bce4a82154 100644 --- a/tests/lib/outputs/html/html_test.py +++ b/tests/lib/outputs/html/html_test.py @@ -26,10 +26,10 @@ pass_html_finding = """ PASS high - test-service + service eu-west-1 - test-check-id - test-check-id + service_test_check_id + service_test_check_id @@ -44,10 +44,10 @@ fail_html_finding = """ FAIL high - test-service + service eu-west-1 - test-check-id - test-check-id + service_test_check_id + service_test_check_id test-resource-uid •key1=value1 @@ -66,10 +66,10 @@ muted_html_finding = """ MUTED (PASS) high - test-service + service eu-west-1 - test-check-id - test-check-id + service_test_check_id + service_test_check_id @@ -84,10 +84,10 @@ manual_html_finding = """ MANUAL high - test-service + service eu-west-1 - test-check-id - test-check-id + service_test_check_id + service_test_check_id @@ -470,10 +470,10 @@ class TestHTML: status="FAIL", resource_tags={"key1": "value1", "key2": "value2"}, severity="high", - service_name="test-service", + service_name="service", region=AWS_REGION_EU_WEST_1, - check_id="test-check-id", - check_title="test-check-id", + check_id="service_test_check_id", + check_title="service_test_check_id", resource_uid="test-resource-uid", status_extended="test-status-extended", risk="test-risk", diff --git a/tests/lib/outputs/ocsf/ocsf_test.py b/tests/lib/outputs/ocsf/ocsf_test.py index bf48938471..2634754b86 100644 --- a/tests/lib/outputs/ocsf/ocsf_test.py +++ b/tests/lib/outputs/ocsf/ocsf_test.py @@ -167,7 +167,7 @@ class TestOCSF: { "message": "status extended", "metadata": { - "event_code": "test-check-id", + "event_code": "service_test_check_id", "product": { "name": "Prowler", "uid": "prowler", @@ -198,7 +198,7 @@ class TestOCSF: "created_time": int(datetime.now().timestamp()), "created_time_dt": datetime.now().isoformat(), "desc": "check description", - "title": "test-check-id", + "title": "service_test_check_id", "uid": "test-unique-finding", "types": ["test-type"], }, @@ -210,7 +210,7 @@ class TestOCSF: "details": "resource_details", "metadata": {}, }, - "group": {"name": "test-service"}, + "group": {"name": "service"}, "labels": [], "name": "resource_name", "type": "test-resource", diff --git a/tests/providers/aws/lib/mutelist/aws_mutelist_test.py b/tests/providers/aws/lib/mutelist/aws_mutelist_test.py index 3e3afd98e9..8fa562880e 100644 --- a/tests/providers/aws/lib/mutelist/aws_mutelist_test.py +++ b/tests/providers/aws/lib/mutelist/aws_mutelist_test.py @@ -1888,7 +1888,7 @@ class TestAWSMutelist: # Finding finding_1 = generate_finding_output( - check_id="check_test", + check_id="service_check_test", status="FAIL", region=AWS_REGION_US_EAST_1, resource_uid="prowler", diff --git a/tests/providers/aws/services/iam/iam_service_test.py b/tests/providers/aws/services/iam/iam_service_test.py index 615d25dec6..70835f3d69 100644 --- a/tests/providers/aws/services/iam/iam_service_test.py +++ b/tests/providers/aws/services/iam/iam_service_test.py @@ -286,6 +286,22 @@ class Test_IAM_Service: } ], } + # Hybrid role - assumable by both service and AWS account + hybrid_policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "cloudformation.amazonaws.com"}, + "Action": "sts:AssumeRole", + }, + { + "Effect": "Allow", + "Principal": {"AWS": "arn:aws:iam::123456789012:root"}, + "Action": "sts:AssumeRole", + }, + ], + } service_role = iam_client.create_role( RoleName="test-1", AssumeRolePolicyDocument=dumps(service_policy_document), @@ -300,6 +316,13 @@ class Test_IAM_Service: {"Key": "test", "Value": "test"}, ], )["Role"] + hybrid_role = iam_client.create_role( + RoleName="test-3", + AssumeRolePolicyDocument=dumps(hybrid_policy_document), + Tags=[ + {"Key": "test", "Value": "test"}, + ], + )["Role"] # IAM client for this test class aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) @@ -314,6 +337,8 @@ class Test_IAM_Service: ] assert is_service_role(service_role) assert not is_service_role(role) + # Hybrid role should return False even though it has a service principal + assert not is_service_role(hybrid_role) # Test IAM Get Groups @mock_aws diff --git a/tests/providers/azure/azure_provider_test.py b/tests/providers/azure/azure_provider_test.py index 8967142bc5..01d3e703ea 100644 --- a/tests/providers/azure/azure_provider_test.py +++ b/tests/providers/azure/azure_provider_test.py @@ -85,6 +85,7 @@ class TestAzureProvider: "python_latest_version": "3.12", "java_latest_version": "17", "recommended_minimal_tls_versions": ["1.2", "1.3"], + "defender_attack_path_minimal_risk_level": "High", } def test_azure_provider_not_auth_methods(self): diff --git a/tests/providers/azure/lib/mutelist/azure_mutelist_test.py b/tests/providers/azure/lib/mutelist/azure_mutelist_test.py index 83a981cbd2..d15faa83ed 100644 --- a/tests/providers/azure/lib/mutelist/azure_mutelist_test.py +++ b/tests/providers/azure/lib/mutelist/azure_mutelist_test.py @@ -40,7 +40,7 @@ class TestAzureMutelist: assert mutelist.mutelist == {} assert mutelist.mutelist_file_path is None - def test_is_finding_muted(self): + def test_is_finding_muted_subscription_name(self): # Mutelist mutelist_content = { "Accounts": { @@ -66,7 +66,39 @@ class TestAzureMutelist: finding.resource_tags = {} finding.subscription = "subscription_1" - assert mutelist.is_finding_muted(finding) + assert mutelist.is_finding_muted( + finding, "12345678-1234-1234-1234-123456789012" + ) + + def test_is_finding_muted_subscription_id(self): + # Mutelist + mutelist_content = { + "Accounts": { + "12345678-1234-1234-1234-123456789012": { + "Checks": { + "check_test": { + "Regions": ["*"], + "Resources": ["test_resource"], + } + } + } + } + } + + mutelist = AzureMutelist(mutelist_content=mutelist_content) + + finding = MagicMock + finding.check_metadata = MagicMock + finding.check_metadata.CheckID = "check_test" + finding.location = "West Europe" + finding.status = "FAIL" + finding.resource_name = "test_resource" + finding.resource_tags = {} + finding.subscription = "subscription_1" + + assert mutelist.is_finding_muted( + finding, "12345678-1234-1234-1234-123456789012" + ) def test_mute_finding(self): # Mutelist @@ -86,7 +118,7 @@ class TestAzureMutelist: mutelist = AzureMutelist(mutelist_content=mutelist_content) finding_1 = generate_finding_output( - check_id="check_test", + check_id="service_check_test", status="FAIL", account_uid="subscription_1", region="subscription_1", diff --git a/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py b/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py index 3ef2114d4c..752c8a6641 100644 --- a/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py +++ b/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py @@ -1,7 +1,10 @@ from unittest import mock from uuid import uuid4 -from prowler.providers.azure.services.defender.defender_service import SecurityContacts +from prowler.providers.azure.services.defender.defender_service import ( + NotificationsByRole, + SecurityContactConfiguration, +) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, @@ -10,8 +13,8 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_additional_email_configured_with_a_security_contact: def test_defender_no_subscriptions(self): - defender_client = mock.MagicMock - defender_client.security_contacts = {} + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = {} with ( mock.patch( @@ -33,18 +36,20 @@ class Test_defender_additional_email_configured_with_a_security_contact: def test_defender_no_additional_emails(self): resource_id = str(uuid4()) - defender_client = mock.MagicMock - defender_client.security_contacts = { + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { - resource_id: SecurityContacts( - resource_id=resource_id, + resource_id: SecurityContactConfiguration( + id=resource_id, name="default", - emails="", + enabled=True, + emails=[], phone="", - alert_notifications_minimal_severity="High", - alert_notifications_state="On", - notified_roles=["Contributor"], - notified_roles_state="On", + notifications_by_role=NotificationsByRole( + state=True, roles=["Contributor"] + ), + alert_minimal_severity=None, + attack_path_minimal_risk_level=None, ) } } @@ -75,108 +80,22 @@ class Test_defender_additional_email_configured_with_a_security_contact: assert result[0].resource_name == "default" assert result[0].resource_id == resource_id - def test_defender_additional_email_bad_format(self): + def test_defender_additional_email_configured(self): resource_id = str(uuid4()) - defender_client = mock.MagicMock - defender_client.security_contacts = { + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { - resource_id: SecurityContacts( - resource_id=resource_id, + resource_id: SecurityContactConfiguration( + id=resource_id, name="default", - emails="bad_email", + enabled=True, + emails=["test@test.com"], phone="", - alert_notifications_minimal_severity="High", - alert_notifications_state="On", - notified_roles=["Contributor"], - notified_roles_state="On", - ) - } - } - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_azure_provider(), - ), - mock.patch( - "prowler.providers.azure.services.defender.defender_additional_email_configured_with_a_security_contact.defender_additional_email_configured_with_a_security_contact.defender_client", - new=defender_client, - ), - ): - from prowler.providers.azure.services.defender.defender_additional_email_configured_with_a_security_contact.defender_additional_email_configured_with_a_security_contact import ( - defender_additional_email_configured_with_a_security_contact, - ) - - check = defender_additional_email_configured_with_a_security_contact() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"There is not another correct email configured for subscription {AZURE_SUBSCRIPTION_ID}." - ) - assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == "default" - assert result[0].resource_id == resource_id - - def test_defender_additional_email_bad_separator(self): - resource_id = str(uuid4()) - defender_client = mock.MagicMock - defender_client.security_contacts = { - AZURE_SUBSCRIPTION_ID: { - resource_id: SecurityContacts( - resource_id=resource_id, - name="default", - emails="test@test.es, test@test.email.com", - phone="", - alert_notifications_minimal_severity="High", - alert_notifications_state="On", - notified_roles=["Contributor"], - notified_roles_state="On", - ) - } - } - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_azure_provider(), - ), - mock.patch( - "prowler.providers.azure.services.defender.defender_additional_email_configured_with_a_security_contact.defender_additional_email_configured_with_a_security_contact.defender_client", - new=defender_client, - ), - ): - from prowler.providers.azure.services.defender.defender_additional_email_configured_with_a_security_contact.defender_additional_email_configured_with_a_security_contact import ( - defender_additional_email_configured_with_a_security_contact, - ) - - check = defender_additional_email_configured_with_a_security_contact() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"There is not another correct email configured for subscription {AZURE_SUBSCRIPTION_ID}." - ) - assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == "default" - assert result[0].resource_id == resource_id - - def test_defender_additional_email_good_format(self): - resource_id = str(uuid4()) - defender_client = mock.MagicMock - defender_client.security_contacts = { - AZURE_SUBSCRIPTION_ID: { - resource_id: SecurityContacts( - resource_id=resource_id, - name="default", - emails="test@test.com", - phone="", - alert_notifications_minimal_severity="High", - alert_notifications_state="On", - notified_roles=["Contributor"], - notified_roles_state="On", + notifications_by_role=NotificationsByRole( + state=True, roles=["Contributor"] + ), + alert_minimal_severity=None, + attack_path_minimal_risk_level=None, ) } } @@ -206,139 +125,3 @@ class Test_defender_additional_email_configured_with_a_security_contact: assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" assert result[0].resource_id == resource_id - - def test_defender_additional_email_good_format_multiple_subdomains(self): - resource_id = str(uuid4()) - defender_client = mock.MagicMock - defender_client.security_contacts = { - AZURE_SUBSCRIPTION_ID: { - resource_id: SecurityContacts( - resource_id=resource_id, - name="default", - emails="test@test.mail.es; bad_mail", - phone="", - alert_notifications_minimal_severity="High", - alert_notifications_state="On", - notified_roles=["Contributor"], - notified_roles_state="On", - ) - } - } - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_azure_provider(), - ), - mock.patch( - "prowler.providers.azure.services.defender.defender_additional_email_configured_with_a_security_contact.defender_additional_email_configured_with_a_security_contact.defender_client", - new=defender_client, - ), - ): - from prowler.providers.azure.services.defender.defender_additional_email_configured_with_a_security_contact.defender_additional_email_configured_with_a_security_contact import ( - defender_additional_email_configured_with_a_security_contact, - ) - - check = defender_additional_email_configured_with_a_security_contact() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "PASS" - assert ( - result[0].status_extended - == f"There is another correct email configured for subscription {AZURE_SUBSCRIPTION_ID}." - ) - assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == "default" - assert result[0].resource_id == resource_id - - def test_defender_default_security_contact_not_found(self): - defender_client = mock.MagicMock - defender_client.security_contacts = { - AZURE_SUBSCRIPTION_ID: { - f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default": SecurityContacts( - resource_id=f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default", - name="default", - emails="", - phone="", - alert_notifications_minimal_severity="", - alert_notifications_state="", - notified_roles=[""], - notified_roles_state="", - ) - } - } - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_azure_provider(), - ), - mock.patch( - "prowler.providers.azure.services.defender.defender_additional_email_configured_with_a_security_contact.defender_additional_email_configured_with_a_security_contact.defender_client", - new=defender_client, - ), - ): - from prowler.providers.azure.services.defender.defender_additional_email_configured_with_a_security_contact.defender_additional_email_configured_with_a_security_contact import ( - defender_additional_email_configured_with_a_security_contact, - ) - - check = defender_additional_email_configured_with_a_security_contact() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"There is not another correct email configured for subscription {AZURE_SUBSCRIPTION_ID}." - ) - assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == "default" - assert ( - result[0].resource_id - == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default" - ) - - def test_defender_default_security_contact_not_found_empty_name(self): - resource_id = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default" - defender_client = mock.MagicMock - defender_client.security_contacts = { - AZURE_SUBSCRIPTION_ID: { - resource_id: SecurityContacts( - resource_id=resource_id, - name="", - emails="", - phone="", - alert_notifications_minimal_severity="", - alert_notifications_state="", - notified_roles=[""], - notified_roles_state="", - ) - } - } - contact = defender_client.security_contacts[AZURE_SUBSCRIPTION_ID][resource_id] - contact.name = getattr(contact, "name", "default") or "default" - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_azure_provider(), - ), - mock.patch( - "prowler.providers.azure.services.defender.defender_additional_email_configured_with_a_security_contact.defender_additional_email_configured_with_a_security_contact.defender_client", - new=defender_client, - ), - ): - from prowler.providers.azure.services.defender.defender_additional_email_configured_with_a_security_contact.defender_additional_email_configured_with_a_security_contact import ( - defender_additional_email_configured_with_a_security_contact, - ) - - check = defender_additional_email_configured_with_a_security_contact() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"There is not another correct email configured for subscription {AZURE_SUBSCRIPTION_ID}." - ) - assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == "default" - assert result[0].resource_id == resource_id diff --git a/tests/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured_test.py b/tests/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured_test.py new file mode 100644 index 0000000000..cd21783149 --- /dev/null +++ b/tests/providers/azure/services/defender/defender_attack_path_notifications_properly_configured/defender_attack_path_notifications_properly_configured_test.py @@ -0,0 +1,367 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.defender.defender_service import ( + NotificationsByRole, + SecurityContactConfiguration, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_defender_attack_path_notifications_properly_configured: + def test_no_subscriptions(self): + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = {} + defender_client.audit_config = {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_attack_path_notifications_properly_configured.defender_attack_path_notifications_properly_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_attack_path_notifications_properly_configured.defender_attack_path_notifications_properly_configured import ( + defender_attack_path_notifications_properly_configured, + ) + + check = defender_attack_path_notifications_properly_configured() + result = check.execute() + assert len(result) == 0 + + def test_attack_path_notifications_none(self): + resource_id = str(uuid4()) + contact_name = "default" + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = { + AZURE_SUBSCRIPTION_ID: { + resource_id: SecurityContactConfiguration( + id=resource_id, + name=contact_name, + enabled=True, + emails=[""], + phone="", + notifications_by_role=NotificationsByRole( + state=True, roles=["Owner"] + ), + alert_minimal_severity="High", + attack_path_minimal_risk_level=None, + ) + } + } + defender_client.audit_config = {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_attack_path_notifications_properly_configured.defender_attack_path_notifications_properly_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_attack_path_notifications_properly_configured.defender_attack_path_notifications_properly_configured import ( + defender_attack_path_notifications_properly_configured, + ) + + check = defender_attack_path_notifications_properly_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + f"Attack path notifications are not enabled in subscription {AZURE_SUBSCRIPTION_ID} for security contact {contact_name}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == contact_name + assert result[0].resource_id == resource_id + + def test_attack_path_notifications_custom_config(self): + # Configured minimal risk level is Medium + resource_id = str(uuid4()) + contact_name = "default" + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = { + AZURE_SUBSCRIPTION_ID: { + resource_id: SecurityContactConfiguration( + id=resource_id, + name=contact_name, + enabled=True, + emails=[""], + phone="", + notifications_by_role=NotificationsByRole( + state=True, roles=["Owner"] + ), + alert_minimal_severity="High", + attack_path_minimal_risk_level="Medium", + ) + } + } + defender_client.audit_config = { + "defender_attack_path_minimal_risk_level": "Medium" + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_attack_path_notifications_properly_configured.defender_attack_path_notifications_properly_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_attack_path_notifications_properly_configured.defender_attack_path_notifications_properly_configured import ( + defender_attack_path_notifications_properly_configured, + ) + + check = defender_attack_path_notifications_properly_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"Attack path notifications are enabled with minimal risk level Medium in subscription {AZURE_SUBSCRIPTION_ID} for security contact {contact_name}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == contact_name + assert result[0].resource_id == resource_id + + def test_attack_path_notifications_invalid_config(self): + # Configured minimal risk level is invalid, should default to High + resource_id = str(uuid4()) + contact_name = "default" + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = { + AZURE_SUBSCRIPTION_ID: { + resource_id: SecurityContactConfiguration( + id=resource_id, + name=contact_name, + enabled=True, + emails=[""], + phone="", + notifications_by_role=NotificationsByRole( + state=True, roles=["Owner"] + ), + alert_minimal_severity="High", + attack_path_minimal_risk_level="Medium", + ) + } + } + defender_client.audit_config = { + "defender_attack_path_minimal_risk_level": "INVALID" + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_attack_path_notifications_properly_configured.defender_attack_path_notifications_properly_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_attack_path_notifications_properly_configured.defender_attack_path_notifications_properly_configured import ( + defender_attack_path_notifications_properly_configured, + ) + + check = defender_attack_path_notifications_properly_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"Attack path notifications are enabled with minimal risk level Medium in subscription {AZURE_SUBSCRIPTION_ID} for security contact {contact_name}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == contact_name + assert result[0].resource_id == resource_id + + def test_attack_path_notifications_low_default_high(self): + # Low risk level, default config (High) -> PASS + resource_id = str(uuid4()) + contact_name = "default" + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = { + AZURE_SUBSCRIPTION_ID: { + resource_id: SecurityContactConfiguration( + id=resource_id, + name=contact_name, + enabled=True, + emails=[""], + phone="", + notifications_by_role=NotificationsByRole( + state=True, roles=["Owner"] + ), + alert_minimal_severity="High", + attack_path_minimal_risk_level="Low", + ) + } + } + defender_client.audit_config = {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_attack_path_notifications_properly_configured.defender_attack_path_notifications_properly_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_attack_path_notifications_properly_configured.defender_attack_path_notifications_properly_configured import ( + defender_attack_path_notifications_properly_configured, + ) + + check = defender_attack_path_notifications_properly_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"Attack path notifications are enabled with minimal risk level Low in subscription {AZURE_SUBSCRIPTION_ID} for security contact {contact_name}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == contact_name + assert result[0].resource_id == resource_id + + def test_attack_path_notifications_medium_default_high(self): + # Medium risk level, default config (High) -> PASS + resource_id = str(uuid4()) + contact_name = "default" + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = { + AZURE_SUBSCRIPTION_ID: { + resource_id: SecurityContactConfiguration( + id=resource_id, + name=contact_name, + enabled=True, + emails=[""], + phone="", + notifications_by_role=NotificationsByRole( + state=True, roles=["Owner"] + ), + alert_minimal_severity="High", + attack_path_minimal_risk_level="Medium", + ) + } + } + defender_client.audit_config = {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_attack_path_notifications_properly_configured.defender_attack_path_notifications_properly_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_attack_path_notifications_properly_configured.defender_attack_path_notifications_properly_configured import ( + defender_attack_path_notifications_properly_configured, + ) + + check = defender_attack_path_notifications_properly_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"Attack path notifications are enabled with minimal risk level Medium in subscription {AZURE_SUBSCRIPTION_ID} for security contact {contact_name}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == contact_name + assert result[0].resource_id == resource_id + + def test_attack_path_notifications_high_default_high(self): + # High risk level, default config (High) -> PASS + resource_id = str(uuid4()) + contact_name = "default" + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = { + AZURE_SUBSCRIPTION_ID: { + resource_id: SecurityContactConfiguration( + id=resource_id, + name=contact_name, + enabled=True, + emails=[""], + phone="", + notifications_by_role=NotificationsByRole( + state=True, roles=["Owner"] + ), + alert_minimal_severity="High", + attack_path_minimal_risk_level="High", + ) + } + } + defender_client.audit_config = {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_attack_path_notifications_properly_configured.defender_attack_path_notifications_properly_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_attack_path_notifications_properly_configured.defender_attack_path_notifications_properly_configured import ( + defender_attack_path_notifications_properly_configured, + ) + + check = defender_attack_path_notifications_properly_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + f"Attack path notifications are enabled with minimal risk level High in subscription {AZURE_SUBSCRIPTION_ID} for security contact {contact_name}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == contact_name + assert result[0].resource_id == resource_id + + def test_attack_path_notifications_critical_default_high(self): + # Critical risk level, default config (High) -> FAIL + resource_id = str(uuid4()) + contact_name = "default" + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = { + AZURE_SUBSCRIPTION_ID: { + resource_id: SecurityContactConfiguration( + id=resource_id, + name=contact_name, + enabled=True, + emails=[""], + phone="", + notifications_by_role=NotificationsByRole( + state=True, roles=["Owner"] + ), + alert_minimal_severity="High", + attack_path_minimal_risk_level="Critical", + ) + } + } + defender_client.audit_config = {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_attack_path_notifications_properly_configured.defender_attack_path_notifications_properly_configured.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_attack_path_notifications_properly_configured.defender_attack_path_notifications_properly_configured import ( + defender_attack_path_notifications_properly_configured, + ) + + check = defender_attack_path_notifications_properly_configured() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + f"Attack path notifications are enabled with minimal risk level Critical in subscription {AZURE_SUBSCRIPTION_ID} for security contact {contact_name}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == contact_name + assert result[0].resource_id == resource_id diff --git a/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py b/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py index 229ae4259e..85355bc1f0 100644 --- a/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py @@ -1,7 +1,10 @@ from unittest import mock from uuid import uuid4 -from prowler.providers.azure.services.defender.defender_service import SecurityContacts +from prowler.providers.azure.services.defender.defender_service import ( + NotificationsByRole, + SecurityContactConfiguration, +) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, @@ -10,8 +13,8 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_notify_alerts_severity_is_high: def test_defender_no_subscriptions(self): - defender_client = mock.MagicMock - defender_client.security_contacts = {} + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = {} with ( mock.patch( @@ -33,18 +36,20 @@ class Test_defender_ensure_notify_alerts_severity_is_high: def test_defender_severity_alerts_critical(self): resource_id = str(uuid4()) - defender_client = mock.MagicMock - defender_client.security_contacts = { + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { - resource_id: SecurityContacts( - resource_id=resource_id, + resource_id: SecurityContactConfiguration( + id=resource_id, name="default", - emails="", + enabled=True, + emails=[""], phone="", - alert_notifications_minimal_severity="Critical", - alert_notifications_state="On", - notified_roles=["Contributor"], - notified_roles_state="On", + notifications_by_role=NotificationsByRole( + state=True, roles=["Contributor"] + ), + alert_minimal_severity="Critical", + attack_path_minimal_risk_level=None, ) } } @@ -77,18 +82,21 @@ class Test_defender_ensure_notify_alerts_severity_is_high: def test_defender_severity_alerts_high(self): resource_id = str(uuid4()) - defender_client = mock.MagicMock - defender_client.security_contacts = { + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { - resource_id: SecurityContacts( + resource_id: SecurityContactConfiguration( resource_id=resource_id, + id=resource_id, name="default", - emails="", + enabled=True, + emails=[""], phone="", - alert_notifications_minimal_severity="High", - alert_notifications_state="On", - notified_roles=["Contributor"], - notified_roles_state="On", + notifications_by_role=NotificationsByRole( + state=True, roles=["Contributor"] + ), + alert_minimal_severity="High", + attack_path_minimal_risk_level=None, ) } } @@ -121,18 +129,21 @@ class Test_defender_ensure_notify_alerts_severity_is_high: def test_defender_severity_alerts_low(self): resource_id = str(uuid4()) - defender_client = mock.MagicMock - defender_client.security_contacts = { + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { - resource_id: SecurityContacts( + resource_id: SecurityContactConfiguration( resource_id=resource_id, + id=resource_id, name="default", - emails="", + enabled=True, + emails=[""], phone="", - alert_notifications_minimal_severity="Low", - alert_notifications_state="On", - notified_roles=["Contributor"], - notified_roles_state="On", + notifications_by_role=NotificationsByRole( + state=True, roles=["Contributor"] + ), + alert_minimal_severity="Low", + attack_path_minimal_risk_level=None, ) } } @@ -164,18 +175,19 @@ class Test_defender_ensure_notify_alerts_severity_is_high: assert result[0].resource_id == resource_id def test_defender_default_security_contact_not_found(self): - defender_client = mock.MagicMock - defender_client.security_contacts = { + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { - f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default": SecurityContacts( + f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default": SecurityContactConfiguration( resource_id=f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default", + id=f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default", name="default", - emails="", + enabled=True, + emails=[""], phone="", - alert_notifications_minimal_severity="", - alert_notifications_state="", - notified_roles=[""], - notified_roles_state="", + notifications_by_role=NotificationsByRole(state=True, roles=[""]), + alert_minimal_severity="", + attack_path_minimal_risk_level=None, ) } } @@ -208,50 +220,3 @@ class Test_defender_ensure_notify_alerts_severity_is_high: result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default" ) - - def test_defender_default_security_contact_not_found_empty_name(self): - resource_id = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default" - defender_client = mock.MagicMock - defender_client.security_contacts = { - AZURE_SUBSCRIPTION_ID: { - resource_id: SecurityContacts( - resource_id=resource_id, - name="", - emails="", - phone="", - alert_notifications_minimal_severity="", - alert_notifications_state="", - notified_roles=[""], - notified_roles_state="", - ) - } - } - - contact = defender_client.security_contacts[AZURE_SUBSCRIPTION_ID][resource_id] - contact.name = getattr(contact, "name", "default") or "default" - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_azure_provider(), - ), - mock.patch( - "prowler.providers.azure.services.defender.defender_ensure_notify_alerts_severity_is_high.defender_ensure_notify_alerts_severity_is_high.defender_client", - new=defender_client, - ), - ): - from prowler.providers.azure.services.defender.defender_ensure_notify_alerts_severity_is_high.defender_ensure_notify_alerts_severity_is_high import ( - defender_ensure_notify_alerts_severity_is_high, - ) - - check = defender_ensure_notify_alerts_severity_is_high() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {AZURE_SUBSCRIPTION_ID}." - ) - assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == "default" - assert result[0].resource_id == resource_id diff --git a/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py b/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py index 092a3a81a0..e4c2dc4371 100644 --- a/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py @@ -1,7 +1,10 @@ from unittest import mock from uuid import uuid4 -from prowler.providers.azure.services.defender.defender_service import SecurityContacts +from prowler.providers.azure.services.defender.defender_service import ( + NotificationsByRole, + SecurityContactConfiguration, +) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, @@ -10,8 +13,8 @@ from tests.providers.azure.azure_fixtures import ( class Test_defender_ensure_notify_emails_to_owners: def test_defender_no_subscriptions(self): - defender_client = mock.MagicMock - defender_client.security_contacts = {} + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = {} with ( mock.patch( @@ -33,22 +36,23 @@ class Test_defender_ensure_notify_emails_to_owners: def test_defender_no_notify_emails_to_owners(self): resource_id = str(uuid4()) - defender_client = mock.MagicMock - defender_client.security_contacts = { + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { - resource_id: SecurityContacts( - resource_id=resource_id, + resource_id: SecurityContactConfiguration( + id=resource_id, name="default", - emails="", + enabled=True, + emails=[""], phone="", - alert_notifications_minimal_severity="High", - alert_notifications_state="On", - notified_roles=["Contributor"], - notified_roles_state="On", + notifications_by_role=NotificationsByRole( + state=True, roles=["Contributor"] + ), + alert_minimal_severity="Critical", + attack_path_minimal_risk_level=None, ) } } - with ( mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -67,28 +71,24 @@ class Test_defender_ensure_notify_emails_to_owners: result = check.execute() assert len(result) == 1 assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"The Owner role is not notified for subscription {AZURE_SUBSCRIPTION_ID}." - ) - assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == "default" assert result[0].resource_id == resource_id def test_defender_notify_emails_to_owners_off(self): resource_id = str(uuid4()) - defender_client = mock.MagicMock - defender_client.security_contacts = { + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { - resource_id: SecurityContacts( - resource_id=resource_id, + resource_id: SecurityContactConfiguration( + id=resource_id, name="default", - emails="", + enabled=True, + emails=[""], phone="", - alert_notifications_minimal_severity="High", - alert_notifications_state="On", - notified_roles=["Owner", "Contributor"], - notified_roles_state="Off", + notifications_by_role=NotificationsByRole( + state=False, roles=["Owner", "Contributor"] + ), + alert_minimal_severity="Critical", + attack_path_minimal_risk_level=None, ) } } @@ -121,18 +121,20 @@ class Test_defender_ensure_notify_emails_to_owners: def test_defender_notify_emails_to_owners(self): resource_id = str(uuid4()) - defender_client = mock.MagicMock - defender_client.security_contacts = { + defender_client = mock.MagicMock() + defender_client.security_contact_configurations = { AZURE_SUBSCRIPTION_ID: { - resource_id: SecurityContacts( - resource_id=resource_id, + resource_id: SecurityContactConfiguration( + id=resource_id, name="default", - emails="test@test.es", + enabled=True, + emails=["test@test.es"], phone="", - alert_notifications_minimal_severity="High", - alert_notifications_state="On", - notified_roles=["Owner", "Contributor"], - notified_roles_state="On", + notifications_by_role=NotificationsByRole( + state=True, roles=["Owner", "Contributor"] + ), + alert_minimal_severity="Critical", + attack_path_minimal_risk_level=None, ) } } @@ -162,95 +164,3 @@ class Test_defender_ensure_notify_emails_to_owners: assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" assert result[0].resource_id == resource_id - - def test_defender_default_security_contact_not_found(self): - defender_client = mock.MagicMock - defender_client.security_contacts = { - AZURE_SUBSCRIPTION_ID: { - f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default": SecurityContacts( - resource_id=f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default", - name="default", - emails="", - phone="", - alert_notifications_minimal_severity="", - alert_notifications_state="", - notified_roles=[""], - notified_roles_state="", - ) - } - } - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_azure_provider(), - ), - mock.patch( - "prowler.providers.azure.services.defender.defender_ensure_notify_emails_to_owners.defender_ensure_notify_emails_to_owners.defender_client", - new=defender_client, - ), - ): - from prowler.providers.azure.services.defender.defender_ensure_notify_emails_to_owners.defender_ensure_notify_emails_to_owners import ( - defender_ensure_notify_emails_to_owners, - ) - - check = defender_ensure_notify_emails_to_owners() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"The Owner role is not notified for subscription {AZURE_SUBSCRIPTION_ID}." - ) - assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == "default" - assert ( - result[0].resource_id - == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default" - ) - - def test_defender_default_security_contact_not_found_empty_name(self): - defender_client = mock.MagicMock() - resource_id = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default" - defender_client.security_contacts = { - AZURE_SUBSCRIPTION_ID: { - resource_id: SecurityContacts( - resource_id=resource_id, - name="", - emails="", - phone="", - alert_notifications_minimal_severity="", - alert_notifications_state="", - notified_roles=[""], - notified_roles_state="", - ) - } - } - contact = defender_client.security_contacts[AZURE_SUBSCRIPTION_ID][resource_id] - contact.name = getattr(contact, "name", "default") or "default" - - with ( - mock.patch( - "prowler.providers.common.provider.Provider.get_global_provider", - return_value=set_mocked_azure_provider(), - ), - mock.patch( - "prowler.providers.azure.services.defender.defender_ensure_notify_emails_to_owners.defender_ensure_notify_emails_to_owners.defender_client", - new=defender_client, - ), - ): - from prowler.providers.azure.services.defender.defender_ensure_notify_emails_to_owners.defender_ensure_notify_emails_to_owners import ( - defender_ensure_notify_emails_to_owners, - ) - - check = defender_ensure_notify_emails_to_owners() - result = check.execute() - assert len(result) == 1 - assert result[0].status == "FAIL" - assert ( - result[0].status_extended - == f"The Owner role is not notified for subscription {AZURE_SUBSCRIPTION_ID}." - ) - assert result[0].subscription == AZURE_SUBSCRIPTION_ID - assert result[0].resource_name == "default" - assert result[0].resource_id == resource_id diff --git a/tests/providers/azure/services/defender/defender_service_test.py b/tests/providers/azure/services/defender/defender_service_test.py index ccf4260885..20156640ee 100644 --- a/tests/providers/azure/services/defender/defender_service_test.py +++ b/tests/providers/azure/services/defender/defender_service_test.py @@ -7,7 +7,7 @@ from prowler.providers.azure.services.defender.defender_service import ( Defender, IoTSecuritySolution, Pricing, - SecurityContacts, + SecurityContactConfiguration, Setting, ) from tests.providers.azure.azure_fixtures import ( @@ -55,18 +55,24 @@ def mock_defender_get_assessments(_): } -def mock_defender_get_security_contacts(_): +def mock_defender_get_security_contacts(*args, **kwargs): + from prowler.providers.azure.services.defender.defender_service import ( + NotificationsByRole, + ) + return { AZURE_SUBSCRIPTION_ID: { - "/subscriptions/resource_id": SecurityContacts( - resource_id="/subscriptions/resource_id", + "/subscriptions/resource_id": SecurityContactConfiguration( + id="/subscriptions/resource_id", name="default", - emails="user@user.com, test@test.es", + enabled=True, + emails=["user@user.com", "test@test.es"], phone="666666666", - alert_notifications_minimal_severity="High", - alert_notifications_state="On", - notified_roles=["Owner", "Contributor"], - notified_roles_state="On", + notifications_by_role=NotificationsByRole( + state=True, roles=["Owner", "Contributor"] + ), + alert_minimal_severity="High", + attack_path_minimal_risk_level=None, ) } } @@ -216,52 +222,17 @@ class Test_Defender_Service: def test_get_security_contacts(self): defender = Defender(set_mocked_azure_provider()) - assert len(defender.security_contacts) == 1 - assert ( - defender.security_contacts[AZURE_SUBSCRIPTION_ID][ - "/subscriptions/resource_id" - ].resource_id - == "/subscriptions/resource_id" - ) - assert ( - defender.security_contacts[AZURE_SUBSCRIPTION_ID][ - "/subscriptions/resource_id" - ].name - == "default" - ) - assert ( - defender.security_contacts[AZURE_SUBSCRIPTION_ID][ - "/subscriptions/resource_id" - ].emails - == "user@user.com, test@test.es" - ) - assert ( - defender.security_contacts[AZURE_SUBSCRIPTION_ID][ - "/subscriptions/resource_id" - ].phone - == "666666666" - ) - assert ( - defender.security_contacts[AZURE_SUBSCRIPTION_ID][ - "/subscriptions/resource_id" - ].alert_notifications_minimal_severity - == "High" - ) - assert ( - defender.security_contacts[AZURE_SUBSCRIPTION_ID][ - "/subscriptions/resource_id" - ].alert_notifications_state - == "On" - ) - assert defender.security_contacts[AZURE_SUBSCRIPTION_ID][ + assert len(defender.security_contact_configurations) == 1 + contact = defender.security_contact_configurations[AZURE_SUBSCRIPTION_ID][ "/subscriptions/resource_id" - ].notified_roles == ["Owner", "Contributor"] - assert ( - defender.security_contacts[AZURE_SUBSCRIPTION_ID][ - "/subscriptions/resource_id" - ].notified_roles_state - == "On" - ) + ] + assert contact.id == "/subscriptions/resource_id" + assert contact.name == "default" + assert contact.emails == ["user@user.com", "test@test.es"] + assert contact.phone == "666666666" + assert contact.alert_minimal_severity == "High" + assert contact.notifications_by_role.state is True + assert contact.notifications_by_role.roles == ["Owner", "Contributor"] def test_get_iot_security_solutions(self): defender = Defender(set_mocked_azure_provider()) diff --git a/tests/providers/gcp/lib/mutelist/gcp_mutelist_test.py b/tests/providers/gcp/lib/mutelist/gcp_mutelist_test.py index 0285dd44f7..d846525ef9 100644 --- a/tests/providers/gcp/lib/mutelist/gcp_mutelist_test.py +++ b/tests/providers/gcp/lib/mutelist/gcp_mutelist_test.py @@ -84,7 +84,7 @@ class TestGCPMutelist: mutelist = GCPMutelist(mutelist_content=mutelist_content) finding_1 = generate_finding_output( - check_id="check_test", + check_id="service_check_test", status="FAIL", account_uid="project_1", region="test-region", diff --git a/tests/providers/github/github_provider_test.py b/tests/providers/github/github_provider_test.py index 60b705b629..88ed4dcfdb 100644 --- a/tests/providers/github/github_provider_test.py +++ b/tests/providers/github/github_provider_test.py @@ -1,9 +1,20 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch + +import pytest from prowler.config.config import ( default_fixer_config_file_path, load_and_validate_config_file, ) +from prowler.providers.common.models import Connection +from prowler.providers.github.exceptions.exceptions import ( + GithubEnvironmentVariableError, + GithubInvalidCredentialsError, + GithubInvalidProviderIdError, + GithubInvalidTokenError, + GithubSetUpIdentityError, + GithubSetUpSessionError, +) from prowler.providers.github.github_provider import GithubProvider from prowler.providers.github.models import ( GithubAppIdentityInfo, @@ -141,3 +152,492 @@ class TestGitHubProvider: "inactive_not_archived_days_threshold": 180, } assert provider._fixer_config == fixer_config + + def test_test_connection_with_personal_access_token_success(self): + """Test successful connection with personal access token.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + return_value=GithubSession(token=PAT_TOKEN, id="", key=""), + ), + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_identity", + return_value=GithubIdentityInfo( + account_id=ACCOUNT_ID, + account_name=ACCOUNT_NAME, + account_url=ACCOUNT_URL, + ), + ), + ): + connection = GithubProvider.test_connection(personal_access_token=PAT_TOKEN) + + assert isinstance(connection, Connection) + assert connection.is_connected is True + assert connection.error is None + + def test_test_connection_with_oauth_app_token_success(self): + """Test successful connection with OAuth app token.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + return_value=GithubSession(token=OAUTH_TOKEN, id="", key=""), + ), + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_identity", + return_value=GithubIdentityInfo( + account_id=ACCOUNT_ID, + account_name=ACCOUNT_NAME, + account_url=ACCOUNT_URL, + ), + ), + ): + connection = GithubProvider.test_connection(oauth_app_token=OAUTH_TOKEN) + + assert isinstance(connection, Connection) + assert connection.is_connected is True + assert connection.error is None + + def test_test_connection_with_github_app_success(self): + """Test successful connection with GitHub App credentials.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + return_value=GithubSession(token="", id=APP_ID, key=APP_KEY), + ), + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_identity", + return_value=GithubAppIdentityInfo(app_id=APP_ID), + ), + ): + connection = GithubProvider.test_connection( + github_app_id=APP_ID, github_app_key=APP_KEY + ) + + assert isinstance(connection, Connection) + assert connection.is_connected is True + assert connection.error is None + + def test_test_connection_with_invalid_token_raises_exception(self): + """Test connection with invalid token raises exception.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + return_value=GithubSession(token="invalid-token", id="", key=""), + ), + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_identity", + side_effect=GithubInvalidTokenError( + original_exception=Exception("Invalid token") + ), + ), + ): + with pytest.raises(GithubInvalidTokenError): + GithubProvider.test_connection(personal_access_token="invalid-token") + + def test_test_connection_with_invalid_token_no_raise(self): + """Test connection with invalid token without raising exception.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + return_value=GithubSession(token="invalid-token", id="", key=""), + ), + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_identity", + side_effect=GithubInvalidTokenError( + original_exception=Exception("Invalid token") + ), + ), + ): + connection = GithubProvider.test_connection( + personal_access_token="invalid-token", raise_on_exception=False + ) + + assert isinstance(connection, Connection) + assert connection.is_connected is False + assert isinstance(connection.error, GithubInvalidTokenError) + + def test_test_connection_with_invalid_app_credentials_raises_exception(self): + """Test connection with invalid GitHub App credentials raises exception.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + return_value=GithubSession(token="", id=APP_ID, key="invalid-key"), + ), + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_identity", + side_effect=GithubInvalidCredentialsError( + original_exception=Exception("Invalid credentials") + ), + ), + ): + with pytest.raises(GithubInvalidCredentialsError): + GithubProvider.test_connection( + github_app_id=APP_ID, github_app_key="invalid-key" + ) + + def test_test_connection_with_invalid_app_credentials_no_raise(self): + """Test connection with invalid GitHub App credentials without raising exception.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + return_value=GithubSession(token="", id=APP_ID, key="invalid-key"), + ), + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_identity", + side_effect=GithubInvalidCredentialsError( + original_exception=Exception("Invalid credentials") + ), + ), + ): + connection = GithubProvider.test_connection( + github_app_id=APP_ID, + github_app_key="invalid-key", + raise_on_exception=False, + ) + + assert isinstance(connection, Connection) + assert connection.is_connected is False + assert isinstance(connection.error, GithubInvalidCredentialsError) + + def test_test_connection_setup_session_error_raises_exception(self): + """Test connection when setup_session raises an exception.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + side_effect=GithubSetUpSessionError( + original_exception=Exception("Setup error") + ), + ), + patch("prowler.providers.github.github_provider.logger") as mock_logger, + ): + with pytest.raises(GithubSetUpSessionError): + GithubProvider.test_connection(personal_access_token=PAT_TOKEN) + + mock_logger.critical.assert_called_once() + + def test_test_connection_setup_session_error_no_raise(self): + """Test connection when setup_session raises an exception without raising.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + side_effect=GithubSetUpSessionError( + original_exception=Exception("Setup error") + ), + ), + patch("prowler.providers.github.github_provider.logger") as mock_logger, + ): + connection = GithubProvider.test_connection( + personal_access_token=PAT_TOKEN, raise_on_exception=False + ) + + assert isinstance(connection, Connection) + assert connection.is_connected is False + assert isinstance(connection.error, GithubSetUpSessionError) + mock_logger.critical.assert_called_once() + + def test_test_connection_environment_variable_error_raises_exception(self): + """Test connection when environment variable error occurs.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + side_effect=GithubEnvironmentVariableError( + file="test_file.py", message="Env error" + ), + ), + patch("prowler.providers.github.github_provider.logger") as mock_logger, + ): + with pytest.raises(GithubEnvironmentVariableError): + GithubProvider.test_connection(personal_access_token=PAT_TOKEN) + + mock_logger.critical.assert_called_once() + + def test_test_connection_environment_variable_error_no_raise(self): + """Test connection when environment variable error occurs without raising.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + side_effect=GithubEnvironmentVariableError( + file="test_file.py", message="Env error" + ), + ), + patch("prowler.providers.github.github_provider.logger") as mock_logger, + ): + connection = GithubProvider.test_connection( + personal_access_token=PAT_TOKEN, raise_on_exception=False + ) + + assert isinstance(connection, Connection) + assert connection.is_connected is False + assert isinstance(connection.error, GithubEnvironmentVariableError) + mock_logger.critical.assert_called_once() + + def test_test_connection_setup_identity_error_raises_exception(self): + """Test connection when setup_identity raises an exception.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + return_value=GithubSession(token=PAT_TOKEN, id="", key=""), + ), + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_identity", + side_effect=GithubSetUpIdentityError( + original_exception=Exception("Identity error") + ), + ), + ): + with pytest.raises(GithubSetUpIdentityError): + GithubProvider.test_connection(personal_access_token=PAT_TOKEN) + + def test_test_connection_setup_identity_error_no_raise(self): + """Test connection when setup_identity raises an exception without raising.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + return_value=GithubSession(token=PAT_TOKEN, id="", key=""), + ), + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_identity", + side_effect=GithubSetUpIdentityError( + original_exception=Exception("Identity error") + ), + ), + ): + connection = GithubProvider.test_connection( + personal_access_token=PAT_TOKEN, raise_on_exception=False + ) + + assert isinstance(connection, Connection) + assert connection.is_connected is False + assert isinstance(connection.error, GithubSetUpIdentityError) + + def test_test_connection_generic_exception_raises_exception(self): + """Test connection when a generic exception occurs.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + side_effect=Exception("Generic error"), + ), + patch("prowler.providers.github.github_provider.logger") as mock_logger, + ): + with pytest.raises(Exception) as exc_info: + GithubProvider.test_connection(personal_access_token=PAT_TOKEN) + + assert str(exc_info.value) == "Generic error" + mock_logger.critical.assert_called_once() + + def test_test_connection_generic_exception_no_raise(self): + """Test connection when a generic exception occurs without raising.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + side_effect=Exception("Generic error"), + ), + patch("prowler.providers.github.github_provider.logger") as mock_logger, + ): + connection = GithubProvider.test_connection( + personal_access_token=PAT_TOKEN, raise_on_exception=False + ) + + assert isinstance(connection, Connection) + assert connection.is_connected is False + assert isinstance(connection.error, Exception) + assert str(connection.error) == "Generic error" + mock_logger.critical.assert_called_once() + + def test_test_connection_with_provider_id(self): + """Test connection with provider_id parameter (should validate provider ID).""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + return_value=GithubSession(token=PAT_TOKEN, id="", key=""), + ), + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_identity", + return_value=GithubIdentityInfo( + account_id=ACCOUNT_ID, + account_name=ACCOUNT_NAME, + account_url=ACCOUNT_URL, + ), + ), + patch( + "prowler.providers.github.github_provider.GithubProvider.validate_provider_id", + return_value=None, + ), + ): + connection = GithubProvider.test_connection( + personal_access_token=PAT_TOKEN, provider_id="test-org" + ) + + assert isinstance(connection, Connection) + assert connection.is_connected is True + assert connection.error is None + + def test_test_connection_with_invalid_provider_id_raises_exception(self): + """Test connection with invalid provider_id raises exception.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + return_value=GithubSession(token=PAT_TOKEN, id="", key=""), + ), + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_identity", + return_value=GithubIdentityInfo( + account_id=ACCOUNT_ID, + account_name=ACCOUNT_NAME, + account_url=ACCOUNT_URL, + ), + ), + patch( + "prowler.providers.github.github_provider.GithubProvider.validate_provider_id", + side_effect=GithubInvalidProviderIdError( + file="test_file.py", message="Invalid provider ID" + ), + ), + ): + with pytest.raises(GithubInvalidProviderIdError): + GithubProvider.test_connection( + personal_access_token=PAT_TOKEN, provider_id="invalid-org" + ) + + def test_test_connection_with_invalid_provider_id_no_raise(self): + """Test connection with invalid provider_id without raising exception.""" + with ( + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_session", + return_value=GithubSession(token=PAT_TOKEN, id="", key=""), + ), + patch( + "prowler.providers.github.github_provider.GithubProvider.setup_identity", + return_value=GithubIdentityInfo( + account_id=ACCOUNT_ID, + account_name=ACCOUNT_NAME, + account_url=ACCOUNT_URL, + ), + ), + patch( + "prowler.providers.github.github_provider.GithubProvider.validate_provider_id", + side_effect=GithubInvalidProviderIdError( + file="test_file.py", message="Invalid provider ID" + ), + ), + ): + connection = GithubProvider.test_connection( + personal_access_token=PAT_TOKEN, + provider_id="invalid-org", + raise_on_exception=False, + ) + + assert isinstance(connection, Connection) + assert connection.is_connected is False + assert isinstance(connection.error, GithubInvalidProviderIdError) + + def test_validate_provider_id_with_valid_user(self): + """Test validate_provider_id with valid user (matches authenticated user).""" + mock_session = GithubSession(token=PAT_TOKEN, id="", key="") + + with ( + patch("prowler.providers.github.github_provider.Auth.Token"), + patch("prowler.providers.github.github_provider.Github") as mock_github, + patch("prowler.providers.github.github_provider.GithubRetry"), + ): + # Mock the GitHub client and user + mock_user = MagicMock() + mock_user.login = "test-user" + mock_github_instance = MagicMock() + mock_github_instance.get_user.return_value = mock_user + mock_github.return_value = mock_github_instance + + # Should not raise an exception + GithubProvider.validate_provider_id(mock_session, "test-user") + + def test_validate_provider_id_with_valid_organization(self): + """Test validate_provider_id with valid organization.""" + mock_session = GithubSession(token=PAT_TOKEN, id="", key="") + + with ( + patch("prowler.providers.github.github_provider.Auth.Token"), + patch("prowler.providers.github.github_provider.Github") as mock_github, + patch("prowler.providers.github.github_provider.GithubRetry"), + ): + # Mock the GitHub client and user + mock_user = MagicMock() + mock_user.login = "test-user" + mock_github_instance = MagicMock() + mock_github_instance.get_user.return_value = mock_user + mock_github_instance.get_organization.return_value = ( + MagicMock() + ) # Organization exists + mock_github.return_value = mock_github_instance + + # Should not raise an exception + GithubProvider.validate_provider_id(mock_session, "test-org") + + def test_validate_provider_id_with_invalid_provider_id(self): + """Test validate_provider_id with invalid provider ID.""" + mock_session = GithubSession(token=PAT_TOKEN, id="", key="") + + with ( + patch("prowler.providers.github.github_provider.Auth.Token"), + patch("prowler.providers.github.github_provider.Github") as mock_github, + patch("prowler.providers.github.github_provider.GithubRetry"), + ): + # Mock the GitHub client and user + mock_user = MagicMock() + mock_user.login = "test-user" + mock_github_instance = MagicMock() + mock_github_instance.get_user.return_value = mock_user + mock_github_instance.get_organization.side_effect = Exception("Not found") + mock_github_instance.get_user.side_effect = [ + mock_user, + Exception("Not found"), + ] + mock_github.return_value = mock_github_instance + + with pytest.raises(GithubInvalidProviderIdError): + GithubProvider.validate_provider_id(mock_session, "invalid-provider") + + def test_validate_provider_id_with_github_app(self): + """Test validate_provider_id with GitHub App credentials.""" + mock_session = GithubSession(token="", id=APP_ID, key=APP_KEY) + + with ( + patch("prowler.providers.github.github_provider.Auth.AppAuth"), + patch( + "prowler.providers.github.github_provider.GithubIntegration" + ) as mock_integration, + patch("prowler.providers.github.github_provider.GithubRetry"), + ): + # Mock the GitHub integration and installations + mock_installation = MagicMock() + mock_installation.raw_data = {"account": {"login": "test-org"}} + mock_integration_instance = MagicMock() + mock_integration_instance.get_installations.return_value = [ + mock_installation + ] + mock_integration.return_value = mock_integration_instance + + # Should not raise an exception + GithubProvider.validate_provider_id(mock_session, "test-org") + + def test_validate_provider_id_with_github_app_invalid_org(self): + """Test validate_provider_id with GitHub App credentials and invalid organization.""" + mock_session = GithubSession(token="", id=APP_ID, key=APP_KEY) + + with ( + patch("prowler.providers.github.github_provider.Auth.AppAuth"), + patch( + "prowler.providers.github.github_provider.GithubIntegration" + ) as mock_integration, + patch("prowler.providers.github.github_provider.GithubRetry"), + ): + # Mock the GitHub integration and installations + mock_installation = MagicMock() + mock_installation.raw_data = {"account": {"login": "other-org"}} + mock_integration_instance = MagicMock() + mock_integration_instance.get_installations.return_value = [ + mock_installation + ] + mock_integration.return_value = mock_integration_instance + + with pytest.raises(GithubInvalidProviderIdError): + GithubProvider.validate_provider_id(mock_session, "invalid-org") diff --git a/tests/providers/github/lib/mutelist/github_mutelist_test.py b/tests/providers/github/lib/mutelist/github_mutelist_test.py index b29db60c07..3d74af275a 100644 --- a/tests/providers/github/lib/mutelist/github_mutelist_test.py +++ b/tests/providers/github/lib/mutelist/github_mutelist_test.py @@ -86,7 +86,7 @@ class TestGithubMutelist: mutelist = GithubMutelist(mutelist_content=mutelist_content) finding_1 = generate_finding_output( - check_id="check_test", + check_id="service_check_test", status="FAIL", account_uid="account_1", resource_uid="test_resource", diff --git a/tests/providers/iac/iac_provider_test.py b/tests/providers/iac/iac_provider_test.py index f3da2a7c11..e0fda15462 100644 --- a/tests/providers/iac/iac_provider_test.py +++ b/tests/providers/iac/iac_provider_test.py @@ -1,3 +1,6 @@ +import os +import tempfile +from unittest import mock from unittest.mock import Mock, patch import pytest @@ -131,6 +134,63 @@ class TestIacProvider: assert report.status == "FAIL" assert report.check_metadata.RelatedUrl == "" + def test_provider_run_local_scan(self): + scan_path = "." + provider = IacProvider(scan_path=scan_path) + with mock.patch( + "prowler.providers.iac.iac_provider.IacProvider.run_scan", + ) as mock_run_scan: + provider.run() + mock_run_scan.assert_called_with(scan_path, ["all"], []) + + @mock.patch.dict(os.environ, {}, clear=True) + def test_provider_run_remote_scan(self): + scan_repository_url = "https://github.com/user/repo" + provider = IacProvider(scan_repository_url=scan_repository_url) + with tempfile.TemporaryDirectory() as temp_dir: + with ( + mock.patch( + "prowler.providers.iac.iac_provider.IacProvider._clone_repository", + return_value=temp_dir, + ) as mock_clone, + mock.patch( + "prowler.providers.iac.iac_provider.IacProvider.run_scan" + ) as mock_run_scan, + ): + provider.run() + mock_clone.assert_called_with(scan_repository_url, None, None, None) + mock_run_scan.assert_called_with(temp_dir, ["all"], []) + + @mock.patch.dict(os.environ, {}, clear=True) + def test_print_credentials_local(self): + scan_path = "/path/to/scan" + provider = IacProvider(scan_path=scan_path) + with mock.patch("builtins.print") as mock_print: + provider.print_credentials() + assert any( + f"Directory: \x1b[33m{scan_path}\x1b[0m" in call.args[0] + for call in mock_print.call_args_list + ) + assert any( + "Scanning local IaC directory:" in call.args[0] + for call in mock_print.call_args_list + ) + + @mock.patch.dict(os.environ, {}, clear=True) + def test_print_credentials_remote(self): + repo_url = "https://github.com/user/repo" + provider = IacProvider(scan_repository_url=repo_url) + with mock.patch("builtins.print") as mock_print: + provider.print_credentials() + assert any( + f"Repository: \x1b[33m{repo_url}\x1b[0m" in call.args[0] + for call in mock_print.call_args_list + ) + assert any( + "Scanning remote IaC repository:" in call.args[0] + for call in mock_print.call_args_list + ) + def test_iac_provider_process_check_medium_severity(self): """Test processing a medium severity check""" provider = IacProvider() @@ -543,3 +603,31 @@ class TestIacProvider: mock_run_scan.assert_called_once_with( "/custom/path", ["terraform"], ["exclude"] ) + + @mock.patch("prowler.providers.iac.iac_provider.porcelain.clone") + @mock.patch("tempfile.mkdtemp", return_value="/tmp/fake-dir") + def test_clone_repository_no_auth(self, _mock_mkdtemp, mock_clone): + provider = IacProvider() + url = "https://github.com/user/repo.git" + provider._clone_repository(url) + mock_clone.assert_called_with(url, "/tmp/fake-dir", depth=1) + + @mock.patch("prowler.providers.iac.iac_provider.porcelain.clone") + @mock.patch("tempfile.mkdtemp", return_value="/tmp/fake-dir") + def test_clone_repository_with_pat(self, _mock_mkdtemp, mock_clone): + provider = IacProvider() + url = "https://github.com/user/repo.git" + provider._clone_repository( + url, github_username="user", personal_access_token="token123" + ) + expected_url = "https://user:token123@github.com/user/repo.git" + mock_clone.assert_called_with(expected_url, "/tmp/fake-dir", depth=1) + + @mock.patch("prowler.providers.iac.iac_provider.porcelain.clone") + @mock.patch("tempfile.mkdtemp", return_value="/tmp/fake-dir") + def test_clone_repository_with_oauth(self, _mock_mkdtemp, mock_clone): + provider = IacProvider() + url = "https://github.com/user/repo.git" + provider._clone_repository(url, oauth_app_token="oauth456") + expected_url = "https://oauth2:oauth456@github.com/user/repo.git" + mock_clone.assert_called_with(expected_url, "/tmp/fake-dir", depth=1) diff --git a/tests/providers/iac/lib/arguments_test.py b/tests/providers/iac/lib/arguments_test.py new file mode 100644 index 0000000000..d6b2e92420 --- /dev/null +++ b/tests/providers/iac/lib/arguments_test.py @@ -0,0 +1,33 @@ +import types + + +def test_validate_arguments_mutual_exclusion(): + from prowler.providers.iac.lib.arguments import arguments as iac_arguments + + Args = types.SimpleNamespace + + # Only scan_path (default) + args = Args(scan_path=".", scan_repository_url=None) + valid, msg = iac_arguments.validate_arguments(args) + assert valid + assert msg == "" + + # Only scan_repository_url + args = Args(scan_path=".", scan_repository_url="https://github.com/test/repo") + valid, msg = iac_arguments.validate_arguments(args) + assert valid + assert msg == "" + + # Both set, scan_path is not default + args = Args( + scan_path="/some/path", scan_repository_url="https://github.com/test/repo" + ) + valid, msg = iac_arguments.validate_arguments(args) + assert not valid + assert "mutually exclusive" in msg + + # Both set, scan_path is default (should allow) + args = Args(scan_path=".", scan_repository_url="https://github.com/test/repo") + valid, msg = iac_arguments.validate_arguments(args) + assert valid + assert msg == "" diff --git a/tests/providers/kubernetes/lib/mutelist/kubernetes_mutelist_test.py b/tests/providers/kubernetes/lib/mutelist/kubernetes_mutelist_test.py index 366eb9ee3b..bfca02ddf8 100644 --- a/tests/providers/kubernetes/lib/mutelist/kubernetes_mutelist_test.py +++ b/tests/providers/kubernetes/lib/mutelist/kubernetes_mutelist_test.py @@ -148,7 +148,7 @@ class TestKubernetesMutelist: mutelist = KubernetesMutelist(mutelist_content=mutelist_content) finding_1 = generate_finding_output( - check_id="check_test", + check_id="service_check_test", status="FAIL", account_uid="cluster_1", region="test-region", diff --git a/tests/providers/m365/lib/mutelist/m365_mutelist_test.py b/tests/providers/m365/lib/mutelist/m365_mutelist_test.py index a079666ce8..4d63b98faa 100644 --- a/tests/providers/m365/lib/mutelist/m365_mutelist_test.py +++ b/tests/providers/m365/lib/mutelist/m365_mutelist_test.py @@ -56,7 +56,6 @@ class TestM365Mutelist: mutelist = M365Mutelist(mutelist_content=mutelist_content) finding = MagicMock - finding.tenant_id = "subscription_1" finding.check_metadata = MagicMock finding.check_metadata.CheckID = "check_test" finding.status = "FAIL" @@ -65,7 +64,35 @@ class TestM365Mutelist: finding.tenant_domain = "test_domain" finding.resource_tags = [] - assert mutelist.is_finding_muted(finding) + assert mutelist.is_finding_muted(finding, tenant_id="subscription_1") + + def test_finding_is_not_muted(self): + # Mutelist + mutelist_content = { + "Accounts": { + "subscription_1": { + "Checks": { + "check_test": { + "Regions": ["*"], + "Resources": ["test_resource"], + } + } + } + } + } + + mutelist = M365Mutelist(mutelist_content=mutelist_content) + + finding = MagicMock + finding.check_metadata = MagicMock + finding.check_metadata.CheckID = "check_test" + finding.status = "FAIL" + finding.location = "global" + finding.resource_name = "test_resource" + finding.tenant_domain = "test_domain" + finding.resource_tags = [] + + assert not mutelist.is_finding_muted(finding, tenant_id="subscription_2") def test_mute_finding(self): # Mutelist @@ -85,7 +112,7 @@ class TestM365Mutelist: mutelist = M365Mutelist(mutelist_content=mutelist_content) finding_1 = generate_finding_output( - check_id="check_test", + check_id="service_check_test", status="FAIL", account_uid="subscription_1", region="subscription_1", diff --git a/tests/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time_test.py b/tests/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time_test.py new file mode 100644 index 0000000000..d58f457e41 --- /dev/null +++ b/tests/providers/m365/services/entra/entra_intune_enrollment_sign_in_frequency_every_time/entra_intune_enrollment_sign_in_frequency_every_time_test.py @@ -0,0 +1,353 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + SignInFrequencyType, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + + +class Test_entra_intune_enrollment_sign_in_frequency_every_time: + def test_entra_no_conditional_access_policies(self): + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time import ( + entra_intune_enrollment_sign_in_frequency_every_time, + ) + + entra_client.conditional_access_policies = {} + + check = entra_intune_enrollment_sign_in_frequency_every_time() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy enforces Every Time sign-in frequency for Intune Enrollment." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_entra_intune_enrollment_sign_in_frequency_every_time_disabled(self): + id = str(uuid4()) + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time import ( + entra_intune_enrollment_sign_in_frequency_every_time, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name="Test", + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=[ + "d4ebce55-015a-49b5-a083-c84d1797ae8c" + ], # Intune Enrollment + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[], operator=GrantControlOperator.AND + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=False, mode="always" + ), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_intune_enrollment_sign_in_frequency_every_time() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy enforces Every Time sign-in frequency for Intune Enrollment." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_entra_intune_sign_in_frequency_every_time_enabled(self): + id = str(uuid4()) + display_name = "Test Intune Enrollment Policy" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time import ( + entra_intune_enrollment_sign_in_frequency_every_time, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=[ + "0000000a-0000-0000-c000-000000000000" + ], # Intune Enrollment + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[], operator=GrantControlOperator.AND + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=True, mode="never" + ), + sign_in_frequency=SignInFrequency( + is_enabled=True, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_intune_enrollment_sign_in_frequency_every_time() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy enforces Every Time sign-in frequency for Intune Enrollment." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_entra_all_users_intune_enrollment_4hours(self): + id = str(uuid4()) + display_name = "Test All Users Policy" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time import ( + entra_intune_enrollment_sign_in_frequency_every_time, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[], operator=GrantControlOperator.AND + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=True, mode="never" + ), + sign_in_frequency=SignInFrequency( + is_enabled=True, + frequency=4, + type=SignInFrequencyType.HOURS, + interval=SignInFrequencyInterval.TIME_BASED, + ), + ), + state=ConditionalAccessPolicyState.ENABLED, + ) + } + + check = entra_intune_enrollment_sign_in_frequency_every_time() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No Conditional Access Policy enforces Every Time sign-in frequency for Intune Enrollment." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_entra_intune_enrollment_enabled_for_reporting(self): + id = str(uuid4()) + display_name = "Test Report-Only Policy" + entra_client = mock.MagicMock + entra_client.audited_tenant = "audited_tenant" + entra_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_intune_enrollment_sign_in_frequency_every_time.entra_intune_enrollment_sign_in_frequency_every_time import ( + entra_intune_enrollment_sign_in_frequency_every_time, + ) + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + entra_client.conditional_access_policies = { + id: ConditionalAccessPolicy( + id=id, + display_name=display_name, + conditions=Conditions( + application_conditions=ApplicationsConditions( + included_applications=[ + "d4ebce55-015a-49b5-a083-c84d1797ae8c" + ], # Intune Enrollment + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + ), + grant_controls=GrantControls( + built_in_controls=[], operator=GrantControlOperator.AND + ), + session_controls=SessionControls( + persistent_browser=PersistentBrowser( + is_enabled=True, mode="never" + ), + sign_in_frequency=SignInFrequency( + is_enabled=True, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ), + state=ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + ) + } + + check = entra_intune_enrollment_sign_in_frequency_every_time() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Conditional Access Policy {display_name} reports Every Time sign-in frequency for Intune Enrollment but does not enforce it." + ) + assert result[0].resource == entra_client.conditional_access_policies[id] + assert result[0].resource_name == display_name + assert result[0].resource_id == id + assert result[0].location == "global" 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 7cfbe5de0d..4415628b2c 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 @@ -43,9 +43,9 @@ class Test_entra_users_mfa_capable: assert len(result) == 1 assert result[0].status == "FAIL" assert result[0].status_extended == "User Test User is not MFA capable." - assert result[0].resource == {} - assert result[0].resource_name == "Users" - assert result[0].resource_id == "users" + assert result[0].resource == entra_client.users[user_id] + assert result[0].resource_name == "Test User" + assert result[0].resource_id == user_id def test_user_mfa_capable(self): """User is MFA capable: expected PASS.""" @@ -84,9 +84,9 @@ class Test_entra_users_mfa_capable: assert len(result) == 1 assert result[0].status == "PASS" assert result[0].status_extended == "User Test User is MFA capable." - assert result[0].resource == {} - assert result[0].resource_name == "Users" - assert result[0].resource_id == "users" + assert result[0].resource == entra_client.users[user_id] + assert result[0].resource_name == "Test User" + assert result[0].resource_id == user_id def test_multiple_users(self): """Multiple users with different MFA capabilities: expected mixed results.""" @@ -134,6 +134,12 @@ class Test_entra_users_mfa_capable: # First user (MFA capable) assert result[0].status == "PASS" assert result[0].status_extended == "User Test User 1 is MFA capable." + assert result[0].resource == entra_client.users[user1_id] + assert result[0].resource_name == "Test User 1" + assert result[0].resource_id == user1_id # Second user (not MFA capable) assert result[1].status == "FAIL" assert result[1].status_extended == "User Test User 2 is not MFA capable." + assert result[1].resource == entra_client.users[user2_id] + assert result[1].resource_name == "Test User 2" + assert result[1].resource_id == user2_id diff --git a/tests/providers/nhn/lib/mutelist/nhn_mutelist_test.py b/tests/providers/nhn/lib/mutelist/nhn_mutelist_test.py index de0181c292..efb4759403 100644 --- a/tests/providers/nhn/lib/mutelist/nhn_mutelist_test.py +++ b/tests/providers/nhn/lib/mutelist/nhn_mutelist_test.py @@ -84,7 +84,7 @@ class TestNHNMutelist: mutelist = NHNMutelist(mutelist_content=mutelist_content) finding_1 = generate_finding_output( - check_id="check_test", + check_id="service_check_test", status="FAIL", account_uid="resource_1", region="test_region", diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index e98629166a..0915f7aea4 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,12 +2,16 @@ All notable changes to the **Prowler UI** are documented in this file. -## [v1.9.0] (Prowler v5.9.0) – UNRELEASED +## [v1.9.0] (Prowler v5.9.0) ### 🚀 Added - Mutelist configuration form [(#8190)](https://github.com/prowler-cloud/prowler/pull/8190) - SAML login integration [(#8203)](https://github.com/prowler-cloud/prowler/pull/8203) +- Resource view [(#7760)](https://github.com/prowler-cloud/prowler/pull/7760) +- Navigation link in Scans view to access Compliance Overview [(#8251)](https://github.com/prowler-cloud/prowler/pull/8251) +- Status column for findings table in the Compliance Detail view [(#8244)](https://github.com/prowler-cloud/prowler/pull/8244) +- Allow to restrict routes access based on user permissions [(#8287)](https://github.com/prowler-cloud/prowler/pull/8287) ### Security @@ -16,9 +20,13 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🔄 Changed - Upgrade to Next.js 14.2.30 and lock TypeScript to 5.5.4 for ESLint compatibility [(#8189)](https://github.com/prowler-cloud/prowler/pull/8189) +- Improved active step highlighting and updated step titles and descriptions in the Cloud Provider credentials update flow [(#8303)](https://github.com/prowler-cloud/prowler/pull/8303) ### 🐞 Fixed +- Error message when launching a scan if user has no permissions [(#8280)](https://github.com/prowler-cloud/prowler/pull/8280) +- Include compliance in the download button tooltip [(#8307)](https://github.com/prowler-cloud/prowler/pull/8307) + ### Removed --- diff --git a/ui/actions/auth/auth.ts b/ui/actions/auth/auth.ts index e0e3a75e20..fe93e03f04 100644 --- a/ui/actions/auth/auth.ts +++ b/ui/actions/auth/auth.ts @@ -143,7 +143,7 @@ export const getToken = async (formData: z.infer) => { }; export const getUserByMe = async (accessToken: string) => { - const url = new URL(`${apiBaseUrl}/users/me`); + const url = new URL(`${apiBaseUrl}/users/me?include=roles`); try { const response = await fetch(url.toString(), { @@ -171,11 +171,26 @@ export const getUserByMe = async (accessToken: string) => { } } + const userRole = parsedResponse.included?.find( + (item: any) => item.type === "roles", + ); + + const permissions = { + manage_users: userRole.attributes.manage_users || false, + manage_account: userRole.attributes.manage_account || false, + manage_providers: userRole.attributes.manage_providers || false, + manage_scans: userRole.attributes.manage_scans || false, + manage_integrations: userRole.attributes.manage_integrations || false, + manage_billing: userRole.attributes.manage_billing || false, + unlimited_visibility: userRole.attributes.unlimited_visibility || false, + }; + return { name: parsedResponse.data.attributes.name, email: parsedResponse.data.attributes.email, company: parsedResponse.data.attributes.company_name, dateJoined: parsedResponse.data.attributes.date_joined, + permissions, }; } catch (error: any) { throw new Error(error.message || "Network error or server unreachable"); diff --git a/ui/actions/findings/findings.ts b/ui/actions/findings/findings.ts index 0f2359a421..438459b4d2 100644 --- a/ui/actions/findings/findings.ts +++ b/ui/actions/findings/findings.ts @@ -168,3 +168,24 @@ export const getLatestMetadataInfo = async ({ return undefined; } }; + +export const getFindingById = async (findingId: string, include = "") => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL(`${apiBaseUrl}/findings/${findingId}`); + if (include) url.searchParams.append("include", include); + + try { + const finding = await fetch(url.toString(), { + headers, + }); + + const data = await finding.json(); + const parsedData = parseStringify(data); + + return parsedData; + } catch (error) { + console.error("Error fetching finding by ID:", error); + return undefined; + } +}; diff --git a/ui/actions/resources/index.ts b/ui/actions/resources/index.ts new file mode 100644 index 0000000000..e4e24e4d9a --- /dev/null +++ b/ui/actions/resources/index.ts @@ -0,0 +1,7 @@ +export { + getLatestMetadataInfo, + getLatestResources, + getMetadataInfo, + getResourceById, + getResources, +} from "./resources"; diff --git a/ui/actions/resources/resources.ts b/ui/actions/resources/resources.ts new file mode 100644 index 0000000000..4fa78104de --- /dev/null +++ b/ui/actions/resources/resources.ts @@ -0,0 +1,216 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + +import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib"; + +export const getResources = async ({ + page = 1, + query = "", + sort = "", + filters = {}, + pageSize = 10, + include = "", + fields = [], +}: { + page?: number; + query?: string; + sort?: string; + filters?: Record; + pageSize?: number; + include?: string; + fields?: string[]; +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + if (isNaN(Number(page)) || page < 1) redirect("resources"); + + const url = new URL(`${apiBaseUrl}/resources`); + + if (fields.length > 0) { + url.searchParams.append("fields[resources]", fields.join(",")); + } + + if (page) url.searchParams.append("page[number]", page.toString()); + if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); + if (include) url.searchParams.append("include", include); + if (query) url.searchParams.append("filter[search]", query); + if (sort) url.searchParams.append("sort", sort); + + Object.entries(filters).forEach(([key, value]) => { + url.searchParams.append(key, String(value)); + }); + + try { + const resources = await fetch(url.toString(), { + headers, + }); + + const data = await resources.json(); + const parsedData = parseStringify(data); + + revalidatePath("/resources"); + return parsedData; + } catch (error) { + console.error("Error fetching resources:", error); + return undefined; + } +}; + +export const getLatestResources = async ({ + page = 1, + query = "", + sort = "", + include = "", + filters = {}, + pageSize = 10, + fields = [], +}: { + page?: number; + query?: string; + sort?: string; + filters?: Record; + pageSize?: number; + include?: string; + fields?: string[]; +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + if (isNaN(Number(page)) || page < 1) redirect("resources"); + + const url = new URL(`${apiBaseUrl}/resources/latest`); + + if (fields.length > 0) { + url.searchParams.append("fields[resources]", fields.join(",")); + } + + if (page) url.searchParams.append("page[number]", page.toString()); + if (pageSize) url.searchParams.append("page[size]", pageSize.toString()); + if (include) url.searchParams.append("include", include); + if (query) url.searchParams.append("filter[search]", query); + if (sort) url.searchParams.append("sort", sort); + + Object.entries(filters).forEach(([key, value]) => { + url.searchParams.append(key, String(value)); + }); + + try { + const resources = await fetch(url.toString(), { + headers, + }); + + const data = await resources.json(); + const parsedData = parseStringify(data); + + revalidatePath("/resources"); + return parsedData; + } catch (error) { + console.error("Error fetching latest resources:", error); + return undefined; + } +}; + +export const getMetadataInfo = async ({ + query = "", + sort = "", + filters = {}, +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL(`${apiBaseUrl}/resources/metadata`); + + if (query) url.searchParams.append("filter[search]", query); + if (sort) url.searchParams.append("sort", sort); + + Object.entries(filters).forEach(([key, value]) => { + url.searchParams.append(key, String(value)); + }); + + try { + const metadata = await fetch(url.toString(), { + headers, + }); + + const data = await metadata.json(); + const parsedData = parseStringify(data); + + return parsedData; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error fetching metadata info:", error); + return undefined; + } +}; + +export const getLatestMetadataInfo = async ({ + query = "", + sort = "", + filters = {}, +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL(`${apiBaseUrl}/resources/metadata/latest`); + + if (query) url.searchParams.append("filter[search]", query); + if (sort) url.searchParams.append("sort", sort); + + Object.entries(filters).forEach(([key, value]) => { + url.searchParams.append(key, String(value)); + }); + + try { + const metadata = await fetch(url.toString(), { + headers, + }); + + const data = await metadata.json(); + const parsedData = parseStringify(data); + + return parsedData; + } catch (error) { + console.error("Error fetching latest metadata info:", error); + return undefined; + } +}; + +export const getResourceById = async ( + id: string, + { + fields = [], + include = [], + }: { + fields?: string[]; + include?: string[]; + } = {}, +) => { + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL(`${apiBaseUrl}/resources/${id}`); + + if (fields.length > 0) { + url.searchParams.append("fields[resources]", fields.join(",")); + } + + if (include.length > 0) { + url.searchParams.append("include", include.join(",")); + } + + try { + const resource = await fetch(url.toString(), { + headers, + }); + + if (!resource.ok) { + throw new Error(`Error fetching resource: ${resource.status}`); + } + + const data = await resource.json(); + const parsedData = parseStringify(data); + + return parsedData; + } catch (error) { + console.error("Error fetching resource by ID:", error); + return undefined; + } +}; diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 6d37fed0d7..80505b3ca1 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -36,18 +36,14 @@ export const getScans = async ({ url.searchParams.append(`fields[${key}]`, String(value)); }); - // Handle multiple filters + // Add dynamic filters (e.g., "filter[state]", "fields[scans]") Object.entries(filters).forEach(([key, value]) => { - if (key !== "filter[search]") { - url.searchParams.append(key, String(value)); - } + url.searchParams.append(key, String(value)); }); try { - const scans = await fetch(url.toString(), { - headers, - }); - const data = await scans.json(); + const response = await fetch(url.toString(), { headers }); + const data = await response.json(); const parsedData = parseStringify(data); revalidatePath("/scans"); return parsedData; @@ -144,21 +140,18 @@ export const scanOnDemand = async (formData: FormData) => { }); if (!response.ok) { - try { - const errorData = await response.json(); - throw new Error(errorData?.message || "Failed to start scan"); - } catch { - throw new Error("Failed to start scan"); - } + const errorData = await response.json(); + + return { success: false, error: errorData.errors[0].detail }; } const data = await response.json(); - revalidatePath("/scans"); + return parseStringify(data); } catch (error) { - // eslint-disable-next-line no-console console.error("Error starting scan:", error); + return { error: getErrorMessage(error) }; } }; diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index 93e1c48842..f9e67ae89c 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -32,6 +32,8 @@ interface ComplianceDetailSearchParams { scanData?: string; "filter[region__in]"?: string; "filter[cis_profile_level]"?: string; + page?: string; + pageSize?: string; } const ComplianceIconSmall = ({ @@ -79,8 +81,13 @@ export default async function ComplianceDetail({ const cisProfileFilter = searchParams["filter[cis_profile_level]"]; const logoPath = getComplianceIcon(compliancetitle); - // Create a key that includes region filter for Suspense - const searchParamsKey = JSON.stringify(searchParams || {}); + // Create a key that excludes pagination parameters to preserve accordion state avoiding reloads with pagination + const paramsForKey = Object.fromEntries( + Object.entries(searchParams).filter( + ([key]) => key !== "page" && key !== "pageSize", + ), + ); + const searchParamsKey = JSON.stringify(paramsForKey); const formattedTitle = compliancetitle.split("-").join(" "); const pageTitle = version diff --git a/ui/app/(prowler)/lighthouse/config/page.tsx b/ui/app/(prowler)/lighthouse/config/page.tsx index ce7b2017e7..e8e223783a 100644 --- a/ui/app/(prowler)/lighthouse/config/page.tsx +++ b/ui/app/(prowler)/lighthouse/config/page.tsx @@ -21,7 +21,7 @@ export default async function ChatbotConfigPage() { const configExists = !!response; return ( - + + }> ); diff --git a/ui/app/(prowler)/resources/page.tsx b/ui/app/(prowler)/resources/page.tsx new file mode 100644 index 0000000000..ef4cf65b51 --- /dev/null +++ b/ui/app/(prowler)/resources/page.tsx @@ -0,0 +1,155 @@ +import { Spacer } from "@nextui-org/react"; +import { Suspense } from "react"; + +import { + getLatestMetadataInfo, + getLatestResources, + getMetadataInfo, + getResources, +} from "@/actions/resources"; +import { FilterControls } from "@/components/filters"; +import { SkeletonTableResources } from "@/components/resources/skeleton/skeleton-table-resources"; +import { ColumnResources } from "@/components/resources/table/column-resources"; +import { ContentLayout } from "@/components/ui"; +import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; +import { + createDict, + extractFiltersAndQuery, + extractSortAndKey, + hasDateOrScanFilter, + replaceFieldKey, +} from "@/lib"; +import { ResourceProps, SearchParamsProps } from "@/types"; + +export default async function Resources({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) { + const { searchParamsKey, encodedSort } = extractSortAndKey(searchParams); + const { filters, query } = extractFiltersAndQuery(searchParams); + const outputFilters = replaceFieldKey(filters, "inserted_at", "updated_at"); + + // Check if the searchParams contain any date or scan filter + const hasDateOrScan = hasDateOrScanFilter(searchParams); + + const metadataInfoData = await ( + hasDateOrScan ? getMetadataInfo : getLatestMetadataInfo + )({ + query, + filters: outputFilters, + sort: encodedSort, + }); + + // Extract unique regions, services, types, and names from the metadata endpoint + const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; + const uniqueServices = metadataInfoData?.data?.attributes?.services || []; + const uniqueResourceTypes = metadataInfoData?.data?.attributes?.types || []; + + return ( + + + + + }> + + + + ); +} + +const SSRDataTable = async ({ + searchParams, +}: { + searchParams: SearchParamsProps; +}) => { + const page = parseInt(searchParams.page?.toString() || "1", 10); + const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); + const { encodedSort } = extractSortAndKey({ + ...searchParams, + ...(searchParams.sort && { sort: searchParams.sort }), + }); + + const { filters, query } = extractFiltersAndQuery(searchParams); + // Check if the searchParams contain any date or scan filter + const hasDateOrScan = hasDateOrScanFilter(searchParams); + + const outputFilters = replaceFieldKey(filters, "inserted_at", "updated_at"); + + const fetchResources = hasDateOrScan ? getResources : getLatestResources; + + const resourcesData = await fetchResources({ + query, + page, + sort: encodedSort, + filters: outputFilters, + pageSize, + include: "provider", + fields: [ + "name", + "failed_findings_count", + "region", + "service", + "type", + "provider", + "inserted_at", + "updated_at", + "uid", + ], + }); + + // Create dictionary for providers (removed findings dict since we're not including findings anymore) + const providerDict = createDict("providers", resourcesData); + + // Expand each resource with its corresponding provider (removed findings expansion) + const expandedResources = resourcesData?.data + ? resourcesData.data.map((resource: ResourceProps) => { + const provider = { + data: providerDict[resource.relationships.provider.data.id], + }; + + return { + ...resource, + relationships: { + ...resource.relationships, + provider, + }, + }; + }) + : []; + + return ( + <> + {resourcesData?.errors && ( +
+

Error:

+

{resourcesData.errors[0].detail}

+
+ )} + + + ); +}; diff --git a/ui/auth.config.ts b/ui/auth.config.ts index ed15ed4a38..a317a1a800 100644 --- a/ui/auth.config.ts +++ b/ui/auth.config.ts @@ -90,6 +90,7 @@ export const authConfig = { email: userMeResponse.email, company: userMeResponse?.company, dateJoined: userMeResponse.dateJoined, + permissions: userMeResponse.permissions, }; return { @@ -121,6 +122,8 @@ export const authConfig = { email: userMeResponse.email, company: userMeResponse?.company, dateJoined: userMeResponse.dateJoined, + + permissions: userMeResponse.permissions, }; return { @@ -171,6 +174,15 @@ export const authConfig = { companyName: user?.company, email: user?.email, dateJoined: user?.dateJoined, + permissions: user?.permissions || { + manage_users: false, + manage_account: false, + manage_providers: false, + manage_scans: false, + manage_integrations: false, + manage_billing: false, + unlimited_visibility: false, + }, }; if (account && user) { diff --git a/ui/components/auth/oss/auth-form.tsx b/ui/components/auth/oss/auth-form.tsx index 211d238341..a965204dbc 100644 --- a/ui/components/auth/oss/auth-form.tsx +++ b/ui/components/auth/oss/auth-form.tsx @@ -3,7 +3,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Icon } from "@iconify/react"; import { Button, Checkbox, Divider, Link, Tooltip } from "@nextui-org/react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -41,6 +42,24 @@ export const AuthForm = ({ }) => { const formSchema = authFormSchema(type); const router = useRouter(); + const searchParams = useSearchParams(); + const { toast } = useToast(); + + useEffect(() => { + const samlError = searchParams.get("sso_saml_failed"); + + if (samlError) { + // Add a delay to the toast to ensure it is rendered + setTimeout(() => { + toast({ + variant: "destructive", + title: "SAML Authentication Error", + description: + "An error occurred while attempting to login via your Identity Provider (IdP). Please check your IdP configuration.", + }); + }, 100); + } + }, [searchParams, toast]); const form = useForm>({ resolver: zodResolver(formSchema), @@ -58,7 +77,6 @@ export const AuthForm = ({ }); const isLoading = form.formState.isSubmitting; - const { toast } = useToast(); const isSamlMode = form.watch("isSamlMode"); const onSubmit = async (data: z.infer) => { diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx index 638cd3300d..bcee9fb8e0 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -160,9 +160,7 @@ export const ClientAccordionContent = ({ index !== 4 && index !== 7, - )} + columns={ColumnFindings.filter((_, index) => index !== 7)} data={expandedFindings || []} metadata={findings?.meta} disableScroll={true} diff --git a/ui/components/filters/custom-checkbox-muted-findings.tsx b/ui/components/filters/custom-checkbox-muted-findings.tsx index 05cae96522..6739617fb3 100644 --- a/ui/components/filters/custom-checkbox-muted-findings.tsx +++ b/ui/components/filters/custom-checkbox-muted-findings.tsx @@ -7,7 +7,7 @@ import { useState } from "react"; import { useUrlFilters } from "@/hooks/use-url-filters"; export const CustomCheckboxMutedFindings = () => { - const { updateFilter } = useUrlFilters(); + const { updateFilter, clearFilter } = useUrlFilters(); const searchParams = useSearchParams(); const [excludeMuted, setExcludeMuted] = useState( searchParams.get("filter[muted]") === "false", @@ -15,7 +15,13 @@ export const CustomCheckboxMutedFindings = () => { const handleMutedChange = (value: boolean) => { setExcludeMuted(value); - updateFilter("muted", value ? "false" : "true"); + + // Only URL update if value is false else remove filter + if (value) { + updateFilter("muted", "false"); + } else { + clearFilter("muted"); + } }; return ( diff --git a/ui/components/findings/table/finding-detail.tsx b/ui/components/findings/table/finding-detail.tsx index a32eeaa207..06d665658c 100644 --- a/ui/components/findings/table/finding-detail.tsx +++ b/ui/components/findings/table/finding-detail.tsx @@ -4,6 +4,7 @@ import { Snippet } from "@nextui-org/react"; import Link from "next/link"; import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; +import { CustomSection } from "@/components/ui/custom"; import { EntityInfoShort, InfoField } from "@/components/ui/entities"; import { DateWithTime } from "@/components/ui/entities/date-with-time"; import { SeverityBadge } from "@/components/ui/table/severity-badge"; @@ -16,21 +17,6 @@ const renderValue = (value: string | null | undefined) => { return value && value.trim() !== "" ? value : "-"; }; -const Section = ({ - title, - children, -}: { - title: string; - children: React.ReactNode; -}) => ( -
-

- {title} -

- {children} -
-); - // Add new utility function for duration formatting const formatDuration = (seconds: number) => { const hours = Math.floor(seconds / 3600); @@ -87,7 +73,7 @@ export const FindingDetail = ({ {/* Check Metadata */} -
+
{attributes.check_metadata.categories?.join(", ") || "-"} -
+ {/* Resource Details */} -
+ @@ -257,10 +243,10 @@ export const FindingDetail = ({ -
+ {/* Add new Scan Details section */} -
+
{scan.name || "N/A"} @@ -296,7 +282,7 @@ export const FindingDetail = ({ )}
-
+ ); }; diff --git a/ui/components/icons/Icons.tsx b/ui/components/icons/Icons.tsx index f2cfc22c6e..86ed943b65 100644 --- a/ui/components/icons/Icons.tsx +++ b/ui/components/icons/Icons.tsx @@ -1117,3 +1117,49 @@ export const KubernetesIcon: React.FC = ({ ); }; + +export const LighthouseIcon: React.FC = ({ + size = 24, + width, + height, + ...props +}) => { + return ( + + {/* Square container with rounded corners, broken top-right edge */} + + + {/* Slightly smaller center star */} + + + {/* Small star in top-right corner */} + + + ); +}; diff --git a/ui/components/lighthouse/chat.tsx b/ui/components/lighthouse/chat.tsx index cfc350f4da..c31c6a08b9 100644 --- a/ui/components/lighthouse/chat.tsx +++ b/ui/components/lighthouse/chat.tsx @@ -135,8 +135,8 @@ export const Chat = ({ hasConfig, isActive }: ChatProps) => {

{!hasConfig - ? "Please configure your OpenAI API key to use Lighthouse." - : "OpenAI API key is invalid. Please update your key to use Lighthouse."} + ? "Please configure your OpenAI API key to use Lighthouse AI." + : "OpenAI API key is invalid. Please update your key to use Lighthouse AI."}

= ({ return ( -
+
= ({ -
+
= ({
- {shouldShowMuted && ( -
-
- - } - color="warning" - radius="lg" - size="md" +
+ {shouldShowMuted ? ( + <> +
+ - {chartData.find((item) => item.findings === "Muted") - ?.number || 0} - - - {updatedChartData.find( - (item) => item.findings === "Muted", - )?.percent || "0%"} - - -
-
- {muted_new > 0 ? ( - <> - +{muted_new} muted findings from last day{" "} - - - ) : muted_new < 0 ? ( - <>{muted_new} muted findings from last day - ) : ( - "No change from last day" - )} -
-
- )} + } + color="warning" + radius="lg" + size="md" + > + {chartData.find((item) => item.findings === "Muted") + ?.number || 0} + + + {updatedChartData.find( + (item) => item.findings === "Muted", + )?.percent || "0%"} + + +
+
+ {muted_new > 0 ? ( + <> + +{muted_new} muted findings from last day{" "} + + + ) : muted_new < 0 ? ( + <>{muted_new} muted findings from last day + ) : ( + "No change from last day" + )} +
+ + ) : null} +
diff --git a/ui/components/providers/workflow/workflow-add-provider.tsx b/ui/components/providers/workflow/workflow-add-provider.tsx index edad4beed8..4775fc6045 100644 --- a/ui/components/providers/workflow/workflow-add-provider.tsx +++ b/ui/components/providers/workflow/workflow-add-provider.tsx @@ -27,14 +27,38 @@ const steps = [ }, ]; +const ROUTE_CONFIG: Record< + string, + { + stepIndex: number; + stepOverride?: { index: number; title: string; description: string }; + } +> = { + "/providers/connect-account": { stepIndex: 0 }, + "/providers/add-credentials": { stepIndex: 1 }, + "/providers/test-connection": { stepIndex: 2 }, + "/providers/update-credentials": { + stepIndex: 1, + stepOverride: { + index: 2, + title: "Make sure the new credentials are valid", + description: "Valid credentials will take you back to the providers page", + }, + }, +}; + export const WorkflowAddProvider = () => { const pathname = usePathname(); - // Calculate current step based on pathname - const currentStepIndex = steps.findIndex((step) => - pathname.endsWith(step.href), - ); - const currentStep = currentStepIndex === -1 ? 0 : currentStepIndex; + const config = ROUTE_CONFIG[pathname] || { stepIndex: 0 }; + const currentStep = config.stepIndex; + + const updatedSteps = steps.map((step, index) => { + if (config.stepOverride && index === config.stepOverride.index) { + return { ...step, ...config.stepOverride }; + } + return step; + }); return (
@@ -63,7 +87,7 @@ export const WorkflowAddProvider = () => { hideProgressBars currentStep={currentStep} stepClassName="border border-default-200 dark:border-default-50 aria-[current]:bg-default-100 dark:aria-[current]:bg-prowler-blue-800 cursor-default" - steps={steps} + steps={updatedSteps} />
diff --git a/ui/components/resources/skeleton/skeleton-finding-details.tsx b/ui/components/resources/skeleton/skeleton-finding-details.tsx new file mode 100644 index 0000000000..dd5bfb8be5 --- /dev/null +++ b/ui/components/resources/skeleton/skeleton-finding-details.tsx @@ -0,0 +1,76 @@ +import React from "react"; + +export const SkeletonFindingDetails = () => { + return ( +
+ {/* Header */} +
+
+
+
+
+
+
+ + {/* Metadata Section */} +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+ ))} +
+ + {/* InfoField Blocks */} + {Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+ ))} + + {/* Risk and Description Sections */} +
+
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+ + {/* Additional Resources */} +
+
+
+
+ + {/* Categories */} +
+
+
+
+ + {/* Provider Info Section */} +
+
+
+
+
+
+
+
+
+
+ ); +}; diff --git a/ui/components/resources/skeleton/skeleton-finding-summary.tsx b/ui/components/resources/skeleton/skeleton-finding-summary.tsx new file mode 100644 index 0000000000..f3bc958c79 --- /dev/null +++ b/ui/components/resources/skeleton/skeleton-finding-summary.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +export const SkeletonFindingSummary = () => { + return ( +
+
+
+
+
+
+
+
+
+
+ ); +}; diff --git a/ui/components/resources/skeleton/skeleton-table-resources.tsx b/ui/components/resources/skeleton/skeleton-table-resources.tsx new file mode 100644 index 0000000000..92db74fce9 --- /dev/null +++ b/ui/components/resources/skeleton/skeleton-table-resources.tsx @@ -0,0 +1,65 @@ +import { Card, Skeleton } from "@nextui-org/react"; +import React from "react"; + +export const SkeletonTableResources = () => { + return ( + + {/* Table headers */} +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + {/* Table body */} +
+ {[...Array(3)].map((_, index) => ( +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ ))} +
+
+ ); +}; diff --git a/ui/components/resources/table/column-resources.tsx b/ui/components/resources/table/column-resources.tsx new file mode 100644 index 0000000000..c1dab5f9c9 --- /dev/null +++ b/ui/components/resources/table/column-resources.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Database } from "lucide-react"; +import { useSearchParams } from "next/navigation"; + +import { InfoIcon } from "@/components/icons"; +import { EntityInfoShort, SnippetChip } from "@/components/ui/entities"; +import { TriggerSheet } from "@/components/ui/sheet"; +import { DataTableColumnHeader } from "@/components/ui/table"; +import { ProviderType, ResourceProps } from "@/types"; + +import { ResourceDetail } from "./resource-detail"; + +const getResourceData = ( + row: { original: ResourceProps }, + field: keyof ResourceProps["attributes"], +) => { + return row.original.attributes?.[field]; +}; + +const getChipStyle = (count: number) => { + if (count === 0) return "bg-green-100 text-green-800"; + if (count >= 10) return "bg-red-100 text-red-800"; + if (count >= 1) return "bg-yellow-100 text-yellow-800"; +}; + +const getProviderData = ( + row: { original: ResourceProps }, + field: keyof ResourceProps["relationships"]["provider"]["data"]["attributes"], +) => { + return ( + row.original.relationships?.provider?.data?.attributes?.[field] ?? + `No ${field} found in provider` + ); +}; + +const ResourceDetailsCell = ({ row }: { row: any }) => { + const searchParams = useSearchParams(); + const resourceId = searchParams.get("resourceId"); + const isOpen = resourceId === row.original.id; + + return ( +
+ } + title="Resource Details" + description="View the Resource details" + defaultOpen={isOpen} + > + + +
+ ); +}; + +export const ColumnResources: ColumnDef[] = [ + { + id: "moreInfo", + header: "Details", + cell: ({ row }) => , + }, + { + accessorKey: "resourceName", + header: "Resource name", + cell: ({ row }) => { + const resourceName = getResourceData(row, "name"); + + return ( + `...${value.slice(-30)}`} + className="w-[300px] truncate" + icon={} + /> + ); + }, + }, + { + accessorKey: "failedFindings", + header: () =>
Failed Findings
, + cell: ({ row }) => { + const failedFindingsCount = getResourceData( + row, + "failed_findings_count", + ) as number; + + return ( + <> +

+ + {failedFindingsCount} + +

+ + ); + }, + }, + { + accessorKey: "region", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const region = getResourceData(row, "region"); + + return ( +
+ {typeof region === "string" ? region : "Invalid region"} +
+ ); + }, + }, + { + accessorKey: "type", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const type = getResourceData(row, "type"); + + return ( +
+ {typeof type === "string" ? type : "Invalid type"} +
+ ); + }, + }, + { + accessorKey: "service", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const service = getResourceData(row, "service"); + + return ( +
+ {typeof service === "string" ? service : "Invalid region"} +
+ ); + }, + }, + { + accessorKey: "provider", + header: "Cloud Provider", + cell: ({ row }) => { + const provider = getProviderData(row, "provider"); + const alias = getProviderData(row, "alias"); + const uid = getProviderData(row, "uid"); + return ( + <> + + + ); + }, + }, +]; diff --git a/ui/components/resources/table/index.ts b/ui/components/resources/table/index.ts new file mode 100644 index 0000000000..5f18c9b1d4 --- /dev/null +++ b/ui/components/resources/table/index.ts @@ -0,0 +1,3 @@ +export * from "../skeleton/skeleton-table-resources"; +export * from "./column-resources"; +export * from "./resource-detail"; diff --git a/ui/components/resources/table/resource-detail.tsx b/ui/components/resources/table/resource-detail.tsx new file mode 100644 index 0000000000..1f92dd41cb --- /dev/null +++ b/ui/components/resources/table/resource-detail.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { Snippet, Spinner } from "@nextui-org/react"; +import { InfoIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { getFindingById } from "@/actions/findings"; +import { getResourceById } from "@/actions/resources"; +import { FindingDetail } from "@/components/findings/table/finding-detail"; +import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui"; +import { CustomSection } from "@/components/ui/custom"; +import { + DateWithTime, + EntityInfoShort, + InfoField, +} from "@/components/ui/entities"; +import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table"; +import { createDict } from "@/lib"; +import { FindingProps, ProviderType, ResourceProps } from "@/types"; + +const renderValue = (value: string | null | undefined) => { + return value && value.trim() !== "" ? value : "-"; +}; + +const buildCustomBreadcrumbs = ( + _resourceName: string, + findingTitle?: string, + onBackToResource?: () => void, +): CustomBreadcrumbItem[] => { + const breadcrumbs: CustomBreadcrumbItem[] = [ + { + name: "Resource Details", + isClickable: !!findingTitle, + onClick: findingTitle ? onBackToResource : undefined, + isLast: !findingTitle, + }, + ]; + + if (findingTitle) { + breadcrumbs.push({ + name: findingTitle, + isLast: true, + isClickable: false, + }); + } + + return breadcrumbs; +}; + +export const ResourceDetail = ({ + resourceId, + initialResourceData, +}: { + resourceId: string; + initialResourceData: ResourceProps; +}) => { + const [findingsData, setFindingsData] = useState([]); + const [resourceTags, setResourceTags] = useState>({}); + const [findingsLoading, setFindingsLoading] = useState(true); + const [selectedFindingId, setSelectedFindingId] = useState( + null, + ); + const [findingDetails, setFindingDetails] = useState( + null, + ); + + useEffect(() => { + const loadFindings = async () => { + setFindingsLoading(true); + + try { + const resourceData = await getResourceById(resourceId, { + include: ["findings"], + fields: ["tags", "findings"], + }); + + if (resourceData?.data) { + // Get tags from the detailed resource data + setResourceTags(resourceData.data.attributes.tags || {}); + + // Create dictionary for findings and expand them + if (resourceData.data.relationships?.findings) { + const findingsDict = createDict("findings", resourceData); + const findings = + resourceData.data.relationships.findings.data?.map( + (finding: any) => findingsDict[finding.id], + ) || []; + setFindingsData(findings); + } else { + setFindingsData([]); + } + } else { + setFindingsData([]); + setResourceTags({}); + } + } catch (err) { + console.error("Error loading findings:", err); + setFindingsData([]); + setResourceTags({}); + } finally { + setFindingsLoading(false); + } + }; + + if (resourceId) { + loadFindings(); + } + }, [resourceId]); + + const navigateToFinding = async (findingId: string) => { + setSelectedFindingId(findingId); + + try { + const findingData = await getFindingById( + findingId, + "resources,scan.provider", + ); + if (findingData?.data) { + // Create dictionaries for resources, scans, and providers + const resourceDict = createDict("resources", findingData); + const scanDict = createDict("scans", findingData); + const providerDict = createDict("providers", findingData); + + // Expand the finding with its corresponding resource, scan, and provider + const finding = findingData.data; + const scan = scanDict[finding.relationships?.scan?.data?.id]; + const resource = + resourceDict[finding.relationships?.resources?.data?.[0]?.id]; + const provider = providerDict[scan?.relationships?.provider?.data?.id]; + + const expandedFinding = { + ...finding, + relationships: { scan, resource, provider }, + }; + + setFindingDetails(expandedFinding); + } + } catch (error) { + console.error("Error fetching finding:", error); + } + }; + + const handleBackToResource = () => { + setSelectedFindingId(null); + setFindingDetails(null); + }; + + if (!initialResourceData) { + return ( +
+ +

+ Loading resource details... +

+
+ ); + } + + const resource = initialResourceData; + const attributes = resource.attributes; + const providerData = resource.relationships.provider.data.attributes; + const allFindings = findingsData; + + if (selectedFindingId) { + const findingTitle = + findingDetails?.attributes?.check_metadata?.checktitle || + "Finding Detail"; + + return ( +
+ + + {findingDetails && } +
+ ); + } + + return ( +
+ {/* Resource Details section */} + +
+ + + + {renderValue(attributes.uid)} + + + +
+ +
+
+ +
+ + {renderValue(attributes.name)} + + + {renderValue(attributes.type)} + +
+
+ + {renderValue(attributes.service)} + + {renderValue(attributes.region)} +
+
+ + + + + + +
+ + {resourceTags && Object.entries(resourceTags).length > 0 ? ( +
+

+ Tags +

+
+ {Object.entries(resourceTags).map(([key, value]) => ( + + {renderValue(value)} + + ))} +
+
+ ) : null} +
+ + {/* Finding associated with this resource section */} + + {findingsLoading ? ( +
+ +

+ Loading findings... +

+
+ ) : allFindings.length > 0 ? ( +
+

+ Total findings: {allFindings.length} +

+ {allFindings.map((finding: any, index: number) => { + const { attributes: findingAttrs, id } = finding; + + // Handle cases where finding might not have all attributes + if (!findingAttrs) { + return ( +
+

+ Finding {id} - No attributes available +

+
+ ); + } + + const { severity, check_metadata, status } = findingAttrs; + const checktitle = check_metadata?.checktitle || "Unknown check"; + + return ( + + ); + })} +
+ ) : ( +

+ No findings found for this resource. +

+ )} +
+
+ ); +}; diff --git a/ui/components/scans/index.ts b/ui/components/scans/index.ts index b365e95f16..a1493163cc 100644 --- a/ui/components/scans/index.ts +++ b/ui/components/scans/index.ts @@ -1,5 +1,4 @@ export * from "./auto-refresh"; -export * from "./link-to-findings-from-scan"; export * from "./no-providers-added"; export * from "./no-providers-connected"; export * from "./scans-filters"; diff --git a/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx b/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx index 74bfbf8fde..bca5c061b5 100644 --- a/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx +++ b/ui/components/scans/launch-workflow/launch-scan-workflow-form.tsx @@ -63,13 +63,11 @@ export const LaunchScanWorkflow = ({ const data = await scanOnDemand(formData); - if (data?.errors && data.errors.length > 0) { - const error = data.errors[0]; - const errorMessage = `${error.detail}`; + if (data?.error) { toast({ variant: "destructive", title: "Oops! Something went wrong", - description: errorMessage, + description: data.error, }); } else { toast({ diff --git a/ui/components/scans/table/scans/column-get-scans.tsx b/ui/components/scans/table/scans/column-get-scans.tsx index 4d6796b6bc..f0b832e5d1 100644 --- a/ui/components/scans/table/scans/column-get-scans.tsx +++ b/ui/components/scans/table/scans/column-get-scans.tsx @@ -5,12 +5,12 @@ import { ColumnDef } from "@tanstack/react-table"; import { useSearchParams } from "next/navigation"; import { InfoIcon } from "@/components/icons"; +import { TableLink } from "@/components/ui/custom"; import { DateWithTime, EntityInfoShort } from "@/components/ui/entities"; import { TriggerSheet } from "@/components/ui/sheet"; import { DataTableColumnHeader, StatusBadge } from "@/components/ui/table"; import { ProviderType, ScanProps } from "@/types"; -import { LinkToFindingsFromScan } from "../../link-to-findings-from-scan"; import { TriggerIcon } from "../../trigger-icon"; import { DataTableDownloadDetails } from "./data-table-download-details"; import { DataTableRowActions } from "./data-table-row-actions"; @@ -106,9 +106,25 @@ export const ColumnGetScans: ColumnDef[] = [ const { id } = getScanData(row); const scanState = row.original.attributes?.state; return ( - + ); + }, + }, + { + accessorKey: "compliance", + header: "Compliance", + cell: ({ row }) => { + const { id } = getScanData(row); + const scanState = row.original.attributes?.state; + return ( + ); }, @@ -120,7 +136,7 @@ export const ColumnGetScans: ColumnDef[] = [

Download

diff --git a/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx b/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx new file mode 100644 index 0000000000..6f4daf047b --- /dev/null +++ b/ui/components/ui/breadcrumbs/breadcrumb-navigation.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { Icon } from "@iconify/react"; +import { BreadcrumbItem, Breadcrumbs } from "@nextui-org/react"; +import Link from "next/link"; +import { usePathname, useSearchParams } from "next/navigation"; +import { ReactNode } from "react"; + +export interface CustomBreadcrumbItem { + name: string; + path?: string; + isLast?: boolean; + isClickable?: boolean; + onClick?: () => void; +} + +interface BreadcrumbNavigationProps { + // For automatic breadcrumbs (like navbar) + mode?: "auto" | "custom" | "hybrid"; + title?: string; + icon?: string | ReactNode; + + // For custom breadcrumbs (like resource-detail) + customItems?: CustomBreadcrumbItem[]; + + // Common options + className?: string; + paramToPreserve?: string; + showTitle?: boolean; +} + +export function BreadcrumbNavigation({ + mode = "auto", + title, + icon, + customItems = [], + className = "", + paramToPreserve = "scanId", + showTitle = true, +}: BreadcrumbNavigationProps) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const generateAutoBreadcrumbs = (): CustomBreadcrumbItem[] => { + const pathSegments = pathname + .split("/") + .filter((segment) => segment !== ""); + + if (pathSegments.length === 0) { + return [{ name: "Home", path: "/", isLast: true }]; + } + + const breadcrumbs: CustomBreadcrumbItem[] = []; + let currentPath = ""; + + pathSegments.forEach((segment, index) => { + currentPath += `/${segment}`; + const isLast = index === pathSegments.length - 1; + let displayName = segment.charAt(0).toUpperCase() + segment.slice(1); + + // Special cases: + if (segment.includes("-")) { + displayName = segment + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + } + + breadcrumbs.push({ + name: displayName, + path: currentPath, + isLast, + isClickable: !isLast, + }); + }); + + return breadcrumbs; + }; + + const buildNavigationUrl = (path: string) => { + const paramValue = searchParams.get(paramToPreserve); + if (path === "/compliance" && paramValue) { + return `/compliance?${paramToPreserve}=${paramValue}`; + } + return path; + }; + + const renderTitleWithIcon = (titleText: string, isLink: boolean = false) => ( + <> + {typeof icon === "string" ? ( + + ) : icon ? ( +
+ {icon} +
+ ) : null} +

+ {titleText} +

+ + ); + + // Determine which breadcrumbs to use + let breadcrumbItems: CustomBreadcrumbItem[] = []; + + switch (mode) { + case "auto": + breadcrumbItems = generateAutoBreadcrumbs(); + break; + case "custom": + breadcrumbItems = customItems; + break; + case "hybrid": + breadcrumbItems = [...generateAutoBreadcrumbs(), ...customItems]; + break; + } + + return ( +
+ + {breadcrumbItems.map((breadcrumb, index) => ( + + {breadcrumb.isLast && showTitle && title ? ( + renderTitleWithIcon(title) + ) : breadcrumb.isClickable && breadcrumb.path ? ( + + + {breadcrumb.name} + + + ) : breadcrumb.isClickable && breadcrumb.onClick ? ( + + ) : ( + + {breadcrumb.name} + + )} + + ))} + +
+ ); +} diff --git a/ui/components/ui/breadcrumbs/index.ts b/ui/components/ui/breadcrumbs/index.ts new file mode 100644 index 0000000000..908d780e87 --- /dev/null +++ b/ui/components/ui/breadcrumbs/index.ts @@ -0,0 +1 @@ +export * from "./breadcrumb-navigation"; diff --git a/ui/components/ui/content-layout/content-layout.tsx b/ui/components/ui/content-layout/content-layout.tsx index 518fb58efa..181cdea764 100644 --- a/ui/components/ui/content-layout/content-layout.tsx +++ b/ui/components/ui/content-layout/content-layout.tsx @@ -1,9 +1,8 @@ -import { ReactNode, Suspense, use } from "react"; +"use client"; -import { getUserInfo } from "@/actions/users/users"; +import { ReactNode } from "react"; import { Navbar } from "../nav-bar/navbar"; -import { SkeletonContentLayout } from "./skeleton-content-layout"; interface ContentLayoutProps { title: string; @@ -12,13 +11,9 @@ interface ContentLayoutProps { } export function ContentLayout({ title, icon, children }: ContentLayoutProps) { - const user = use(getUserInfo()); - return ( <> - }> - - +
{children}
); diff --git a/ui/components/ui/custom/custom-section.tsx b/ui/components/ui/custom/custom-section.tsx new file mode 100644 index 0000000000..8412b000a1 --- /dev/null +++ b/ui/components/ui/custom/custom-section.tsx @@ -0,0 +1,21 @@ +interface CustomSectionProps { + title: string; + children: React.ReactNode; + action?: React.ReactNode; +} + +export const CustomSection = ({ + title, + children, + action, +}: CustomSectionProps) => ( +
+
+

+ {title} +

+ {action &&
{action}
} +
+ {children} +
+); diff --git a/ui/components/scans/link-to-findings-from-scan.tsx b/ui/components/ui/custom/custom-table-link.tsx similarity index 50% rename from ui/components/scans/link-to-findings-from-scan.tsx rename to ui/components/ui/custom/custom-table-link.tsx index 6f94773cee..0ba4f173c3 100644 --- a/ui/components/scans/link-to-findings-from-scan.tsx +++ b/ui/components/ui/custom/custom-table-link.tsx @@ -2,25 +2,26 @@ import { CustomButton } from "@/components/ui/custom"; -interface LinkToFindingsProps { - scanId?: string; +interface TableLinkProps { + href: string; + label: string; isDisabled?: boolean; } -export const LinkToFindingsFromScan = ({ - scanId, - isDisabled, -}: LinkToFindingsProps) => { +export const TableLink = ({ href, label, isDisabled }: TableLinkProps) => { return ( + // TODO: Replace CustomButton with CustomLink once the CustomLink component is merged. - See Findings + {label} ); }; + +TableLink.displayName = "TableLink"; diff --git a/ui/components/ui/custom/index.ts b/ui/components/ui/custom/index.ts index 4e7a55b03f..b263ce20d7 100644 --- a/ui/components/ui/custom/index.ts +++ b/ui/components/ui/custom/index.ts @@ -6,5 +6,7 @@ export * from "./custom-dropdown-selection"; export * from "./custom-input"; export * from "./custom-loader"; export * from "./custom-radio"; +export * from "./custom-section"; export * from "./custom-server-input"; +export * from "./custom-table-link"; export * from "./custom-textarea"; diff --git a/ui/components/ui/entities/info-field.tsx b/ui/components/ui/entities/info-field.tsx index 3d75bb98ae..0aec6a98dd 100644 --- a/ui/components/ui/entities/info-field.tsx +++ b/ui/components/ui/entities/info-field.tsx @@ -13,7 +13,7 @@ interface InfoFieldProps {
@@ -64,7 +64,7 @@ export const InfoField = ({ {variant === "simple" ? ( -
+
{children}
) : variant === "transparent" ? ( diff --git a/ui/components/ui/feedback-banner/feedback-banner.tsx b/ui/components/ui/feedback-banner/feedback-banner.tsx new file mode 100644 index 0000000000..806761e5ed --- /dev/null +++ b/ui/components/ui/feedback-banner/feedback-banner.tsx @@ -0,0 +1,66 @@ +import { AlertIcon } from "@/components/icons/Icons"; +import { cn } from "@/lib/utils"; + +type FeedbackType = "error" | "warning" | "info" | "success"; + +interface FeedbackBannerProps { + type?: FeedbackType; + title: string; + message: string; + className?: string; +} + +const typeStyles: Record< + FeedbackType, + { border: string; bg: string; text: string } +> = { + error: { + border: "border-danger", + bg: "bg-system-error-light/30 dark:bg-system-error-light/80", + text: "text-danger", + }, + warning: { + border: "border-warning", + bg: "bg-yellow-100 dark:bg-yellow-200", + text: "text-yellow-800", + }, + info: { + border: "border-blue-400", + bg: "bg-blue-50 dark:bg-blue-100", + text: "text-blue-800", + }, + success: { + border: "border-green-500", + bg: "bg-green-50 dark:bg-green-100", + text: "text-green-800", + }, +}; + +export const FeedbackBanner: React.FC = ({ + type = "info", + title, + message, + className, +}) => { + const styles = typeStyles[type]; + + return ( +
+
+ + + +

+ {title} {message} +

+
+
+ ); +}; diff --git a/ui/components/ui/index.ts b/ui/components/ui/index.ts index 6fb4555d80..205ec7fd50 100644 --- a/ui/components/ui/index.ts +++ b/ui/components/ui/index.ts @@ -2,11 +2,13 @@ export * from "./accordion/Accordion"; export * from "./action-card/ActionCard"; export * from "./alert/Alert"; export * from "./alert-dialog/AlertDialog"; +export * from "./breadcrumbs"; export * from "./chart/Chart"; export * from "./content-layout/content-layout"; export * from "./dialog/dialog"; export * from "./download-icon-button/download-icon-button"; export * from "./dropdown/Dropdown"; +export * from "./feedback-banner/feedback-banner"; export * from "./headers/navigation-header"; export * from "./label/Label"; export * from "./main-layout/main-layout"; diff --git a/ui/components/ui/nav-bar/navbar.tsx b/ui/components/ui/nav-bar/navbar.tsx index 6d7c633e17..1a0c0015da 100644 --- a/ui/components/ui/nav-bar/navbar.tsx +++ b/ui/components/ui/nav-bar/navbar.tsx @@ -1,13 +1,9 @@ "use client"; -import { Icon } from "@iconify/react"; -import { BreadcrumbItem, Breadcrumbs } from "@nextui-org/react"; -import Link from "next/link"; -import { usePathname, useSearchParams } from "next/navigation"; import { ReactNode } from "react"; import { ThemeSwitch } from "@/components/ThemeSwitch"; -import { UserProfileProps } from "@/types"; +import { BreadcrumbNavigation } from "@/components/ui"; import { SheetMenu } from "../sidebar/sheet-menu"; import { UserNav } from "../user-nav/user-nav"; @@ -15,109 +11,24 @@ import { UserNav } from "../user-nav/user-nav"; interface NavbarProps { title: string; icon: string | ReactNode; - user: UserProfileProps; } -interface BreadcrumbItem { - name: string; - path: string; - isLast: boolean; -} - -export function Navbar({ title, icon, user }: NavbarProps) { - const pathname = usePathname(); - const searchParams = useSearchParams(); - - const generateBreadcrumbs = (): BreadcrumbItem[] => { - const pathSegments = pathname - .split("/") - .filter((segment) => segment !== ""); - - if (pathSegments.length === 0) { - return [{ name: "Home", path: "/", isLast: true }]; - } - - const breadcrumbs: BreadcrumbItem[] = []; - let currentPath = ""; - - pathSegments.forEach((segment, index) => { - currentPath += `/${segment}`; - const isLast = index === pathSegments.length - 1; - let displayName = segment.charAt(0).toUpperCase() + segment.slice(1); - - //special cases: - if (segment.includes("-")) { - displayName = segment - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); - } - - breadcrumbs.push({ - name: displayName, - path: currentPath, - isLast, - }); - }); - - return breadcrumbs; - }; - - const buildNavigationUrl = (paramToPreserve: string, path: string) => { - const paramValue = searchParams.get(paramToPreserve); - if (path === "/compliance" && paramValue) { - return `/compliance?${paramToPreserve}=${paramValue}`; - } - - return path; - }; - - const renderTitleWithIcon = (titleText: string, isLink: boolean = false) => ( - <> - {typeof icon === "string" ? ( - - ) : ( -
- {icon} -
- )} -

- {titleText} -

- - ); - - const breadcrumbs = generateBreadcrumbs(); - +export function Navbar({ title, icon }: NavbarProps) { return (
- - {breadcrumbs.map((breadcrumb) => ( - - {breadcrumb.isLast ? ( - renderTitleWithIcon(title) - ) : ( - -

- {breadcrumb.name} -

- - )} -
- ))} -
+
- +
diff --git a/ui/components/ui/sidebar/menu.tsx b/ui/components/ui/sidebar/menu.tsx index f8c1188f10..7a1e20f261 100644 --- a/ui/components/ui/sidebar/menu.tsx +++ b/ui/components/ui/sidebar/menu.tsx @@ -14,6 +14,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip/tooltip"; +import { useAuth } from "@/hooks"; import { getMenuList } from "@/lib/menu-list"; import { cn } from "@/lib/utils"; @@ -21,10 +22,54 @@ import { Button } from "../button/button"; import { CustomButton } from "../custom/custom-button"; import { ScrollArea } from "../scroll-area/scroll-area"; +interface MenuHideRule { + label: string; + condition: (permissions: any) => boolean; +} + +// Configuration for hiding menu items based on permissions +const MENU_HIDE_RULES: MenuHideRule[] = [ + { + label: "Billing", + condition: (permissions) => permissions?.manage_billing === false, + }, + // Add more rules as needed: + // { + // label: "Users", + // condition: (permissions) => !permissions?.manage_users + // }, + // { + // label: "Configuration", + // condition: (permissions) => !permissions?.manage_providers + // }, +]; + +const hideMenuItems = (menuGroups: any[], labelsToHide: string[]) => { + return menuGroups.map((group) => ({ + ...group, + menus: group.menus + .filter((menu: any) => !labelsToHide.includes(menu.label)) + .map((menu: any) => ({ + ...menu, + submenus: + menu.submenus?.filter( + (submenu: any) => !labelsToHide.includes(submenu.label), + ) || [], + })), + })); +}; + export const Menu = ({ isOpen }: { isOpen: boolean }) => { const pathname = usePathname(); + const { permissions } = useAuth(); const menuList = getMenuList(pathname); + const labelsToHide = MENU_HIDE_RULES.filter((rule) => + rule.condition(permissions), + ).map((rule) => rule.label); + + const filteredMenuList = hideMenuItems(menuList, labelsToHide); + return ( <>
@@ -43,7 +88,7 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
diff --git a/ui/hooks/index.ts b/ui/hooks/index.ts index 1c42f6d01f..f03f9b4ba0 100644 --- a/ui/hooks/index.ts +++ b/ui/hooks/index.ts @@ -1,6 +1,8 @@ +export * from "./use-auth"; export * from "./use-credentials-form"; export * from "./use-form-server-errors"; export * from "./use-local-storage"; export * from "./use-related-filters"; export * from "./use-sidebar"; export * from "./use-store"; +export * from "./use-url-filters"; diff --git a/ui/hooks/use-auth.ts b/ui/hooks/use-auth.ts new file mode 100644 index 0000000000..58b55349d5 --- /dev/null +++ b/ui/hooks/use-auth.ts @@ -0,0 +1,31 @@ +import { useSession } from "next-auth/react"; + +export function useAuth() { + const { data: session, status } = useSession(); + + const isLoading = status === "loading"; + const isAuthenticated = !!session?.user; + + const permissions = session?.user?.permissions || { + manage_users: false, + manage_account: false, + manage_providers: false, + manage_scans: false, + manage_integrations: false, + manage_billing: false, + unlimited_visibility: false, + }; + + const hasPermission = (permission: keyof typeof permissions) => { + return permissions[permission] === true; + }; + + return { + session, + isLoading, + isAuthenticated, + user: session?.user, + permissions, + hasPermission, + }; +} diff --git a/ui/lib/helper-filters.ts b/ui/lib/helper-filters.ts index 416982fb85..15ac934c29 100644 --- a/ui/lib/helper-filters.ts +++ b/ui/lib/helper-filters.ts @@ -48,6 +48,32 @@ export const extractSortAndKey = (searchParams: Record) => { return { searchParamsKey, rawSort, encodedSort }; }; +/** + * Replaces a specific field name inside a filter-style key of an object. + * @param obj - The input object with filter-style keys (e.g., { 'filter[inserted_at]': '2025-05-21' }). + * @param oldField - The field name to be replaced (e.g., 'inserted_at'). + * @param newField - The field name to replace with (e.g., 'updated_at'). + * @returns A new object with the updated filter key if a match is found. + */ +export function replaceFieldKey( + obj: Record, + oldField: string, + newField: string, +): Record { + const fieldObj: Record = {}; + + for (const key in obj) { + const match = key.match(/^filter\[(.+)\]$/); + if (match && match[1] === oldField) { + const newKey = `filter[${newField}]`; + fieldObj[newKey] = obj[key]; + } else { + fieldObj[key] = obj[key]; + } + } + + return fieldObj; +} export const isScanEntity = (entity: ScanEntity) => { return entity && entity.providerInfo && entity.attributes; }; diff --git a/ui/lib/index.ts b/ui/lib/index.ts index 8d50d049be..d5a6948833 100644 --- a/ui/lib/index.ts +++ b/ui/lib/index.ts @@ -3,4 +3,5 @@ export * from "./external-urls"; export * from "./helper"; export * from "./helper-filters"; export * from "./menu-list"; +export * from "./permissions"; export * from "./utils"; diff --git a/ui/lib/menu-list.ts b/ui/lib/menu-list.ts index 454d320ce6..190d1af81e 100644 --- a/ui/lib/menu-list.ts +++ b/ui/lib/menu-list.ts @@ -3,12 +3,12 @@ import { AlertCircle, Bookmark, - Bot, CloudCog, Cog, Group, LayoutGrid, Mail, + Package, Settings, ShieldCheck, SquareChartGantt, @@ -18,6 +18,7 @@ import { User, UserCog, Users, + Warehouse, } from "lucide-react"; import { @@ -28,6 +29,7 @@ import { DocIcon, GCPIcon, KubernetesIcon, + LighthouseIcon, M365Icon, SupportIcon, } from "@/components/icons/Icons"; @@ -60,13 +62,22 @@ export const getMenuList = (pathname: string): GroupProps[] => { }, ], }, - { - groupLabel: "Issues", + groupLabel: "", + menus: [ + { + href: "/lighthouse", + label: "Lighthouse AI", + icon: LighthouseIcon, + }, + ], + }, + { + groupLabel: "", menus: [ { href: "", - label: "Top failed issues", + label: "Top failed findings", icon: Bookmark, submenus: [ { @@ -122,9 +133,26 @@ export const getMenuList = (pathname: string): GroupProps[] => { }, ], }, - { - groupLabel: "Settings", + groupLabel: "", + menus: [ + { + href: "", + label: "Resources", + icon: Warehouse, + submenus: [ + { + href: "/resources", + label: "Browse all resources", + icon: Package, + }, + ], + defaultOpen: true, + }, + ], + }, + { + groupLabel: "", menus: [ { href: "", @@ -135,14 +163,14 @@ export const getMenuList = (pathname: string): GroupProps[] => { { href: "/manage-groups", label: "Provider Groups", icon: Group }, { href: "/scans", label: "Scan Jobs", icon: Timer }, { href: "/roles", label: "Roles", icon: UserCog }, - { href: "/lighthouse/config", label: "Lighthouse", icon: Cog }, + { href: "/lighthouse/config", label: "Lighthouse AI", icon: Cog }, ], defaultOpen: true, }, ], }, { - groupLabel: "Workspace", + groupLabel: "", menus: [ { href: "", @@ -156,16 +184,6 @@ export const getMenuList = (pathname: string): GroupProps[] => { }, ], }, - { - groupLabel: "Prowler Lighthouse", - menus: [ - { - href: "/lighthouse", - label: "Lighthouse", - icon: Bot, - }, - ], - }, { groupLabel: "", menus: [ diff --git a/ui/middleware.ts b/ui/middleware.ts index f6f1af8974..54116aaa57 100644 --- a/ui/middleware.ts +++ b/ui/middleware.ts @@ -1,10 +1,49 @@ -import NextAuth from "next-auth"; +import { NextRequest, NextResponse } from "next/server"; -import { authConfig } from "./auth.config"; +import { auth } from "@/auth.config"; -export default NextAuth(authConfig).auth; +const publicRoutes = [ + "/sign-in", + "/sign-up", + // In Cloud uncomment the following lines: + // "/reset-password", + // "/email-verification", + // "/set-password", +]; + +const isPublicRoute = (pathname: string): boolean => { + return publicRoutes.some((route) => pathname.startsWith(route)); +}; + +export default auth((req: NextRequest & { auth: any }) => { + const { pathname } = req.nextUrl; + const user = req.auth?.user; + + if (!user && !isPublicRoute(pathname)) { + return NextResponse.redirect(new URL("/sign-in", req.url)); + } + + if (user?.permissions) { + const permissions = user.permissions; + + if (pathname.startsWith("/billing") && !permissions.manage_billing) { + return NextResponse.redirect(new URL("/profile", req.url)); + } + } + + return NextResponse.next(); +}); export const config = { - // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher - matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"], + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - *.png, *.jpg, *.jpeg, *.svg, *.ico (image files) + */ + "/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|svg|ico|css|js)$).*)", + ], }; diff --git a/ui/nextauth.d.ts b/ui/nextauth.d.ts index d5b6f472f9..ffb22bc89b 100644 --- a/ui/nextauth.d.ts +++ b/ui/nextauth.d.ts @@ -1,11 +1,14 @@ import { DefaultSession } from "next-auth"; +import { RolePermissionAttributes } from "./types/users"; + declare module "next-auth" { interface User extends NextAuthUser { name: string; email: string; company?: string; dateJoined: string; + permissions?: RolePermissionAttributes; } interface Session extends DefaultSession { @@ -14,6 +17,7 @@ declare module "next-auth" { email: string; companyName?: string; dateJoined: string; + permissions: RolePermissionAttributes; } & DefaultSession["user"]; userId: string; tenantId: string; diff --git a/ui/types/index.ts b/ui/types/index.ts index 944b09a376..312d9c6e29 100644 --- a/ui/types/index.ts +++ b/ui/types/index.ts @@ -4,4 +4,5 @@ export * from "./filters"; export * from "./formSchemas"; export * from "./processors"; export * from "./providers"; +export * from "./resources"; export * from "./scans"; diff --git a/ui/types/resources.ts b/ui/types/resources.ts new file mode 100644 index 0000000000..487e749168 --- /dev/null +++ b/ui/types/resources.ts @@ -0,0 +1,143 @@ +export interface ResourceProps { + type: "resources"; + id: string; + attributes: { + inserted_at: string; + updated_at: string; + uid: string; + name: string; + region: string; + service: string; + tags: Record; + type: string; + failed_findings_count: number; + }; + relationships: { + provider: { + data: { + type: "providers"; + id: string; + attributes: { + inserted_at: string; + updated_at: string; + provider: string; + uid: string; + alias: string | null; + connection: { + connected: boolean; + last_checked_at: string; + }; + }; + relationships: { + secret: { + data: { + type: "provider-secrets"; + id: string; + }; + }; + }; + links: { + self: string; + }; + }; + }; + findings: { + meta: { + count: number; + }; + data: { + type: "findings"; + id: string; + attributes: { status: string; delta: string }; + }[]; + }; + }; + links: { + self: string; + }; +} + +interface ResourceItemProps { + type: "providers" | "findings"; + id: string; + attributes: { + uid: string; + delta: string; + status: "PASS" | "FAIL" | "MANUAL"; + status_extended: string; + severity: "informational" | "low" | "medium" | "high" | "critical"; + check_id: string; + check_metadata: CheckMetadataProps; + raw_result: Record; + inserted_at: string; + updated_at: string; + first_seen_at: string; + muted: boolean; + }; + relationships: { + secret: { + data: { + type: string; + id: string; + }; + }; + scan: { + data: { + type: string; + id: string; + }; + }; + provider_groups: { + meta: { + count: number; + }; + data: []; + }; + }; + links: { + self: string; + }; +} + +interface CheckMetadataProps { + risk: string; + notes: string; + checkid: string; + provider: string; + severity: string; + checktype: string[]; + dependson: string[]; + relatedto: string[]; + categories: string[]; + checktitle: string; + compliance: any; + relatedurl: string; + description: string; + remediation: { + code: { + cli: string; + other: string; + nativeiac: string; + terraform: string; + }; + recommendation: { + url: string; + text: string; + }; + }; + servicename: string; + checkaliases: string[]; + resourcetype: string; + subservicename: string; + resourceidtemplate: string; +} + +interface Meta { + version: string; +} + +export interface ResourceApiResponse { + data: ResourceProps; + included: ResourceItemProps[]; + meta: Meta; +} diff --git a/ui/types/users.ts b/ui/types/users.ts index 993f1bf286..3ecd835d96 100644 --- a/ui/types/users.ts +++ b/ui/types/users.ts @@ -62,6 +62,7 @@ export type PermissionKey = | "manage_providers" | "manage_scans" | "manage_integrations" + | "manage_billing" | "unlimited_visibility"; export type RolePermissionAttributes = Pick< @@ -79,6 +80,7 @@ export interface RoleDetail { manage_providers: boolean; manage_scans: boolean; manage_integrations: boolean; + manage_billing?: boolean; unlimited_visibility: boolean; permission_state?: string; inserted_at?: string; diff --git a/util/get_prowler_threatscore_from_generic_output.py b/util/get_prowler_threatscore_from_generic_output.py index d3cd53a6b6..ed82ec0481 100644 --- a/util/get_prowler_threatscore_from_generic_output.py +++ b/util/get_prowler_threatscore_from_generic_output.py @@ -2,17 +2,19 @@ import csv import json import sys -file_name_output = sys.argv[1] # It is the output CSV file -file_name_compliance = sys.argv[2] # It is the compliance JSON file +file_name_output = sys.argv[1] +file_name_compliance = sys.argv[2] -number_of_findings_per_pillar = {} score_per_pillar = {} -# Read the compliance JSON file +max_score_per_pillar = {} +counted_req_ids = [] +to_fix = "" + with open(file_name_compliance, "r") as file: data = json.load(file) -# Read the output CSV file + with open(file_name_output, "r") as file: reader = csv.reader(file, delimiter=";") headers = next(reader) @@ -24,29 +26,48 @@ with open(file_name_output, "r") as file: muted_index = headers.index("MUTED") for row in reader: for requirement in data["Requirements"]: - # Take the column that contains the CHECK_ID + # Avoid counting the same requirement twice + if requirement["Id"] in counted_req_ids: + continue + if row[check_id_index] in requirement["Checks"]: - if ( - requirement["Attributes"][0]["Section"] - not in number_of_findings_per_pillar.keys() - ): - number_of_findings_per_pillar[ - requirement["Attributes"][0]["Section"] - ] = 0 if ( requirement["Attributes"][0]["Section"] not in score_per_pillar.keys() ): score_per_pillar[requirement["Attributes"][0]["Section"]] = 0 + max_score_per_pillar[requirement["Attributes"][0]["Section"]] = 0 if row[status_index] == "FAIL" and row[muted_index] != "TRUE": - number_of_findings_per_pillar[ - requirement["Attributes"][0]["Section"] - ] += 1 - score_per_pillar[ - requirement["Attributes"][0]["Section"] - ] += requirement["Attributes"][0]["LevelOfRisk"] + max_score_per_pillar[requirement["Attributes"][0]["Section"]] += ( + requirement["Attributes"][0]["LevelOfRisk"] + * requirement["Attributes"][0]["Weight"] + ) + counted_req_ids.append(requirement["Id"]) + if requirement["Attributes"][0]["Weight"] >= 100: + to_fix += ( + requirement["Id"] + + " - " + + requirement["Description"] + + "\n" + ) + else: + if row[status_index] == "PASS" and row[muted_index] != "TRUE": + score_per_pillar[requirement["Attributes"][0]["Section"]] += ( + requirement["Attributes"][0]["LevelOfRisk"] + * requirement["Attributes"][0]["Weight"] + ) + max_score_per_pillar[ + requirement["Attributes"][0]["Section"] + ] += ( + requirement["Attributes"][0]["LevelOfRisk"] + * requirement["Attributes"][0]["Weight"] + ) + counted_req_ids.append(requirement["Id"]) -for key, value in number_of_findings_per_pillar.items(): +for key in score_per_pillar.keys(): print("Pillar:", key) - print("Score:", score_per_pillar[key] / value) + print("Score:", score_per_pillar[key] / max_score_per_pillar[key] * 100) print("--------------------------------") + +print("Threats to fix ASAP (weight >= 100):") +print(to_fix)