diff --git a/.github/workflows/api-pull-request.yml b/.github/workflows/api-pull-request.yml index 2929646088..0e55d83bff 100644 --- a/.github/workflows/api-pull-request.yml +++ b/.github/workflows/api-pull-request.yml @@ -136,12 +136,6 @@ jobs: run: | poetry check --lock - - name: Prevents known compatibility error between lxml and libxml2/libxmlsec versions - https://github.com/xmlsec/python-xmlsec/issues/320 - working-directory: ./api - if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true' - run: | - poetry run pip install --force-reinstall --no-binary lxml lxml - - name: Lint with ruff working-directory: ./api if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true' diff --git a/.github/workflows/sdk-bump-version.yml b/.github/workflows/sdk-bump-version.yml index e5e5dba4e0..9c75928dfe 100644 --- a/.github/workflows/sdk-bump-version.yml +++ b/.github/workflows/sdk-bump-version.yml @@ -97,6 +97,7 @@ jobs: commit-message: "chore(release): Bump version to v${{ env.BUMP_VERSION_TO }}" branch: "version-bump-to-v${{ env.BUMP_VERSION_TO }}" title: "chore(release): Bump version to v${{ env.BUMP_VERSION_TO }}" + labels: no-changelog body: | ### Description @@ -135,6 +136,7 @@ jobs: commit-message: "chore(release): Bump version to v${{ env.PATCH_VERSION_TO }}" branch: "version-bump-to-v${{ env.PATCH_VERSION_TO }}" title: "chore(release): Bump version to v${{ env.PATCH_VERSION_TO }}" + labels: no-changelog body: | ### Description diff --git a/.gitignore b/.gitignore index a023609218..2ee32ac433 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,16 @@ junit-reports/ # Cursor files .cursorignore +.cursor/ + +# RooCode files +.roo/ +.rooignore +.roomodes + +# Cline files +.cline/ +.clineignore # Terraform .terraform* diff --git a/Dockerfile b/Dockerfile index f9aa97c58b..5884bda9f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12.10-slim-bookworm AS build +FROM python:3.12.11-slim-bookworm AS build LABEL maintainer="https://github.com/prowler-cloud/prowler" LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler" diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 615691cad0..59998e9fba 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -4,6 +4,32 @@ All notable changes to the **Prowler API** are documented in this file. ## [v1.10.0] (Prowler UNRELEASED) +### Added + +- SSO with SAML support [(#8175)](https://github.com/prowler-cloud/prowler/pull/8175) +- `/processors` endpoints to post-process findings. Currently, only the Mutelist processor is supported to allow to mute findings. + +### 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) + +--- + +## [v1.9.1] (Prowler v5.8.1) + +### Added +- Custom exception for provider connection errors during scans [(#8234)](https://github.com/prowler-cloud/prowler/pull/8234) + +### Changed +- Summary and overview tasks now use a dedicated queue and no longer propagate errors to compliance tasks [(#8214)](https://github.com/prowler-cloud/prowler/pull/8214) + +### Fixed +- Scan with no resources will not trigger legacy code for findings metadata [(#8183)](https://github.com/prowler-cloud/prowler/pull/8183) +- Invitation email comparison case-insensitive [(#8206)](https://github.com/prowler-cloud/prowler/pull/8206) + +### Removed +- Validation of the provider's secret type during updates [(#8197)](https://github.com/prowler-cloud/prowler/pull/8197) + --- ## [v1.9.0] (Prowler v5.8.0) diff --git a/api/Dockerfile b/api/Dockerfile index d2e31faefc..7595b0be45 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -57,10 +57,6 @@ RUN poetry install --no-root && \ RUN poetry run python "$(poetry env info --path)/src/prowler/prowler/providers/m365/lib/powershell/m365_powershell.py" -# Prevents known compatibility error between lxml and libxml2/libxmlsec versions. -# See: https://github.com/xmlsec/python-xmlsec/issues/320 -RUN poetry run pip install --force-reinstall --no-binary lxml lxml - COPY src/backend/ ./backend/ COPY docker-entrypoint.sh ./docker-entrypoint.sh diff --git a/api/README.md b/api/README.md index a1241372ca..ba8896ff35 100644 --- a/api/README.md +++ b/api/README.md @@ -257,7 +257,7 @@ cd src/backend python manage.py loaddata api/fixtures/0_dev_users.json --database admin ``` -> The default credentials are `dev@prowler.com:thisisapassword123` or `dev2@prowler.com:thisisapassword123` +> The default credentials are `dev@prowler.com:Thisisapassword123@` or `dev2@prowler.com:Thisisapassword123@` ## Run tests diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh index 4ff9cc4cd1..9d424432cd 100755 --- a/api/docker-entrypoint.sh +++ b/api/docker-entrypoint.sh @@ -32,7 +32,7 @@ start_prod_server() { start_worker() { echo "Starting the worker..." - poetry run python -m celery -A config.celery worker -l "${DJANGO_LOGGING_LEVEL:-info}" -Q celery,scans,scan-reports,deletion,backfill -E --max-tasks-per-child 1 + poetry run python -m celery -A config.celery worker -l "${DJANGO_LOGGING_LEVEL:-info}" -Q celery,scans,scan-reports,deletion,backfill,overview -E --max-tasks-per-child 1 } start_worker_beat() { diff --git a/api/poetry.lock b/api/poetry.lock index 18622a248e..07ecc9ac73 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -2684,144 +2684,150 @@ adal = ["adal (>=1.0.2)"] [[package]] name = "lxml" -version = "5.4.0" +version = "5.3.2" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "lxml-5.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7bc6df34d42322c5289e37e9971d6ed114e3776b45fa879f734bded9d1fea9c"}, - {file = "lxml-5.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6854f8bd8a1536f8a1d9a3655e6354faa6406621cf857dc27b681b69860645c7"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:696ea9e87442467819ac22394ca36cb3d01848dad1be6fac3fb612d3bd5a12cf"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef80aeac414f33c24b3815ecd560cee272786c3adfa5f31316d8b349bfade28"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9c2754cef6963f3408ab381ea55f47dabc6f78f4b8ebb0f0b25cf1ac1f7609"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a62cc23d754bb449d63ff35334acc9f5c02e6dae830d78dab4dd12b78a524f4"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f82125bc7203c5ae8633a7d5d20bcfdff0ba33e436e4ab0abc026a53a8960b7"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b67319b4aef1a6c56576ff544b67a2a6fbd7eaee485b241cabf53115e8908b8f"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:a8ef956fce64c8551221f395ba21d0724fed6b9b6242ca4f2f7beb4ce2f41997"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:0a01ce7d8479dce84fc03324e3b0c9c90b1ece9a9bb6a1b6c9025e7e4520e78c"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:91505d3ddebf268bb1588eb0f63821f738d20e1e7f05d3c647a5ca900288760b"}, - {file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a3bcdde35d82ff385f4ede021df801b5c4a5bcdfb61ea87caabcebfc4945dc1b"}, - {file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aea7c06667b987787c7d1f5e1dfcd70419b711cdb47d6b4bb4ad4b76777a0563"}, - {file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7fb111eef4d05909b82152721a59c1b14d0f365e2be4c742a473c5d7372f4f5"}, - {file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43d549b876ce64aa18b2328faff70f5877f8c6dede415f80a2f799d31644d776"}, - {file = "lxml-5.4.0-cp310-cp310-win32.whl", hash = "sha256:75133890e40d229d6c5837b0312abbe5bac1c342452cf0e12523477cd3aa21e7"}, - {file = "lxml-5.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:de5b4e1088523e2b6f730d0509a9a813355b7f5659d70eb4f319c76beea2e250"}, - {file = "lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9"}, - {file = "lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86"}, - {file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056"}, - {file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7"}, - {file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd"}, - {file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751"}, - {file = "lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4"}, - {file = "lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539"}, - {file = "lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4"}, - {file = "lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079"}, - {file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20"}, - {file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8"}, - {file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f"}, - {file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc"}, - {file = "lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f"}, - {file = "lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2"}, - {file = "lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0"}, - {file = "lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982"}, - {file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61"}, - {file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54"}, - {file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b"}, - {file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a"}, - {file = "lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82"}, - {file = "lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f"}, - {file = "lxml-5.4.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7be701c24e7f843e6788353c055d806e8bd8466b52907bafe5d13ec6a6dbaecd"}, - {file = "lxml-5.4.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb54f7c6bafaa808f27166569b1511fc42701a7713858dddc08afdde9746849e"}, - {file = "lxml-5.4.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97dac543661e84a284502e0cf8a67b5c711b0ad5fb661d1bd505c02f8cf716d7"}, - {file = "lxml-5.4.0-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:c70e93fba207106cb16bf852e421c37bbded92acd5964390aad07cb50d60f5cf"}, - {file = "lxml-5.4.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9c886b481aefdf818ad44846145f6eaf373a20d200b5ce1a5c8e1bc2d8745410"}, - {file = "lxml-5.4.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:fa0e294046de09acd6146be0ed6727d1f42ded4ce3ea1e9a19c11b6774eea27c"}, - {file = "lxml-5.4.0-cp36-cp36m-win32.whl", hash = "sha256:61c7bbf432f09ee44b1ccaa24896d21075e533cd01477966a5ff5a71d88b2f56"}, - {file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"}, - {file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"}, - {file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"}, - {file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"}, - {file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"}, - {file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"}, - {file = "lxml-5.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eaf24066ad0b30917186420d51e2e3edf4b0e2ea68d8cd885b14dc8afdcf6556"}, - {file = "lxml-5.4.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b31a3a77501d86d8ade128abb01082724c0dfd9524f542f2f07d693c9f1175f"}, - {file = "lxml-5.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e108352e203c7afd0eb91d782582f00a0b16a948d204d4dec8565024fafeea5"}, - {file = "lxml-5.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a11a96c3b3f7551c8a8109aa65e8594e551d5a84c76bf950da33d0fb6dfafab7"}, - {file = "lxml-5.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:ca755eebf0d9e62d6cb013f1261e510317a41bf4650f22963474a663fdfe02aa"}, - {file = "lxml-5.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:4cd915c0fb1bed47b5e6d6edd424ac25856252f09120e3e8ba5154b6b921860e"}, - {file = "lxml-5.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:226046e386556a45ebc787871d6d2467b32c37ce76c2680f5c608e25823ffc84"}, - {file = "lxml-5.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b108134b9667bcd71236c5a02aad5ddd073e372fb5d48ea74853e009fe38acb6"}, - {file = "lxml-5.4.0-cp38-cp38-win32.whl", hash = "sha256:1320091caa89805df7dcb9e908add28166113dcd062590668514dbd510798c88"}, - {file = "lxml-5.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:073eb6dcdf1f587d9b88c8c93528b57eccda40209cf9be549d469b942b41d70b"}, - {file = "lxml-5.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bda3ea44c39eb74e2488297bb39d47186ed01342f0022c8ff407c250ac3f498e"}, - {file = "lxml-5.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9ceaf423b50ecfc23ca00b7f50b64baba85fb3fb91c53e2c9d00bc86150c7e40"}, - {file = "lxml-5.4.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:664cdc733bc87449fe781dbb1f309090966c11cc0c0cd7b84af956a02a8a4729"}, - {file = "lxml-5.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67ed8a40665b84d161bae3181aa2763beea3747f748bca5874b4af4d75998f87"}, - {file = "lxml-5.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4a3bd174cc9cdaa1afbc4620c049038b441d6ba07629d89a83b408e54c35cd"}, - {file = "lxml-5.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:b0989737a3ba6cf2a16efb857fb0dfa20bc5c542737fddb6d893fde48be45433"}, - {file = "lxml-5.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:dc0af80267edc68adf85f2a5d9be1cdf062f973db6790c1d065e45025fa26140"}, - {file = "lxml-5.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:639978bccb04c42677db43c79bdaa23785dc7f9b83bfd87570da8207872f1ce5"}, - {file = "lxml-5.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a99d86351f9c15e4a901fc56404b485b1462039db59288b203f8c629260a142"}, - {file = "lxml-5.4.0-cp39-cp39-win32.whl", hash = "sha256:3e6d5557989cdc3ebb5302bbdc42b439733a841891762ded9514e74f60319ad6"}, - {file = "lxml-5.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:a8c9b7f16b63e65bbba889acb436a1034a82d34fa09752d754f88d708eca80e1"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1b717b00a71b901b4667226bba282dd462c42ccf618ade12f9ba3674e1fabc55"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27a9ded0f0b52098ff89dd4c418325b987feed2ea5cc86e8860b0f844285d740"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7ce10634113651d6f383aa712a194179dcd496bd8c41e191cec2099fa09de5"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53370c26500d22b45182f98847243efb518d268374a9570409d2e2276232fd37"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6364038c519dffdbe07e3cf42e6a7f8b90c275d4d1617a69bb59734c1a2d571"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b12cb6527599808ada9eb2cd6e0e7d3d8f13fe7bbb01c6311255a15ded4c7ab4"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5f11a1526ebd0dee85e7b1e39e39a0cc0d9d03fb527f56d8457f6df48a10dc0c"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48b4afaf38bf79109bb060d9016fad014a9a48fb244e11b94f74ae366a64d252"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de6f6bb8a7840c7bf216fb83eec4e2f79f7325eca8858167b68708b929ab2172"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5cca36a194a4eb4e2ed6be36923d3cffd03dcdf477515dea687185506583d4c9"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b7c86884ad23d61b025989d99bfdd92a7351de956e01c61307cb87035960bcb1"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:53d9469ab5460402c19553b56c3648746774ecd0681b1b27ea74d5d8a3ef5590"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:56dbdbab0551532bb26c19c914848d7251d73edb507c3079d6805fa8bba5b706"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14479c2ad1cb08b62bb941ba8e0e05938524ee3c3114644df905d2331c76cd57"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32697d2ea994e0db19c1df9e40275ffe84973e4232b5c274f47e7c1ec9763cdd"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:24f6df5f24fc3385f622c0c9d63fe34604893bc1a5bdbb2dbf5870f85f9a404a"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:151d6c40bc9db11e960619d2bf2ec5829f0aaffb10b41dcf6ad2ce0f3c0b2325"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4025bf2884ac4370a3243c5aa8d66d3cb9e15d3ddd0af2d796eccc5f0244390e"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9459e6892f59ecea2e2584ee1058f5d8f629446eab52ba2305ae13a32a059530"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47fb24cc0f052f0576ea382872b3fc7e1f7e3028e53299ea751839418ade92a6"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50441c9de951a153c698b9b99992e806b71c1f36d14b154592580ff4a9d0d877"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ab339536aa798b1e17750733663d272038bf28069761d5be57cb4a9b0137b4f8"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9776af1aad5a4b4a1317242ee2bea51da54b2a7b7b48674be736d463c999f37d"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:63e7968ff83da2eb6fdda967483a7a023aa497d85ad8f05c3ad9b1f2e8c84987"}, - {file = "lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd"}, + {file = "lxml-5.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c4b84d6b580a9625dfa47269bf1fd7fbba7ad69e08b16366a46acb005959c395"}, + {file = "lxml-5.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4c08ecb26e4270a62f81f81899dfff91623d349e433b126931c9c4577169666"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef926e9f11e307b5a7c97b17c5c609a93fb59ffa8337afac8f89e6fe54eb0b37"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:017ceeabe739100379fe6ed38b033cd244ce2da4e7f6f07903421f57da3a19a2"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dae97d9435dc90590f119d056d233c33006b2fd235dd990d5564992261ee7ae8"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:910f39425c6798ce63c93976ae5af5fff6949e2cb446acbd44d6d892103eaea8"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9780de781a0d62a7c3680d07963db3048b919fc9e3726d9cfd97296a65ffce1"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:1a06b0c6ba2e3ca45a009a78a4eb4d6b63831830c0a83dcdc495c13b9ca97d3e"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:4c62d0a34d1110769a1bbaf77871a4b711a6f59c4846064ccb78bc9735978644"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:8f961a4e82f411b14538fe5efc3e6b953e17f5e809c463f0756a0d0e8039b700"}, + {file = "lxml-5.3.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:3dfc78f5f9251b6b8ad37c47d4d0bfe63ceb073a916e5b50a3bf5fd67a703335"}, + {file = "lxml-5.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10e690bc03214d3537270c88e492b8612d5e41b884f232df2b069b25b09e6711"}, + {file = "lxml-5.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aa837e6ee9534de8d63bc4c1249e83882a7ac22bd24523f83fad68e6ffdf41ae"}, + {file = "lxml-5.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:da4c9223319400b97a2acdfb10926b807e51b69eb7eb80aad4942c0516934858"}, + {file = "lxml-5.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dc0e9bdb3aa4d1de703a437576007d366b54f52c9897cae1a3716bb44fc1fc85"}, + {file = "lxml-5.3.2-cp310-cp310-win32.win32.whl", hash = "sha256:dd755a0a78dd0b2c43f972e7b51a43be518ebc130c9f1a7c4480cf08b4385486"}, + {file = "lxml-5.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:d64ea1686474074b38da13ae218d9fde0d1dc6525266976808f41ac98d9d7980"}, + {file = "lxml-5.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d61a7d0d208ace43986a92b111e035881c4ed45b1f5b7a270070acae8b0bfb4"}, + {file = "lxml-5.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:856dfd7eda0b75c29ac80a31a6411ca12209183e866c33faf46e77ace3ce8a79"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a01679e4aad0727bedd4c9407d4d65978e920f0200107ceeffd4b019bd48529"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b6b37b4c3acb8472d191816d4582379f64d81cecbdce1a668601745c963ca5cc"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3df5a54e7b7c31755383f126d3a84e12a4e0333db4679462ef1165d702517477"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c09a40f28dcded933dc16217d6a092be0cc49ae25811d3b8e937c8060647c353"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1ef20f1851ccfbe6c5a04c67ec1ce49da16ba993fdbabdce87a92926e505412"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:f79a63289dbaba964eb29ed3c103b7911f2dce28c36fe87c36a114e6bd21d7ad"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:75a72697d95f27ae00e75086aed629f117e816387b74a2f2da6ef382b460b710"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:b9b00c9ee1cc3a76f1f16e94a23c344e0b6e5c10bec7f94cf2d820ce303b8c01"}, + {file = "lxml-5.3.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:77cbcab50cbe8c857c6ba5f37f9a3976499c60eada1bf6d38f88311373d7b4bc"}, + {file = "lxml-5.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:29424058f072a24622a0a15357bca63d796954758248a72da6d512f9bd9a4493"}, + {file = "lxml-5.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7d82737a8afe69a7c80ef31d7626075cc7d6e2267f16bf68af2c764b45ed68ab"}, + {file = "lxml-5.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:95473d1d50a5d9fcdb9321fdc0ca6e1edc164dce4c7da13616247d27f3d21e31"}, + {file = "lxml-5.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2162068f6da83613f8b2a32ca105e37a564afd0d7009b0b25834d47693ce3538"}, + {file = "lxml-5.3.2-cp311-cp311-win32.whl", hash = "sha256:f8695752cf5d639b4e981afe6c99e060621362c416058effd5c704bede9cb5d1"}, + {file = "lxml-5.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:d1a94cbb4ee64af3ab386c2d63d6d9e9cf2e256ac0fd30f33ef0a3c88f575174"}, + {file = "lxml-5.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:16b3897691ec0316a1aa3c6585f61c8b7978475587c5b16fc1d2c28d283dc1b0"}, + {file = "lxml-5.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a8d4b34a0eeaf6e73169dcfd653c8d47f25f09d806c010daf074fba2db5e2d3f"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9cd7a959396da425022e1e4214895b5cfe7de7035a043bcc2d11303792b67554"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cac5eaeec3549c5df7f8f97a5a6db6963b91639389cdd735d5a806370847732b"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29b5f7d77334877c2146e7bb8b94e4df980325fab0a8af4d524e5d43cd6f789d"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13f3495cfec24e3d63fffd342cc8141355d1d26ee766ad388775f5c8c5ec3932"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e70ad4c9658beeff99856926fd3ee5fde8b519b92c693f856007177c36eb2e30"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:507085365783abd7879fa0a6fa55eddf4bdd06591b17a2418403bb3aff8a267d"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:5bb304f67cbf5dfa07edad904732782cbf693286b9cd85af27059c5779131050"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:3d84f5c093645c21c29a4e972b84cb7cf682f707f8706484a5a0c7ff13d7a988"}, + {file = "lxml-5.3.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:bdc13911db524bd63f37b0103af014b7161427ada41f1b0b3c9b5b5a9c1ca927"}, + {file = "lxml-5.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ec944539543f66ebc060ae180d47e86aca0188bda9cbfadff47d86b0dc057dc"}, + {file = "lxml-5.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:59d437cc8a7f838282df5a199cf26f97ef08f1c0fbec6e84bd6f5cc2b7913f6e"}, + {file = "lxml-5.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e275961adbd32e15672e14e0cc976a982075208224ce06d149c92cb43db5b93"}, + {file = "lxml-5.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:038aeb6937aa404480c2966b7f26f1440a14005cb0702078c173c028eca72c31"}, + {file = "lxml-5.3.2-cp312-cp312-win32.whl", hash = "sha256:3c2c8d0fa3277147bff180e3590be67597e17d365ce94beb2efa3138a2131f71"}, + {file = "lxml-5.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:77809fcd97dfda3f399102db1794f7280737b69830cd5c961ac87b3c5c05662d"}, + {file = "lxml-5.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:77626571fb5270ceb36134765f25b665b896243529eefe840974269b083e090d"}, + {file = "lxml-5.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78a533375dc7aa16d0da44af3cf6e96035e484c8c6b2b2445541a5d4d3d289ee"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6f62b2404b3f3f0744bbcabb0381c5fe186fa2a9a67ecca3603480f4846c585"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ea918da00091194526d40c30c4996971f09dacab032607581f8d8872db34fbf"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c35326f94702a7264aa0eea826a79547d3396a41ae87a70511b9f6e9667ad31c"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3bef90af21d31c4544bc917f51e04f94ae11b43156356aff243cdd84802cbf2"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52fa7ba11a495b7cbce51573c73f638f1dcff7b3ee23697467dc063f75352a69"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ad131e2c4d2c3803e736bb69063382334e03648de2a6b8f56a878d700d4b557d"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:00a4463ca409ceacd20490a893a7e08deec7870840eff33dc3093067b559ce3e"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:87e8d78205331cace2b73ac8249294c24ae3cba98220687b5b8ec5971a2267f1"}, + {file = "lxml-5.3.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:bf6389133bb255e530a4f2f553f41c4dd795b1fbb6f797aea1eff308f1e11606"}, + {file = "lxml-5.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b3709fc752b42fb6b6ffa2ba0a5b9871646d97d011d8f08f4d5b3ee61c7f3b2b"}, + {file = "lxml-5.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:abc795703d0de5d83943a4badd770fbe3d1ca16ee4ff3783d7caffc252f309ae"}, + {file = "lxml-5.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:98050830bb6510159f65d9ad1b8aca27f07c01bb3884ba95f17319ccedc4bcf9"}, + {file = "lxml-5.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6ba465a91acc419c5682f8b06bcc84a424a7aa5c91c220241c6fd31de2a72bc6"}, + {file = "lxml-5.3.2-cp313-cp313-win32.whl", hash = "sha256:56a1d56d60ea1ec940f949d7a309e0bff05243f9bd337f585721605670abb1c1"}, + {file = "lxml-5.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:1a580dc232c33d2ad87d02c8a3069d47abbcdce974b9c9cc82a79ff603065dbe"}, + {file = "lxml-5.3.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1a59f7fe888d0ec1916d0ad69364c5400cfa2f885ae0576d909f342e94d26bc9"}, + {file = "lxml-5.3.2-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d67b50abc2df68502a26ed2ccea60c1a7054c289fb7fc31c12e5e55e4eec66bd"}, + {file = "lxml-5.3.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2cb08d2cb047c98d6fbbb2e77d6edd132ad6e3fa5aa826ffa9ea0c9b1bc74a84"}, + {file = "lxml-5.3.2-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:495ddb7e10911fb4d673d8aa8edd98d1eadafb3b56e8c1b5f427fd33cadc455b"}, + {file = "lxml-5.3.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:884d9308ac7d581b705a3371185282e1b8eebefd68ccf288e00a2d47f077cc51"}, + {file = "lxml-5.3.2-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:37f3d7cf7f2dd2520df6cc8a13df4c3e3f913c8e0a1f9a875e44f9e5f98d7fee"}, + {file = "lxml-5.3.2-cp36-cp36m-win32.whl", hash = "sha256:e885a1bf98a76dff0a0648850c3083b99d9358ef91ba8fa307c681e8e0732503"}, + {file = "lxml-5.3.2-cp36-cp36m-win_amd64.whl", hash = "sha256:b45f505d0d85f4cdd440cd7500689b8e95110371eaa09da0c0b1103e9a05030f"}, + {file = "lxml-5.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b53cd668facd60b4f0dfcf092e01bbfefd88271b5b4e7b08eca3184dd006cb30"}, + {file = "lxml-5.3.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5dea998c891f082fe204dec6565dbc2f9304478f2fc97bd4d7a940fec16c873"}, + {file = "lxml-5.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d46bc3e58b01e4f38d75e0d7f745a46875b7a282df145aca9d1479c65ff11561"}, + {file = "lxml-5.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:661feadde89159fd5f7d7639a81ccae36eec46974c4a4d5ccce533e2488949c8"}, + {file = "lxml-5.3.2-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:43af2a69af2cacc2039024da08a90174e85f3af53483e6b2e3485ced1bf37151"}, + {file = "lxml-5.3.2-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:1539f962d82436f3d386eb9f29b2a29bb42b80199c74a695dff51b367a61ec0a"}, + {file = "lxml-5.3.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:6673920bf976421b5fac4f29b937702eef4555ee42329546a5fc68bae6178a48"}, + {file = "lxml-5.3.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:9fa722a9cd8845594593cce399a49aa6bfc13b6c83a7ee05e2ab346d9253d52f"}, + {file = "lxml-5.3.2-cp37-cp37m-win32.whl", hash = "sha256:2eadd4efa487f4710755415aed3d6ae9ac8b4327ea45226ffccb239766c8c610"}, + {file = "lxml-5.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:83d8707b1b08cd02c04d3056230ec3b771b18c566ec35e723e60cdf037064e08"}, + {file = "lxml-5.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc6e8678bfa5ccba370103976ccfcf776c85c83da9220ead41ea6fd15d2277b4"}, + {file = "lxml-5.3.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bed509662f67f719119ad56006cd4a38efa68cfa74383060612044915e5f7ad"}, + {file = "lxml-5.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e3925975fadd6fd72a6d80541a6ec75dfbad54044a03aa37282dafcb80fbdfa"}, + {file = "lxml-5.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83c0462dedc5213ac586164c6d7227da9d4d578cf45dd7fbab2ac49b63a008eb"}, + {file = "lxml-5.3.2-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:53e3f9ca72858834688afa17278649d62aa768a4b2018344be00c399c4d29e95"}, + {file = "lxml-5.3.2-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:32ba634ef3f1b20f781019a91d78599224dc45745dd572f951adbf1c0c9b0d75"}, + {file = "lxml-5.3.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:1b16504c53f41da5fcf04868a80ac40a39d3eec5329caf761114caec6e844ad1"}, + {file = "lxml-5.3.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:1f9682786138549da44ca4c49b20e7144d063b75f2b2ba611f4cff9b83db1062"}, + {file = "lxml-5.3.2-cp38-cp38-win32.whl", hash = "sha256:d8f74ef8aacdf6ee5c07566a597634bb8535f6b53dc89790db43412498cf6026"}, + {file = "lxml-5.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:49f1cee0fa27e1ee02589c696a9bdf4027e7427f184fa98e6bef0c6613f6f0fa"}, + {file = "lxml-5.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:741c126bcf9aa939e950e64e5e0a89c8e01eda7a5f5ffdfc67073f2ed849caea"}, + {file = "lxml-5.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ab6e9e6aca1fd7d725ffa132286e70dee5b9a4561c5ed291e836440b82888f89"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58e8c9b9ed3c15c2d96943c14efc324b69be6352fe5585733a7db2bf94d97841"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7811828ddfb8c23f4f1fbf35e7a7b2edec2f2e4c793dee7c52014f28c4b35238"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72968623efb1e12e950cbdcd1d0f28eb14c8535bf4be153f1bfffa818b1cf189"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ebfceaa2ea588b54efb6160e3520983663d45aed8a3895bb2031ada080fb5f04"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d685d458505b2bfd2e28c812749fe9194a2b0ce285a83537e4309a187ffa270b"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:334e0e414dab1f5366ead8ca34ec3148415f236d5660e175f1d640b11d645847"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:02e56f7de72fa82561eae69628a7d6febd7891d72248c7ff7d3e7814d4031017"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:638d06b4e1d34d1a074fa87deed5fb55c18485fa0dab97abc5604aad84c12031"}, + {file = "lxml-5.3.2-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:354dab7206d22d7a796fa27c4c5bffddd2393da2ad61835355a4759d435beb47"}, + {file = "lxml-5.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d9d9f82ff2c3bf9bb777cb355149f7f3a98ec58f16b7428369dc27ea89556a4c"}, + {file = "lxml-5.3.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:95ad58340e3b7d2b828efc370d1791856613c5cb62ae267158d96e47b3c978c9"}, + {file = "lxml-5.3.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:30fe05f4b7f6e9eb32862745512e7cbd021070ad0f289a7f48d14a0d3fc1d8a9"}, + {file = "lxml-5.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:34c688fef86f73dbca0798e0a61bada114677006afa524a8ce97d9e5fabf42e6"}, + {file = "lxml-5.3.2-cp39-cp39-win32.whl", hash = "sha256:4d6d3d1436d57f41984920667ec5ef04bcb158f80df89ac4d0d3f775a2ac0c87"}, + {file = "lxml-5.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:2996e1116bbb3ae2a1fbb2ba4da8f92742290b4011e7e5bce2bd33bbc9d9485a"}, + {file = "lxml-5.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:521ab9c80b98c30b2d987001c3ede2e647e92eeb2ca02e8cb66ef5122d792b24"}, + {file = "lxml-5.3.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f1231b0f9810289d41df1eacc4ebb859c63e4ceee29908a0217403cddce38d0"}, + {file = "lxml-5.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271f1a4d5d2b383c36ad8b9b489da5ea9c04eca795a215bae61ed6a57cf083cd"}, + {file = "lxml-5.3.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:6fca8a5a13906ba2677a5252752832beb0f483a22f6c86c71a2bb320fba04f61"}, + {file = "lxml-5.3.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ea0c3b7922209160faef194a5b6995bfe7fa05ff7dda6c423ba17646b7b9de10"}, + {file = "lxml-5.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0a006390834603e5952a2ff74b9a31a6007c7cc74282a087aa6467afb4eea987"}, + {file = "lxml-5.3.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:eae4136a3b8c4cf76f69461fc8f9410d55d34ea48e1185338848a888d71b9675"}, + {file = "lxml-5.3.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d48e06be8d8c58e7feaedd8a37897a6122637efb1637d7ce00ddf5f11f9a92ad"}, + {file = "lxml-5.3.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4b83aed409134093d90e114007034d2c1ebcd92e501b71fd9ec70e612c8b2eb"}, + {file = "lxml-5.3.2-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7a0e77edfe26d3703f954d46bed52c3ec55f58586f18f4b7f581fc56954f1d84"}, + {file = "lxml-5.3.2-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:19f6fcfd15b82036b4d235749d78785eb9c991c7812012dc084e0d8853b4c1c0"}, + {file = "lxml-5.3.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:d49919c95d31ee06eefd43d8c6f69a3cc9bdf0a9b979cc234c4071f0eb5cb173"}, + {file = "lxml-5.3.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2d0a60841410123c533990f392819804a8448853f06daf412c0f383443925e89"}, + {file = "lxml-5.3.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b7f729e03090eb4e3981f10efaee35e6004b548636b1a062b8b9a525e752abc"}, + {file = "lxml-5.3.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:579df6e20d8acce3bcbc9fb8389e6ae00c19562e929753f534ba4c29cfe0be4b"}, + {file = "lxml-5.3.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2abcf3f3b8367d6400b908d00d4cd279fc0b8efa287e9043820525762d383699"}, + {file = "lxml-5.3.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:348c06cb2e3176ce98bee8c397ecc89181681afd13d85870df46167f140a305f"}, + {file = "lxml-5.3.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:617ecaccd565cbf1ac82ffcaa410e7da5bd3a4b892bb3543fb2fe19bd1c4467d"}, + {file = "lxml-5.3.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c3eb4278dcdb9d86265ed2c20b9ecac45f2d6072e3904542e591e382c87a9c00"}, + {file = "lxml-5.3.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:258b6b53458c5cbd2a88795557ff7e0db99f73a96601b70bc039114cd4ee9e02"}, + {file = "lxml-5.3.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0a9d8d25ed2f2183e8471c97d512a31153e123ac5807f61396158ef2793cb6e"}, + {file = "lxml-5.3.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:73bcb635a848c18a3e422ea0ab0092f2e4ef3b02d8ebe87ab49748ebc8ec03d8"}, + {file = "lxml-5.3.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1545de0a69a16ced5767bae8cca1801b842e6e49e96f5e4a8a5acbef023d970b"}, + {file = "lxml-5.3.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:165fcdc2f40fc0fe88a3c3c06c9c2a097388a90bda6a16e6f7c9199c903c9b8e"}, + {file = "lxml-5.3.2.tar.gz", hash = "sha256:773947d0ed809ddad824b7b14467e1a481b8976e87278ac4a730c2f7c7fcddc1"}, ] [package.extras] @@ -4982,7 +4988,6 @@ 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"}, @@ -4991,7 +4996,6 @@ 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"}, @@ -5000,7 +5004,6 @@ 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"}, @@ -5009,7 +5012,6 @@ 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"}, @@ -5018,7 +5020,6 @@ 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"}, @@ -5693,35 +5694,70 @@ files = [ [[package]] name = "xmlsec" -version = "1.3.15" +version = "1.3.14" description = "Python bindings for the XML Security Library" optional = false python-versions = ">=3.5" groups = ["main"] files = [ - {file = "xmlsec-1.3.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:60209f82a254a1d6083397c4eeae131e7ac2f64bfddb97f2b0b240369f03c4df"}, - {file = "xmlsec-1.3.15-cp310-cp310-win32.whl", hash = "sha256:a62be0f8964bbec1efd2ca39b025c40da620a2ef9cb5440ff4ffa7e0c6906f70"}, - {file = "xmlsec-1.3.15-cp310-cp310-win_amd64.whl", hash = "sha256:685b92860bbf048e3b725bd5e9310bd4d3515f7eafcb2c284dda62078a1ce90c"}, - {file = "xmlsec-1.3.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c760230d4f77b7828857d076434e0810850eb2603775dc92fa9f760a98c2f694"}, - {file = "xmlsec-1.3.15-cp311-cp311-win32.whl", hash = "sha256:901458034b7476e1fd0881a85814e184d00eec2b5df33b1ceeb312681e8cb9e8"}, - {file = "xmlsec-1.3.15-cp311-cp311-win_amd64.whl", hash = "sha256:2ecbb65eea79a25769fbaa56c9e8bc4553aea63a9704795e962dfe06679b0191"}, - {file = "xmlsec-1.3.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0edff08e0442cdcc82bebf353ba4bcfd5a022f4b2751052ee1564afc5c78bef4"}, - {file = "xmlsec-1.3.15-cp312-cp312-win32.whl", hash = "sha256:e5c402e5633fd39f75fe124219d66d383a040ba04d0de54e024afeb7fe7d3e3a"}, - {file = "xmlsec-1.3.15-cp312-cp312-win_amd64.whl", hash = "sha256:0c47f2347e8dcc0a48648b9702af53179618c204414a8e36926a9f61214ebf0b"}, - {file = "xmlsec-1.3.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6ac2154311d32a6571e22f224ed16356029e59bd5ca76edeb3922a809adfe89c"}, - {file = "xmlsec-1.3.15-cp313-cp313-win32.whl", hash = "sha256:5ed218129f89b0592926ad2be42c017bece469db9b7380dc41bc09b01ca26d5d"}, - {file = "xmlsec-1.3.15-cp313-cp313-win_amd64.whl", hash = "sha256:5fc29e69b064323317b3862751a3a8107670e0a17510ca4517bbdc1939a90b1a"}, - {file = "xmlsec-1.3.15-cp36-cp36m-win32.whl", hash = "sha256:d0404dd76097b1f6dcbeff404c46cf045442a8cf9500f60c46a26ae03130ab9c"}, - {file = "xmlsec-1.3.15-cp36-cp36m-win_amd64.whl", hash = "sha256:672bb43a12d6b8e2e4a392ef495ea731ded5acc1585f9358174295a6fb5df262"}, - {file = "xmlsec-1.3.15-cp37-cp37m-win32.whl", hash = "sha256:96e24b22e862f0c50840a5af23cb7df186e7a1547b311a67ebca5b1e43ea0d86"}, - {file = "xmlsec-1.3.15-cp37-cp37m-win_amd64.whl", hash = "sha256:bec066ce81a82a5a2b994b1e7be2af11715fd716a55754c645668acf9c5a64c0"}, - {file = "xmlsec-1.3.15-cp38-cp38-win32.whl", hash = "sha256:95e80981b2e0ea74a7040cbf66b40072f4424298d7b50c3e587a026a7dab34ad"}, - {file = "xmlsec-1.3.15-cp38-cp38-win_amd64.whl", hash = "sha256:c2a40f8549769ba5fdc223f0ae564d3b4d4ca52b6461d46bc508d3321267b2ad"}, - {file = "xmlsec-1.3.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a2d5692a683054dec769f4a1d6e8fade88ddcfc2cef89b20d0ecc1c75deb0dd6"}, - {file = "xmlsec-1.3.15-cp39-cp39-macosx_13_0_arm64.whl", hash = "sha256:f0115d3b4f156df2cfee8424d75dcb7f5ca2cb4870af18b713098830493d3cb0"}, - {file = "xmlsec-1.3.15-cp39-cp39-win32.whl", hash = "sha256:ffb32d3c5af289c8598d4f9215c9f8f6c208f1551e78f0180f525bc08c8a67d2"}, - {file = "xmlsec-1.3.15-cp39-cp39-win_amd64.whl", hash = "sha256:3211da05c11c7a0d2b913a7834bff59e649150f41127949b3322442bc3986b56"}, - {file = "xmlsec-1.3.15.tar.gz", hash = "sha256:baa856b83d0012e278e6f6cbec96ac8128de667ca9fa9a2eeb02c752e816f6d8"}, + {file = "xmlsec-1.3.14-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4dea6df3ffcb65d0b215678c3a0fe7bbc66785d6eae81291296e372498bad43a"}, + {file = "xmlsec-1.3.14-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fa1311f7489d050dde9028f5a2b5849c2927bb09c9a93491cb2f28fdc563912"}, + {file = "xmlsec-1.3.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28cd9f513cf01dc0c5b9d9f0728714ecde2e7f46b3b6f63de91f4ae32f3008b3"}, + {file = "xmlsec-1.3.14-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:77749b338503fb6e151052c664064b34264f4168e2cb0cca1de78b7e5312a783"}, + {file = "xmlsec-1.3.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4af81ce8044862ec865782efd353d22abdcd95b92364eef3c934de57ae6d5852"}, + {file = "xmlsec-1.3.14-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:cf35a25be3eb6263b2e0544ba26294651113fab79064f994d347a2ca5973e8e2"}, + {file = "xmlsec-1.3.14-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:004e8a82e26728bf8a60f8ece1ef3ffafdac30ef538139dfe28870e8503ca64a"}, + {file = "xmlsec-1.3.14-cp310-cp310-win32.whl", hash = "sha256:e6cbc914d77678db0c8bc39e723d994174633d18f9d6be4665ec29cce978a96d"}, + {file = "xmlsec-1.3.14-cp310-cp310-win_amd64.whl", hash = "sha256:4922afa9234d1c5763950b26c328a5320019e55eb6000272a79dfe54fee8e704"}, + {file = "xmlsec-1.3.14-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7799a9ff3593f9dd43464e18b1a621640bffc40456c47c23383727f937dca7fc"}, + {file = "xmlsec-1.3.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1fe23c2dd5f5dbcb24f40e2c1061e2672a32aabee7cf8ac5337036a485607d72"}, + {file = "xmlsec-1.3.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0be3b7a28e54a03b87faf07fb3c6dc3e50a2c79b686718c3ad08300b8bf6bb67"}, + {file = "xmlsec-1.3.14-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48e894ad3e7de373f56efc09d6a56f7eae73a8dd4cec8943313134849e9c6607"}, + {file = "xmlsec-1.3.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:204d3c586b8bd6f02a5d4c59850a8157205569d40c32567f49576fa5795d897d"}, + {file = "xmlsec-1.3.14-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6679cec780386d848e7351d4b0de92c4483289ea4f0a2187e216159f939a4c6b"}, + {file = "xmlsec-1.3.14-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c4d41c83c8a2b8d8030204391ebeb6174fbdb044f0331653c4b5a4ce4150bcc0"}, + {file = "xmlsec-1.3.14-cp311-cp311-win32.whl", hash = "sha256:df4aa0782a53032fd35e18dcd6d328d6126324bfcfdef0cb5c2856f25b4b6f94"}, + {file = "xmlsec-1.3.14-cp311-cp311-win_amd64.whl", hash = "sha256:1072878301cb9243a54679e0520e6a5be2266c07a28b0ecef9e029d05a90ffcd"}, + {file = "xmlsec-1.3.14-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:1eb3dcf244a52f796377112d8f238dbb522eb87facffb498425dc8582a84a6bf"}, + {file = "xmlsec-1.3.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:330147ce59fbe56a9be5b2085d739c55a569f112576b3f1b33681f87416eaf33"}, + {file = "xmlsec-1.3.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed4034939d8566ccdcd3b4e4f23c63fd807fb8763ae5668d59a19e11640a8242"}, + {file = "xmlsec-1.3.14-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a98eadfcb0c3b23ccceb7a2f245811f8d784bd287640dcfe696a26b9db1e2fc0"}, + {file = "xmlsec-1.3.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86ff7b2711557c1087b72b0a1a88d82eafbf2a6d38b97309a6f7101d4a7041c3"}, + {file = "xmlsec-1.3.14-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:774d5d1e45f07f953c1cc14fd055c1063f0725f7248b6b0e681f59fd8638934d"}, + {file = "xmlsec-1.3.14-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bd10ca3201f164482775a7ce61bf7ee9aade2e7d032046044dd0f6f52c91d79d"}, + {file = "xmlsec-1.3.14-cp312-cp312-win32.whl", hash = "sha256:19c86bab1498e4c2e56d8e2c878f461ccb6e56b67fd7522b0c8fda46d8910781"}, + {file = "xmlsec-1.3.14-cp312-cp312-win_amd64.whl", hash = "sha256:d0762f4232bce2c7f6c0af329db8b821b4460bbe123a2528fb5677d03db7a4b5"}, + {file = "xmlsec-1.3.14-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:03ccba7dacf197850de954666af0221c740a5de631a80136362a1559223fab75"}, + {file = "xmlsec-1.3.14-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c12900e1903e289deb84eb893dca88591d6884d3e3cda4fb711b8812118416e8"}, + {file = "xmlsec-1.3.14-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6566434e2e5c58e472362a6187f208601f1627a148683a6f92bd16479f1d9e20"}, + {file = "xmlsec-1.3.14-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:2401e162aaab7d9416c3405bac7a270e5f370988a0f1f46f0f29b735edba87e1"}, + {file = "xmlsec-1.3.14-cp36-cp36m-win32.whl", hash = "sha256:ba3b39c493e3b04354615068a3218f30897fcc2f42c6d8986d0c1d63aca87782"}, + {file = "xmlsec-1.3.14-cp36-cp36m-win_amd64.whl", hash = "sha256:4edd8db4df04bbac9c4a5ab4af855b74fe2bf2c248d07cac2e6d92a485f1a685"}, + {file = "xmlsec-1.3.14-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b6dd86f440fec9242515c64f0be93fec8b4289287db1f6de2651eee9995aaecb"}, + {file = "xmlsec-1.3.14-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad1634cabe0915fe2a12e142db0ed2daf5be80cbe3891a2cecbba0750195cc6b"}, + {file = "xmlsec-1.3.14-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dba457ff87c39cbae3c5020475a728d24bbd9d00376df9af9724cd3bb59ff07a"}, + {file = "xmlsec-1.3.14-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:12d90059308bb0c1b94bde065784e6852999d08b91bcb2048c17e62b954acb07"}, + {file = "xmlsec-1.3.14-cp37-cp37m-win32.whl", hash = "sha256:ce4e165a1436697e5e39587c4fba24db4545a5c9801e0d749f1afd09ad3ab901"}, + {file = "xmlsec-1.3.14-cp37-cp37m-win_amd64.whl", hash = "sha256:7e8e0171916026cbe8e2022c959558d02086655fd3c3466f2bc0451b09cf9ee8"}, + {file = "xmlsec-1.3.14-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c42735cc68fdb4c6065cf0a0701dfff3a12a1734c63a36376349af9a5481f27b"}, + {file = "xmlsec-1.3.14-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:38e035bf48300b7dbde2dd01d3b8569f8584fc9c73809be13886e6b6c77b74fb"}, + {file = "xmlsec-1.3.14-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73eabf5ef58189d81655058cf328c1dfa9893d89f1bff5fc941481f08533f338"}, + {file = "xmlsec-1.3.14-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bddd2a2328b4e08c8a112e06cf2cd2b4d281f4ad94df15b4cef18f06cdc49d78"}, + {file = "xmlsec-1.3.14-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57fed3bc7943681c9ed4d2221600ab440f060d8d1a8f92f346f2b41effe175b8"}, + {file = "xmlsec-1.3.14-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:147934bd39dfd840663fb6b920ea9201455fa886427975713f1b42d9f20b9b29"}, + {file = "xmlsec-1.3.14-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e732a75fcb6b84872b168f972fbbf3749baf76308635f14015d1d35ed0c5719c"}, + {file = "xmlsec-1.3.14-cp38-cp38-win32.whl", hash = "sha256:b109cdf717257fd4daa77c1d3ec8a3fb2a81318a6d06a36c55a8a53ae381ae5e"}, + {file = "xmlsec-1.3.14-cp38-cp38-win_amd64.whl", hash = "sha256:b7ba2ea38e3d9efa520b14f3c0b7d99a7c055244ae5ba8bc9f4ca73b18f3a215"}, + {file = "xmlsec-1.3.14-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1b9b5de6bc69fdec23147e5f712cb05dc86df105462f254f140d743cc680cc7b"}, + {file = "xmlsec-1.3.14-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:82ac81deb7d7bf5cc8a748148948e5df5386597ff43fb92ec651cc5c7addb0e7"}, + {file = "xmlsec-1.3.14-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bae37b2920115cf00759ee9fb7841cbdebcef3a8a92734ab93ae8fa41ac581d"}, + {file = "xmlsec-1.3.14-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4fac2a787ae3b9fb761f9aec6b9f10f2d1c1b87abb574ebd8ff68435bdc97e3d"}, + {file = "xmlsec-1.3.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34c61ec0c0e70fda710290ae74b9efe1928d9242ed82c4eecf97aa696cff68e6"}, + {file = "xmlsec-1.3.14-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:995e87acecc263a2f6f2aa3cc204268f651cac8f4d7a2047f11b2cd49979cc38"}, + {file = "xmlsec-1.3.14-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2f84a1c509c52773365645a87949081ee9ea9c535cd452048cc8ca4ad3b45666"}, + {file = "xmlsec-1.3.14-cp39-cp39-win32.whl", hash = "sha256:7882963e9cb9c0bd0e8c2715a29159a366417ff4a30d8baf42b05bc5cf249446"}, + {file = "xmlsec-1.3.14-cp39-cp39-win_amd64.whl", hash = "sha256:a487c3d144f791c32f5e560aa27a705fba23171728b8a8511f36de053ff6bc93"}, + {file = "xmlsec-1.3.14.tar.gz", hash = "sha256:934f804f2f895bcdb86f1eaee236b661013560ee69ec108d29cdd6e5f292a2d9"}, ] [package.dependencies] @@ -5869,4 +5905,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.13" -content-hash = "0750d4d8d4c0b020c87a5c6e3c459f1f5f445e6f1395f7e492adea9a901e2056" +content-hash = "6802b33984c2f8438c9dc02dac0a0c14d5a78af60251bd0c80ca59bc2182c48e" diff --git a/api/pyproject.toml b/api/pyproject.toml index 30b1229280..b01e2cda14 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -23,12 +23,14 @@ dependencies = [ "drf-spectacular==0.27.2", "drf-spectacular-jsonapi==0.5.1", "gunicorn==23.0.0", + "lxml==5.3.2", "prowler @ git+https://github.com/prowler-cloud/prowler.git@master", "psycopg2-binary==2.9.9", "pytest-celery[redis] (>=1.0.1,<2.0.0)", "sentry-sdk[django] (>=2.20.0,<3.0.0)", "uuid6==2024.7.10", - "openai (>=1.82.0,<2.0.0)" + "openai (>=1.82.0,<2.0.0)", + "xmlsec==1.3.14" ] description = "Prowler's API (Django/DRF)" license = "Apache-2.0" diff --git a/api/src/backend/api/adapters.py b/api/src/backend/api/adapters.py index b8380c9a48..bf3fb54655 100644 --- a/api/src/backend/api/adapters.py +++ b/api/src/backend/api/adapters.py @@ -17,8 +17,8 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): def pre_social_login(self, request, sociallogin): # Link existing accounts with the same email address email = sociallogin.account.extra_data.get("email") - # if sociallogin.account.provider == "saml": - # email = sociallogin.user.email + if sociallogin.provider.id == "saml": + email = sociallogin.user.email if email: existing_user = self.get_user_by_email(email) if existing_user: @@ -31,98 +31,39 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): """ with transaction.atomic(using=MainRouter.admin_db): user = super().save_user(request, sociallogin, form) + provider = sociallogin.provider.id extra = sociallogin.account.extra_data - # if provider == "saml": - # # Handle SAML-specific logic - # user.first_name = ( - # extra.get("firstName", [""])[0] if extra.get("firstName") else "" - # ) - # user.last_name = ( - # extra.get("lastName", [""])[0] if extra.get("lastName") else "" - # ) - # user.company_name = ( - # extra.get("organization", [""])[0] - # if extra.get("organization") - # else "" - # ) - # user.name = f"{user.first_name} {user.last_name}".strip() - # if user.name == "": - # user.name = "N/A" - # user.save(using=MainRouter.admin_db) - - # email_domain = user.email.split("@")[-1] - # tenant = ( - # SAMLConfiguration.objects.using(MainRouter.admin_db) - # .get(email_domain=email_domain) - # .tenant - # ) - - # with rls_transaction(str(tenant.id)): - # role_name = ( - # extra.get("userType", ["saml_default_role"])[0].strip() - # if extra.get("userType") - # else "saml_default_role" - # ) - - # try: - # role = Role.objects.using(MainRouter.admin_db).get( - # name=role_name, tenant_id=tenant.id - # ) - # except Role.DoesNotExist: - # role = Role.objects.using(MainRouter.admin_db).create( - # name=role_name, - # tenant_id=tenant.id, - # manage_users=False, - # manage_account=False, - # manage_billing=False, - # manage_providers=False, - # manage_integrations=False, - # manage_scans=False, - # unlimited_visibility=False, - # ) - - # Membership.objects.using(MainRouter.admin_db).create( - # user=user, - # tenant=tenant, - # role=Membership.RoleChoices.MEMBER, - # ) - - # UserRoleRelationship.objects.using(MainRouter.admin_db).create( - # user=user, - # role=role, - # tenant_id=tenant.id, - # ) - - # Handle other providers (e.g., GitHub, Google) - user.save(using=MainRouter.admin_db) - social_account_name = extra.get("name") - if social_account_name: - user.name = social_account_name + if provider != "saml": + # Handle other providers (e.g., GitHub, Google) user.save(using=MainRouter.admin_db) + social_account_name = extra.get("name") + if social_account_name: + user.name = social_account_name + user.save(using=MainRouter.admin_db) - tenant = Tenant.objects.using(MainRouter.admin_db).create( - name=f"{user.email.split('@')[0]} default tenant" - ) - with rls_transaction(str(tenant.id)): - Membership.objects.using(MainRouter.admin_db).create( - user=user, tenant=tenant, role=Membership.RoleChoices.OWNER - ) - role = Role.objects.using(MainRouter.admin_db).create( - name="admin", - tenant_id=tenant.id, - manage_users=True, - manage_account=True, - manage_billing=True, - manage_providers=True, - manage_integrations=True, - manage_scans=True, - unlimited_visibility=True, - ) - UserRoleRelationship.objects.using(MainRouter.admin_db).create( - user=user, - role=role, - tenant_id=tenant.id, + tenant = Tenant.objects.using(MainRouter.admin_db).create( + name=f"{user.email.split('@')[0]} default tenant" ) + with rls_transaction(str(tenant.id)): + Membership.objects.using(MainRouter.admin_db).create( + user=user, tenant=tenant, role=Membership.RoleChoices.OWNER + ) + role = Role.objects.using(MainRouter.admin_db).create( + name="admin", + tenant_id=tenant.id, + manage_users=True, + manage_account=True, + manage_billing=True, + manage_providers=True, + manage_integrations=True, + manage_scans=True, + unlimited_visibility=True, + ) + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=user, + role=role, + tenant_id=tenant.id, + ) return user diff --git a/api/src/backend/api/db_utils.py b/api/src/backend/api/db_utils.py index d163a02fd3..c2da9ca32f 100644 --- a/api/src/backend/api/db_utils.py +++ b/api/src/backend/api/db_utils.py @@ -529,3 +529,15 @@ class IntegrationTypeEnum(EnumType): class IntegrationTypeEnumField(PostgresEnumField): def __init__(self, *args, **kwargs): super().__init__("integration_type", *args, **kwargs) + + +# Postgres enum definition for Processor type + + +class ProcessorTypeEnum(EnumType): + enum_type_name = "processor_type" + + +class ProcessorTypeEnumField(PostgresEnumField): + def __init__(self, *args, **kwargs): + super().__init__("processor_type", *args, **kwargs) diff --git a/api/src/backend/api/exceptions.py b/api/src/backend/api/exceptions.py index 14f7227d9a..a6034de806 100644 --- a/api/src/backend/api/exceptions.py +++ b/api/src/backend/api/exceptions.py @@ -57,6 +57,11 @@ class TaskInProgressException(TaskManagementError): super().__init__() +# Provider connection errors +class ProviderConnectionError(Exception): + """Base exception for provider connection errors.""" + + def custom_exception_handler(exc, context): if isinstance(exc, django_validation_error): if hasattr(exc, "error_dict"): diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index 35ebb6a611..ee69359000 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -28,6 +28,7 @@ from api.models import ( Invitation, Membership, PermissionChoices, + Processor, Provider, ProviderGroup, ProviderSecret, @@ -704,3 +705,12 @@ class IntegrationFilter(FilterSet): fields = { "inserted_at": ["date", "gte", "lte"], } + + +class ProcessorFilter(FilterSet): + processor_type = ChoiceFilter(choices=Processor.ProcessorChoices.choices) + processor_type__in = ChoiceInFilter( + choices=Processor.ProcessorChoices.choices, + field_name="processor_type", + lookup_expr="in", + ) diff --git a/api/src/backend/api/fixtures/dev/0_dev_users.json b/api/src/backend/api/fixtures/dev/0_dev_users.json index 87ce06239f..61fb0bd883 100644 --- a/api/src/backend/api/fixtures/dev/0_dev_users.json +++ b/api/src/backend/api/fixtures/dev/0_dev_users.json @@ -3,7 +3,7 @@ "model": "api.user", "pk": "8b38e2eb-6689-4f1e-a4ba-95b275130200", "fields": { - "password": "pbkdf2_sha256$720000$vA62S78kog2c2ytycVQdke$Fp35GVLLMyy5fUq3krSL9I02A+ocQ+RVa4S22LIAO5s=", + "password": "pbkdf2_sha256$870000$Z63pGJ7nre48hfcGbk5S0O$rQpKczAmijs96xa+gPVJifpT3Fetb8DOusl5Eq6gxac=", "last_login": null, "name": "Devie Prowlerson", "email": "dev@prowler.com", @@ -16,7 +16,7 @@ "model": "api.user", "pk": "b6493a3a-c997-489b-8b99-278bf74de9f6", "fields": { - "password": "pbkdf2_sha256$720000$vA62S78kog2c2ytycVQdke$Fp35GVLLMyy5fUq3krSL9I02A+ocQ+RVa4S22LIAO5s=", + "password": "pbkdf2_sha256$870000$Z63pGJ7nre48hfcGbk5S0O$rQpKczAmijs96xa+gPVJifpT3Fetb8DOusl5Eq6gxac=", "last_login": null, "name": "Devietoo Prowlerson", "email": "dev2@prowler.com", diff --git a/api/src/backend/api/migrations/0032_saml.py b/api/src/backend/api/migrations/0032_saml.py new file mode 100644 index 0000000000..f1481e104b --- /dev/null +++ b/api/src/backend/api/migrations/0032_saml.py @@ -0,0 +1,150 @@ +# Generated by Django 5.1.10 on 2025-07-02 15:47 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import api.db_utils +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0031_scan_disable_on_cascade_periodic_tasks"), + ] + + operations = [ + migrations.AlterField( + model_name="integration", + name="integration_type", + field=api.db_utils.IntegrationTypeEnumField( + choices=[ + ("amazon_s3", "Amazon S3"), + ("aws_security_hub", "AWS Security Hub"), + ("jira", "JIRA"), + ("slack", "Slack"), + ] + ), + ), + migrations.CreateModel( + name="SAMLToken", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("expires_at", models.DateTimeField(editable=False)), + ("token", models.JSONField(unique=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "saml_tokens", + }, + ), + migrations.CreateModel( + name="SAMLConfiguration", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "email_domain", + models.CharField( + help_text="Email domain used to identify the tenant, e.g. prowlerdemo.com", + max_length=254, + unique=True, + ), + ), + ( + "metadata_xml", + models.TextField( + help_text="Raw IdP metadata XML to configure SingleSignOnService, certificates, etc." + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "saml_configurations", + }, + ), + migrations.AddConstraint( + model_name="samlconfiguration", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_samlconfiguration", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + migrations.AddConstraint( + model_name="samlconfiguration", + constraint=models.UniqueConstraint( + fields=("tenant",), name="unique_samlconfig_per_tenant" + ), + ), + migrations.CreateModel( + name="SAMLDomainIndex", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("email_domain", models.CharField(max_length=254, unique=True)), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "saml_domain_index", + }, + ), + migrations.AddConstraint( + model_name="samldomainindex", + constraint=models.UniqueConstraint( + fields=("email_domain", "tenant"), + name="unique_resources_by_email_domain", + ), + ), + migrations.AddConstraint( + model_name="samldomainindex", + constraint=api.rls.BaseSecurityConstraint( + name="statements_on_samldomainindex", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/migrations/0033_processors_enum.py b/api/src/backend/api/migrations/0033_processors_enum.py new file mode 100644 index 0000000000..7dbad72241 --- /dev/null +++ b/api/src/backend/api/migrations/0033_processors_enum.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.5 on 2025-03-03 15:46 + +from functools import partial + +from django.db import migrations + +from api.db_utils import PostgresEnumMigration, ProcessorTypeEnum, register_enum +from api.models import Processor + +ProcessorTypeEnumMigration = PostgresEnumMigration( + enum_name="processor_type", + enum_values=tuple( + processor_type[0] for processor_type in Processor.ProcessorChoices.choices + ), +) + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("api", "0032_saml"), + ] + + operations = [ + migrations.RunPython( + ProcessorTypeEnumMigration.create_enum_type, + reverse_code=ProcessorTypeEnumMigration.drop_enum_type, + ), + migrations.RunPython( + partial(register_enum, enum_class=ProcessorTypeEnum), + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/api/src/backend/api/migrations/0034_processors.py b/api/src/backend/api/migrations/0034_processors.py new file mode 100644 index 0000000000..3df4eaf53b --- /dev/null +++ b/api/src/backend/api/migrations/0034_processors.py @@ -0,0 +1,88 @@ +# Generated by Django 5.1.5 on 2025-03-26 13:04 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import api.db_utils +import api.rls +from api.rls import RowLevelSecurityConstraint + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0033_processors_enum"), + ] + + operations = [ + migrations.CreateModel( + name="Processor", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "processor_type", + api.db_utils.ProcessorTypeEnumField( + choices=[("mutelist", "Mutelist")] + ), + ), + ("configuration", models.JSONField(default=dict)), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "processors", + "abstract": False, + "indexes": [ + models.Index( + fields=["tenant_id", "id"], name="processor_tenant_id_idx" + ), + models.Index( + fields=["tenant_id", "processor_type"], + name="processor_tenant_type_idx", + ), + ], + }, + ), + migrations.AddConstraint( + model_name="processor", + constraint=models.UniqueConstraint( + fields=("tenant_id", "processor_type"), + name="unique_processor_types_tenant", + ), + ), + migrations.AddConstraint( + model_name="processor", + constraint=RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_processor", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + migrations.AddField( + model_name="scan", + name="processor", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="scans", + related_query_name="scan", + to="api.processor", + ), + ), + ] diff --git a/api/src/backend/api/migrations/0035_finding_muted_reason.py b/api/src/backend/api/migrations/0035_finding_muted_reason.py new file mode 100644 index 0000000000..c847864830 --- /dev/null +++ b/api/src/backend/api/migrations/0035_finding_muted_reason.py @@ -0,0 +1,22 @@ +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0034_processors"), + ] + + operations = [ + migrations.AddField( + model_name="finding", + name="muted_reason", + field=models.TextField( + blank=True, + max_length=500, + null=True, + validators=[django.core.validators.MinLengthValidator(3)], + ), + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index b321c5fe96..0d486afed3 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -1,15 +1,21 @@ import json import logging import re +import xml.etree.ElementTree as ET +from datetime import datetime, timedelta, timezone from uuid import UUID, uuid4 +from allauth.socialaccount.models import SocialApp from config.custom_logging import BackendLogger +from config.settings.social_login import SOCIALACCOUNT_PROVIDERS from cryptography.fernet import Fernet, InvalidToken from django.conf import settings from django.contrib.auth.models import AbstractBaseUser from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.search import SearchVector, SearchVectorField +from django.contrib.sites.models import Site +from django.core.exceptions import ValidationError from django.core.validators import MinLengthValidator from django.db import models from django.db.models import Q @@ -21,12 +27,14 @@ from psqlextra.models import PostgresPartitionedModel from psqlextra.types import PostgresPartitioningMethod from uuid6 import uuid7 +from api.db_router import MainRouter from api.db_utils import ( CustomUserManager, FindingDeltaEnumField, IntegrationTypeEnumField, InvitationStateEnumField, MemberRoleEnumField, + ProcessorTypeEnumField, ProviderEnumField, ProviderSecretTypeEnumField, ScanTriggerEnumField, @@ -402,20 +410,6 @@ class Scan(RowLevelSecurityProtectedModel): name = models.CharField( blank=True, null=True, max_length=100, validators=[MinLengthValidator(3)] ) - provider = models.ForeignKey( - Provider, - on_delete=models.CASCADE, - related_name="scans", - related_query_name="scan", - ) - task = models.ForeignKey( - Task, - on_delete=models.CASCADE, - related_name="scans", - related_query_name="scan", - null=True, - blank=True, - ) trigger = ScanTriggerEnumField( choices=TriggerChoices.choices, ) @@ -434,8 +428,28 @@ class Scan(RowLevelSecurityProtectedModel): PeriodicTask, on_delete=models.SET_NULL, null=True, blank=True ) output_location = models.CharField(blank=True, null=True, max_length=200) - - # TODO: mutelist foreign key + provider = models.ForeignKey( + Provider, + on_delete=models.CASCADE, + related_name="scans", + related_query_name="scan", + ) + task = models.ForeignKey( + Task, + on_delete=models.CASCADE, + related_name="scans", + related_query_name="scan", + null=True, + blank=True, + ) + processor = models.ForeignKey( + "Processor", + on_delete=models.SET_NULL, + related_name="scans", + related_query_name="scan", + null=True, + blank=True, + ) class Meta(RowLevelSecurityProtectedModel.Meta): db_table = "scans" @@ -691,6 +705,9 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel): check_id = models.CharField(max_length=100, blank=False, null=False) check_metadata = models.JSONField(default=dict, null=False) muted = models.BooleanField(default=False, null=False) + muted_reason = models.TextField( + blank=True, null=True, validators=[MinLengthValidator(3)], max_length=500 + ) compliance = models.JSONField(default=dict, null=True, blank=True) # Denormalize resource data for performance @@ -936,6 +953,11 @@ class Invitation(RowLevelSecurityProtectedModel): null=True, ) + def save(self, *args, **kwargs): + if self.email: + self.email = self.email.strip().lower() + super().save(*args, **kwargs) + class Meta(RowLevelSecurityProtectedModel.Meta): db_table = "invitations" @@ -1364,242 +1386,271 @@ class IntegrationProviderRelationship(RowLevelSecurityProtectedModel): ] -# class SAMLToken(models.Model): -# id = models.UUIDField(primary_key=True, default=uuid4, editable=False) -# inserted_at = models.DateTimeField(auto_now_add=True, editable=False) -# updated_at = models.DateTimeField(auto_now=True, editable=False) -# expires_at = models.DateTimeField(editable=False) -# token = models.JSONField(unique=True) -# user = models.ForeignKey(User, on_delete=models.CASCADE) +class SAMLToken(models.Model): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) + expires_at = models.DateTimeField(editable=False) + token = models.JSONField(unique=True) + user = models.ForeignKey(User, on_delete=models.CASCADE) -# class Meta: -# db_table = "saml_tokens" + class Meta: + db_table = "saml_tokens" -# def save(self, *args, **kwargs): -# if not self.expires_at: -# self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=15) -# super().save(*args, **kwargs) + def save(self, *args, **kwargs): + if not self.expires_at: + self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=15) + super().save(*args, **kwargs) -# def is_expired(self) -> bool: -# return datetime.now(timezone.utc) >= self.expires_at + def is_expired(self) -> bool: + return datetime.now(timezone.utc) >= self.expires_at -# class SAMLDomainIndex(models.Model): -# """ -# Public index of SAML domains. No RLS. Used for fast lookup in SAML login flow. -# """ +class SAMLDomainIndex(models.Model): + """ + Public index of SAML domains. No RLS. Used for fast lookup in SAML login flow. + """ -# email_domain = models.CharField(max_length=254, unique=True) -# tenant = models.ForeignKey("Tenant", on_delete=models.CASCADE) + email_domain = models.CharField(max_length=254, unique=True) + tenant = models.ForeignKey("Tenant", on_delete=models.CASCADE) -# class Meta: -# db_table = "saml_domain_index" + class Meta: + db_table = "saml_domain_index" -# constraints = [ -# models.UniqueConstraint( -# fields=("email_domain", "tenant"), -# name="unique_resources_by_email_domain", -# ), -# BaseSecurityConstraint( -# name="statements_on_%(class)s", -# statements=["SELECT", "INSERT", "UPDATE", "DELETE"], -# ), -# ] + constraints = [ + models.UniqueConstraint( + fields=("email_domain", "tenant"), + name="unique_resources_by_email_domain", + ), + BaseSecurityConstraint( + name="statements_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ] -# class SAMLConfiguration(RowLevelSecurityProtectedModel): -# """ -# Stores per-tenant SAML settings, including email domain and IdP metadata. -# Automatically syncs to a SocialApp instance on save. +class SAMLConfiguration(RowLevelSecurityProtectedModel): + """ + Stores per-tenant SAML settings, including email domain and IdP metadata. + Automatically syncs to a SocialApp instance on save. -# Note: -# This model exists to provide a tenant-aware abstraction over SAML configuration. -# It supports row-level security, custom validation, and metadata parsing, enabling -# Prowler to expose a clean API and admin interface for managing SAML integrations. + Note: + This model exists to provide a tenant-aware abstraction over SAML configuration. + It supports row-level security, custom validation, and metadata parsing, enabling + Prowler to expose a clean API and admin interface for managing SAML integrations. -# Although Django Allauth uses the SocialApp model to store provider configuration, -# it is not designed for multi-tenant use. SocialApp lacks support for tenant scoping, -# email domain mapping, and structured metadata handling. + Although Django Allauth uses the SocialApp model to store provider configuration, + it is not designed for multi-tenant use. SocialApp lacks support for tenant scoping, + email domain mapping, and structured metadata handling. -# By managing SAMLConfiguration separately, we ensure: -# - Strong isolation between tenants via RLS. -# - Ownership of raw IdP metadata and its validation. -# - An explicit link between SAML config and business-level identifiers (e.g. email domain). -# - Programmatic transformation into the SocialApp format used by Allauth. + By managing SAMLConfiguration separately, we ensure: + - Strong isolation between tenants via RLS. + - Ownership of raw IdP metadata and its validation. + - An explicit link between SAML config and business-level identifiers (e.g. email domain). + - Programmatic transformation into the SocialApp format used by Allauth. -# In short, this model acts as a secure and user-friendly layer over Allauth's lower-level primitives. -# """ + In short, this model acts as a secure and user-friendly layer over Allauth's lower-level primitives. + """ -# id = models.UUIDField(primary_key=True, default=uuid4, editable=False) -# email_domain = models.CharField( -# max_length=254, -# unique=True, -# help_text="Email domain used to identify the tenant, e.g. prowlerdemo.com", -# ) -# metadata_xml = models.TextField( -# help_text="Raw IdP metadata XML to configure SingleSignOnService, certificates, etc." -# ) -# created_at = models.DateTimeField(auto_now_add=True) -# updated_at = models.DateTimeField(auto_now=True) + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + email_domain = models.CharField( + max_length=254, + unique=True, + help_text="Email domain used to identify the tenant, e.g. prowlerdemo.com", + ) + metadata_xml = models.TextField( + help_text="Raw IdP metadata XML to configure SingleSignOnService, certificates, etc." + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) -# class JSONAPIMeta: -# resource_name = "saml-configurations" + class JSONAPIMeta: + resource_name = "saml-configurations" -# class Meta: -# db_table = "saml_configurations" + class Meta: + db_table = "saml_configurations" -# constraints = [ -# RowLevelSecurityConstraint( -# field="tenant_id", -# name="rls_on_%(class)s", -# statements=["SELECT", "INSERT", "UPDATE", "DELETE"], -# ), -# # 1 config per tenant -# models.UniqueConstraint( -# fields=["tenant"], -# name="unique_samlconfig_per_tenant", -# ), -# ] + constraints = [ + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + # 1 config per tenant + models.UniqueConstraint( + fields=["tenant"], + name="unique_samlconfig_per_tenant", + ), + ] -# def clean(self, old_email_domain=None): -# # Domain must not contain @ -# if "@" in self.email_domain: -# raise ValidationError({"email_domain": "Domain must not contain @"}) + def clean(self, old_email_domain=None, is_create=False): + # Domain must not contain @ + if "@" in self.email_domain: + raise ValidationError({"email_domain": "Domain must not contain @"}) -# # Enforce at most one config per tenant -# qs = SAMLConfiguration.objects.filter(tenant=self.tenant) -# # Exclude ourselves in case of update -# if self.pk: -# qs = qs.exclude(pk=self.pk) -# if qs.exists(): -# raise ValidationError( -# {"tenant": "A SAML configuration already exists for this tenant."} -# ) + # Enforce at most one config per tenant + qs = SAMLConfiguration.objects.filter(tenant=self.tenant) + # Exclude ourselves in case of update + if self.pk: + qs = qs.exclude(pk=self.pk) + if qs.exists(): + raise ValidationError( + {"tenant": "A SAML configuration already exists for this tenant."} + ) -# # The email domain must be unique in the entire system -# qs = SAMLConfiguration.objects.using(MainRouter.admin_db).filter( -# email_domain__iexact=self.email_domain -# ) -# if qs.exists() and old_email_domain != self.email_domain: -# raise ValidationError( -# {"tenant": "There is a problem with your email domain."} -# ) + # The email domain must be unique in the entire system + qs = SAMLConfiguration.objects.using(MainRouter.admin_db).filter( + email_domain__iexact=self.email_domain + ) + if qs.exists() and old_email_domain != self.email_domain: + raise ValidationError( + {"tenant": "There is a problem with your email domain."} + ) -# def save(self, *args, **kwargs): -# self.email_domain = self.email_domain.strip().lower() -# is_create = not SAMLConfiguration.objects.filter(pk=self.pk).exists() + # The entityID must be unique in the system + idp_settings = self._parsed_metadata + entity_id = idp_settings.get("entity_id") -# if not is_create: -# old = SAMLConfiguration.objects.get(pk=self.pk) -# old_email_domain = old.email_domain -# old_metadata_xml = old.metadata_xml -# else: -# old_email_domain = None -# old_metadata_xml = None + if entity_id: + # Find any SocialApp with this entityID + q = SocialApp.objects.filter(provider="saml", provider_id=entity_id) -# self.clean(old_email_domain) -# super().save(*args, **kwargs) + # If updating, exclude our own SocialApp from the check + if not is_create: + q = q.exclude(client_id=old_email_domain) + else: + q = q.exclude(client_id=self.email_domain) -# if is_create or ( -# old_email_domain != self.email_domain -# or old_metadata_xml != self.metadata_xml -# ): -# self._sync_social_app(old_email_domain) + if q.exists(): + raise ValidationError( + {"metadata_xml": "There is a problem with your metadata."} + ) -# # Sync the public index -# if not is_create and old_email_domain and old_email_domain != self.email_domain: -# SAMLDomainIndex.objects.filter(email_domain=old_email_domain).delete() + def save(self, *args, **kwargs): + self.email_domain = self.email_domain.strip().lower() + is_create = not SAMLConfiguration.objects.filter(pk=self.pk).exists() -# # Create/update the new domain index -# SAMLDomainIndex.objects.update_or_create( -# email_domain=self.email_domain, defaults={"tenant": self.tenant} -# ) + if not is_create: + old = SAMLConfiguration.objects.get(pk=self.pk) + old_email_domain = old.email_domain + old_metadata_xml = old.metadata_xml + else: + old_email_domain = None + old_metadata_xml = None -# def _parse_metadata(self): -# """ -# Parse the raw IdP metadata XML and extract: -# - entity_id -# - sso_url -# - slo_url (may be None) -# - x509cert (required) -# """ -# ns = { -# "md": "urn:oasis:names:tc:SAML:2.0:metadata", -# "ds": "http://www.w3.org/2000/09/xmldsig#", -# } -# try: -# root = ET.fromstring(self.metadata_xml) -# except ET.ParseError as e: -# raise ValidationError({"metadata_xml": f"Invalid XML: {e}"}) + self._parsed_metadata = self._parse_metadata() + self.clean(old_email_domain, is_create) + super().save(*args, **kwargs) -# # Entity ID -# entity_id = root.attrib.get("entityID") + if is_create or ( + old_email_domain != self.email_domain + or old_metadata_xml != self.metadata_xml + ): + self._sync_social_app(old_email_domain) -# # SSO endpoint (must exist) -# sso = root.find(".//md:IDPSSODescriptor/md:SingleSignOnService", ns) -# if sso is None or "Location" not in sso.attrib: -# raise ValidationError( -# {"metadata_xml": "Missing SingleSignOnService in metadata."} -# ) -# sso_url = sso.attrib["Location"] + # Sync the public index + if not is_create and old_email_domain and old_email_domain != self.email_domain: + SAMLDomainIndex.objects.filter(email_domain=old_email_domain).delete() -# # SLO endpoint (optional) -# slo = root.find(".//md:IDPSSODescriptor/md:SingleLogoutService", ns) -# slo_url = slo.attrib.get("Location") if slo is not None else None + # Create/update the new domain index + SAMLDomainIndex.objects.update_or_create( + email_domain=self.email_domain, defaults={"tenant": self.tenant} + ) -# # X.509 certificate (required) -# cert = root.find( -# './/md:KeyDescriptor[@use="signing"]/ds:KeyInfo/ds:X509Data/ds:X509Certificate', -# ns, -# ) -# if cert is None or not cert.text or not cert.text.strip(): -# raise ValidationError( -# { -# "metadata_xml": 'Metadata must include a under .' -# } -# ) -# x509cert = cert.text.strip() + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) -# return { -# "entity_id": entity_id, -# "sso_url": sso_url, -# "slo_url": slo_url, -# "x509cert": x509cert, -# } + SocialApp.objects.filter(provider="saml", client_id=self.email_domain).delete() + SAMLDomainIndex.objects.filter(email_domain=self.email_domain).delete() -# def _sync_social_app(self, previous_email_domain=None): -# """ -# Create or update the corresponding SocialApp based on email_domain. -# If the domain changed, update the matching SocialApp. -# """ -# idp_settings = self._parse_metadata() -# settings_dict = SOCIALACCOUNT_PROVIDERS["saml"].copy() -# settings_dict["idp"] = idp_settings + def _parse_metadata(self): + """ + Parse the raw IdP metadata XML and extract: + - entity_id + - sso_url + - slo_url (may be None) + - x509cert (required) + """ + ns = { + "md": "urn:oasis:names:tc:SAML:2.0:metadata", + "ds": "http://www.w3.org/2000/09/xmldsig#", + } + try: + root = ET.fromstring(self.metadata_xml) + except ET.ParseError as e: + raise ValidationError({"metadata_xml": f"Invalid XML: {e}"}) -# current_site = Site.objects.get(id=settings.SITE_ID) + # Entity ID + entity_id = root.attrib.get("entityID") + if not entity_id: + raise ValidationError({"metadata_xml": "Missing entityID in metadata."}) -# social_app_qs = SocialApp.objects.filter( -# provider="saml", client_id=previous_email_domain or self.email_domain -# ) + # SSO endpoint (must exist) + sso = root.find(".//md:IDPSSODescriptor/md:SingleSignOnService", ns) + if sso is None or "Location" not in sso.attrib: + raise ValidationError( + {"metadata_xml": "Missing SingleSignOnService in metadata."} + ) + sso_url = sso.attrib["Location"] -# client_id = self.email_domain[:191] -# name = f"SAML-{self.email_domain}"[:40] + # SLO endpoint (optional) + slo = root.find(".//md:IDPSSODescriptor/md:SingleLogoutService", ns) + slo_url = slo.attrib.get("Location") if slo is not None else None -# if social_app_qs.exists(): -# social_app = social_app_qs.first() -# social_app.client_id = client_id -# social_app.name = name -# social_app.settings = settings_dict -# social_app.save() -# social_app.sites.set([current_site]) -# else: -# social_app = SocialApp.objects.create( -# provider="saml", -# client_id=client_id, -# name=name, -# settings=settings_dict, -# ) -# social_app.sites.set([current_site]) + # X.509 certificate (required) + cert = root.find( + './/md:KeyDescriptor[@use="signing"]/ds:KeyInfo/ds:X509Data/ds:X509Certificate', + ns, + ) + if cert is None or not cert.text or not cert.text.strip(): + raise ValidationError( + { + "metadata_xml": 'Metadata must include a under .' + } + ) + x509cert = cert.text.strip() + + return { + "entity_id": entity_id, + "sso_url": sso_url, + "slo_url": slo_url, + "x509cert": x509cert, + } + + def _sync_social_app(self, previous_email_domain=None): + """ + Create or update the corresponding SocialApp based on email_domain. + If the domain changed, update the matching SocialApp. + """ + settings_dict = SOCIALACCOUNT_PROVIDERS["saml"].copy() + settings_dict["idp"] = self._parsed_metadata + + current_site = Site.objects.get(id=settings.SITE_ID) + + social_app_qs = SocialApp.objects.filter( + provider="saml", client_id=previous_email_domain or self.email_domain + ) + + client_id = self.email_domain[:191] + name = f"SAML-{self.email_domain}"[:40] + + if social_app_qs.exists(): + social_app = social_app_qs.first() + social_app.client_id = client_id + social_app.name = name + social_app.settings = settings_dict + social_app.provider_id = self._parsed_metadata["entity_id"] + social_app.save() + social_app.sites.set([current_site]) + else: + social_app = SocialApp.objects.create( + provider="saml", + client_id=client_id, + name=name, + settings=settings_dict, + provider_id=self._parsed_metadata["entity_id"], + ) + social_app.sites.set([current_site]) class ResourceScanSummary(RowLevelSecurityProtectedModel): @@ -1776,3 +1827,42 @@ class LighthouseConfiguration(RowLevelSecurityProtectedModel): class JSONAPIMeta: resource_name = "lighthouse-configurations" + + +class Processor(RowLevelSecurityProtectedModel): + class ProcessorChoices(models.TextChoices): + MUTELIST = "mutelist", _("Mutelist") + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) + processor_type = ProcessorTypeEnumField(choices=ProcessorChoices.choices) + configuration = models.JSONField(default=dict) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "processors" + + constraints = [ + models.UniqueConstraint( + fields=("tenant_id", "processor_type"), + name="unique_processor_types_tenant", + ), + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ] + indexes = [ + models.Index( + fields=["tenant_id", "id"], + name="processor_tenant_id_idx", + ), + models.Index( + fields=["tenant_id", "processor_type"], + name="processor_tenant_type_idx", + ), + ] + + class JSONAPIMeta: + resource_name = "processors" diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index c17b6e996e..0164b63b7c 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -389,6 +389,7 @@ paths: - updated_at - first_seen_at - muted + - muted_reason - url - scan - resources @@ -860,6 +861,7 @@ paths: - updated_at - first_seen_at - muted + - muted_reason - url - scan - resources @@ -1354,6 +1356,7 @@ paths: - updated_at - first_seen_at - muted + - muted_reason - url - scan - resources @@ -3540,6 +3543,222 @@ paths: schema: $ref: '#/components/schemas/OverviewServiceResponse' description: '' + /api/v1/processors: + get: + operationId: processors_list + description: Retrieve a list of all configured processors with options for filtering + by various criteria. + summary: List all processors + parameters: + - in: query + name: fields[processors] + schema: + type: array + items: + type: string + enum: + - inserted_at + - updated_at + - processor_type + - configuration + - url + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: query + name: filter[processor_type] + schema: + type: string + enum: + - mutelist + description: '* `mutelist` - Mutelist' + - in: query + name: filter[processor_type__in] + schema: + type: array + items: + type: string + enum: + - mutelist + description: |- + Multiple values may be separated by commas. + + * `mutelist` - Mutelist + explode: false + style: form + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - 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: + - id + - -id + - inserted_at + - -inserted_at + - updated_at + - -updated_at + - processor_type + - -processor_type + - configuration + - -configuration + - url + - -url + explode: false + tags: + - Processor + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedProcessorList' + description: '' + post: + operationId: processors_create + description: Register a new processor with the system, providing necessary configuration + details. There can only be one processor of each type per tenant. + summary: Create a new processor + tags: + - Processor + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ProcessorCreateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/ProcessorCreateRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/ProcessorCreateRequest' + required: true + security: + - jwtAuth: [] + responses: + '201': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ProcessorCreateResponse' + description: '' + /api/v1/processors/{id}: + get: + operationId: processors_retrieve + description: Fetch detailed information about a specific processor by its ID. + summary: Retrieve processor details + parameters: + - in: query + name: fields[processors] + schema: + type: array + items: + type: string + enum: + - inserted_at + - updated_at + - processor_type + - configuration + - url + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this processor. + required: true + tags: + - Processor + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ProcessorResponse' + description: '' + patch: + operationId: processors_partial_update + description: Modify certain fields of an existing processor without affecting + other settings. + summary: Partially update a processor + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this processor. + required: true + tags: + - Processor + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PatchedProcessorUpdateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedProcessorUpdateRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedProcessorUpdateRequest' + required: true + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/ProcessorUpdateResponse' + description: '' + delete: + operationId: processors_destroy + description: Remove a processor from the system by its ID. + summary: Delete a processor + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this processor. + required: true + tags: + - Processor + security: + - jwtAuth: [] + responses: + '204': + description: No response body /api/v1/provider-groups: get: operationId: provider_groups_list @@ -5152,6 +5371,199 @@ paths: responses: '204': description: Relationship deleted successfully + /api/v1/saml-config: + get: + operationId: saml_config_list + description: Returns all the SAML-based SSO configurations associated with the + current tenant. + summary: List all SSO configurations + parameters: + - in: query + name: fields[saml-configurations] + schema: + type: array + items: + type: string + enum: + - email_domain + - metadata_xml + - created_at + - updated_at + description: endpoint return only specific fields in the response on a per-type + basis by including a fields[TYPE] query parameter. + explode: false + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - 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: + - id + - -id + - email_domain + - -email_domain + - metadata_xml + - -metadata_xml + - created_at + - -created_at + - updated_at + - -updated_at + explode: false + tags: + - SAML + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedSAMLConfigurationList' + description: '' + post: + operationId: saml_config_create + description: Creates a new SAML SSO configuration for the current tenant, including + email domain and metadata XML. + summary: Create the SSO configuration + tags: + - SAML + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/SAMLConfigurationRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/SAMLConfigurationRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/SAMLConfigurationRequest' + required: true + security: + - jwtAuth: [] + responses: + '201': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/SAMLConfigurationResponse' + description: '' + /api/v1/saml-config/{id}: + get: + operationId: saml_config_retrieve + description: Returns the details of a specific SAML configuration belonging + to the current tenant. + summary: Retrieve SSO configuration details + parameters: + - in: query + name: fields[saml-configurations] + schema: + type: array + items: + type: string + enum: + - email_domain + - metadata_xml + - created_at + - updated_at + 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 saml configuration. + required: true + tags: + - SAML + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/SAMLConfigurationResponse' + description: '' + patch: + operationId: saml_config_partial_update + description: Partially updates an existing SAML SSO configuration. Supports + changes to email domain and metadata XML. + summary: Update the SSO configuration + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this saml configuration. + required: true + tags: + - SAML + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PatchedSAMLConfigurationRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedSAMLConfigurationRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedSAMLConfigurationRequest' + required: true + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/SAMLConfigurationResponse' + description: '' + delete: + operationId: saml_config_destroy + description: Deletes an existing SAML SSO configuration associated with the + current tenant. + summary: Delete the SSO configuration + parameters: + - in: path + name: id + schema: + type: string + format: uuid + description: A UUID string identifying this saml configuration. + required: true + tags: + - SAML + security: + - jwtAuth: [] + responses: + '204': + description: No response body /api/v1/scans: get: operationId: scans_list @@ -5179,6 +5591,7 @@ paths: - completed_at - scheduled_at - next_scan_at + - processor - url description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. @@ -5490,6 +5903,7 @@ paths: - completed_at - scheduled_at - next_scan_at + - processor - url description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. @@ -7326,6 +7740,11 @@ components: nullable: true muted: type: boolean + muted_reason: + type: string + nullable: true + minLength: 3 + maxLength: 500 required: - uid - status @@ -9113,6 +9532,15 @@ components: $ref: '#/components/schemas/Membership' required: - data + PaginatedProcessorList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Processor' + required: + - data PaginatedProviderGroupList: type: object properties: @@ -9158,6 +9586,15 @@ components: $ref: '#/components/schemas/Role' required: - data + PaginatedSAMLConfigurationList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/SAMLConfiguration' + required: + - data PaginatedScanList: type: object properties: @@ -9485,6 +9922,106 @@ components: readOnly: true required: - data + PatchedProcessorUpdateRequest: + type: object + properties: + data: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - processors + id: {} + attributes: + type: object + properties: + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + configuration: + oneOf: + - type: object + title: Mutelist + properties: + Mutelist: + type: object + properties: + Accounts: + type: object + patternProperties: + .*: + type: object + properties: + Checks: + type: object + patternProperties: + .*: + type: object + properties: + Regions: + type: array + items: + type: string + Resources: + type: array + items: + type: string + Tags: + type: array + items: + type: string + Exceptions: + type: object + properties: + Accounts: + type: array + items: + type: string + Regions: + type: array + items: + type: string + Resources: + type: array + items: + type: string + Tags: + type: array + items: + type: string + additionalProperties: false + Description: + type: string + required: + - Regions + - Resources + additionalProperties: false + additionalProperties: false + required: + - Checks + additionalProperties: false + additionalProperties: false + required: + - Accounts + additionalProperties: false + additionalProperties: false + required: + - configuration + required: + - data PatchedProviderGroupMembershipRequest: type: object properties: @@ -9661,7 +10198,6 @@ components: * `static` - Key-value pairs * `role` - Role assumption * `service_account` - GCP Service Account Key - readOnly: true secret: oneOf: - type: object @@ -10042,6 +10578,52 @@ components: readOnly: true required: - data + PatchedSAMLConfigurationRequest: + type: object + properties: + data: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - saml-configurations + id: + type: string + format: uuid + attributes: + type: object + properties: + email_domain: + type: string + minLength: 1 + description: Email domain used to identify the tenant, e.g. prowlerdemo.com + maxLength: 254 + metadata_xml: + type: string + minLength: 1 + description: Raw IdP metadata XML to configure SingleSignOnService, + certificates, etc. + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - email_domain + - metadata_xml + required: + - data PatchedScanUpdateRequest: type: object properties: @@ -10212,6 +10794,425 @@ components: - email required: - data + Processor: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/Type552Enum' + 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: + type: string + format: uuid + attributes: + type: object + properties: + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + processor_type: + enum: + - mutelist + type: string + description: '* `mutelist` - Mutelist' + configuration: + oneOf: + - type: object + title: Mutelist + properties: + Mutelist: + type: object + properties: + Accounts: + type: object + patternProperties: + .*: + type: object + properties: + Checks: + type: object + patternProperties: + .*: + type: object + properties: + Regions: + type: array + items: + type: string + Resources: + type: array + items: + type: string + Tags: + type: array + items: + type: string + Exceptions: + type: object + properties: + Accounts: + type: array + items: + type: string + Regions: + type: array + items: + type: string + Resources: + type: array + items: + type: string + Tags: + type: array + items: + type: string + additionalProperties: false + Description: + type: string + required: + - Regions + - Resources + additionalProperties: false + additionalProperties: false + required: + - Checks + additionalProperties: false + additionalProperties: false + required: + - Accounts + additionalProperties: false + additionalProperties: false + required: + - processor_type + - configuration + ProcessorCreate: + type: object + required: + - type + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/Type552Enum' + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + attributes: + type: object + properties: + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + processor_type: + enum: + - mutelist + type: string + description: '* `mutelist` - Mutelist' + configuration: + oneOf: + - type: object + title: Mutelist + properties: + Mutelist: + type: object + properties: + Accounts: + type: object + patternProperties: + .*: + type: object + properties: + Checks: + type: object + patternProperties: + .*: + type: object + properties: + Regions: + type: array + items: + type: string + Resources: + type: array + items: + type: string + Tags: + type: array + items: + type: string + Exceptions: + type: object + properties: + Accounts: + type: array + items: + type: string + Regions: + type: array + items: + type: string + Resources: + type: array + items: + type: string + Tags: + type: array + items: + type: string + additionalProperties: false + Description: + type: string + required: + - Regions + - Resources + additionalProperties: false + additionalProperties: false + required: + - Checks + additionalProperties: false + additionalProperties: false + required: + - Accounts + additionalProperties: false + additionalProperties: false + required: + - processor_type + - configuration + ProcessorCreateRequest: + type: object + properties: + data: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - processors + attributes: + type: object + properties: + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + processor_type: + enum: + - mutelist + type: string + description: '* `mutelist` - Mutelist' + configuration: + oneOf: + - type: object + title: Mutelist + properties: + Mutelist: + type: object + properties: + Accounts: + type: object + patternProperties: + .*: + type: object + properties: + Checks: + type: object + patternProperties: + .*: + type: object + properties: + Regions: + type: array + items: + type: string + Resources: + type: array + items: + type: string + Tags: + type: array + items: + type: string + Exceptions: + type: object + properties: + Accounts: + type: array + items: + type: string + Regions: + type: array + items: + type: string + Resources: + type: array + items: + type: string + Tags: + type: array + items: + type: string + additionalProperties: false + Description: + type: string + required: + - Regions + - Resources + additionalProperties: false + additionalProperties: false + required: + - Checks + additionalProperties: false + additionalProperties: false + required: + - Accounts + additionalProperties: false + additionalProperties: false + required: + - processor_type + - configuration + required: + - data + ProcessorCreateResponse: + type: object + properties: + data: + $ref: '#/components/schemas/ProcessorCreate' + required: + - data + ProcessorResponse: + type: object + properties: + data: + $ref: '#/components/schemas/Processor' + required: + - data + ProcessorUpdate: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/Type552Enum' + 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: + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + configuration: + oneOf: + - type: object + title: Mutelist + properties: + Mutelist: + type: object + properties: + Accounts: + type: object + patternProperties: + .*: + type: object + properties: + Checks: + type: object + patternProperties: + .*: + type: object + properties: + Regions: + type: array + items: + type: string + Resources: + type: array + items: + type: string + Tags: + type: array + items: + type: string + Exceptions: + type: object + properties: + Accounts: + type: array + items: + type: string + Regions: + type: array + items: + type: string + Resources: + type: array + items: + type: string + Tags: + type: array + items: + type: string + additionalProperties: false + Description: + type: string + required: + - Regions + - Resources + additionalProperties: false + additionalProperties: false + required: + - Checks + additionalProperties: false + additionalProperties: false + required: + - Accounts + additionalProperties: false + additionalProperties: false + required: + - configuration + ProcessorUpdateResponse: + type: object + properties: + data: + $ref: '#/components/schemas/ProcessorUpdate' + required: + - data Provider: type: object required: @@ -11373,7 +12374,6 @@ components: * `static` - Key-value pairs * `role` - Role assumption * `service_account` - GCP Service Account Key - readOnly: true secret: oneOf: - type: object @@ -12155,6 +13155,97 @@ components: $ref: '#/components/schemas/Role' required: - data + SAMLConfiguration: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/SAMLConfigurationTypeEnum' + 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: + type: string + format: uuid + attributes: + type: object + properties: + email_domain: + type: string + description: Email domain used to identify the tenant, e.g. prowlerdemo.com + maxLength: 254 + metadata_xml: + type: string + description: Raw IdP metadata XML to configure SingleSignOnService, + certificates, etc. + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - email_domain + - metadata_xml + SAMLConfigurationRequest: + type: object + properties: + data: + type: object + required: + - type + additionalProperties: false + properties: + type: + type: string + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + enum: + - saml-configurations + attributes: + type: object + properties: + email_domain: + type: string + minLength: 1 + description: Email domain used to identify the tenant, e.g. prowlerdemo.com + maxLength: 254 + metadata_xml: + type: string + minLength: 1 + description: Raw IdP metadata XML to configure SingleSignOnService, + certificates, etc. + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - email_domain + - metadata_xml + required: + - data + SAMLConfigurationResponse: + type: object + properties: + data: + $ref: '#/components/schemas/SAMLConfiguration' + required: + - data + SAMLConfigurationTypeEnum: + type: string + enum: + - saml-configurations Scan: type: object required: @@ -12290,6 +13381,31 @@ components: description: The identifier of the related object. title: Resource Identifier nullable: true + processor: + type: object + properties: + data: + type: object + properties: + id: + type: string + format: uuid + type: + type: string + enum: + - processors + 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: The identifier of the related object. + title: Resource Identifier + nullable: true required: - provider ScanCreateRequest: @@ -12848,6 +13964,10 @@ components: type: string enum: - lighthouse-configurations + Type552Enum: + type: string + enum: + - processors Type6bbEnum: type: string enum: @@ -13173,3 +14293,6 @@ tags: description: Endpoints for managing Lighthouse configurations, including creation, retrieval, updating, and deletion of configurations such as OpenAI keys, models, and business context. +- name: Processor + description: Endpoints for managing post-processors used to process Prowler findings, + including registration, configuration, and deletion of post-processing actions. diff --git a/api/src/backend/api/tests/integration/test_authentication.py b/api/src/backend/api/tests/integration/test_authentication.py index f41e8207c4..24d8e16f76 100644 --- a/api/src/backend/api/tests/integration/test_authentication.py +++ b/api/src/backend/api/tests/integration/test_authentication.py @@ -11,7 +11,7 @@ def test_basic_authentication(): client = APIClient() test_user = "test_email@prowler.com" - test_password = "test_password" + test_password = "Test_password@1" # Check that a 401 is returned when no basic authentication is provided no_auth_response = client.get(reverse("provider-list")) @@ -108,7 +108,7 @@ def test_user_me_when_inviting_users(create_test_user, tenants_fixture, roles_fi user1_email = "user1@testing.com" user2_email = "user2@testing.com" - password = "thisisapassword123" + password = "Thisisapassword123@" user1_response = client.post( reverse("user-list"), @@ -187,7 +187,7 @@ class TestTokenSwitchTenant: client = APIClient() test_user = "test_email@prowler.com" - test_password = "test_password" + test_password = "Test_password1@" # Check that we can create a new user without any kind of authentication user_creation_response = client.post( diff --git a/api/src/backend/api/tests/integration/test_providers.py b/api/src/backend/api/tests/integration/test_providers.py index 0f17c2d839..9c91ad2c07 100644 --- a/api/src/backend/api/tests/integration/test_providers.py +++ b/api/src/backend/api/tests/integration/test_providers.py @@ -17,7 +17,7 @@ def test_delete_provider_without_executing_task( client = APIClient() test_user = "test_email@prowler.com" - test_password = "test_password" + test_password = "Test_password1@" prowler_task = tasks_fixture[0] task_mock = Mock() diff --git a/api/src/backend/api/tests/test_adapters.py b/api/src/backend/api/tests/test_adapters.py index b554719836..4351e3de3c 100644 --- a/api/src/backend/api/tests/test_adapters.py +++ b/api/src/backend/api/tests/test_adapters.py @@ -20,69 +20,37 @@ class TestProwlerSocialAccountAdapter: adapter = ProwlerSocialAccountAdapter() assert adapter.get_user_by_email("notfound@example.com") is None - # def test_pre_social_login_links_existing_user(self, create_test_user, rf): - # adapter = ProwlerSocialAccountAdapter() + def test_pre_social_login_links_existing_user(self, create_test_user, rf): + adapter = ProwlerSocialAccountAdapter() - # sociallogin = MagicMock(spec=SocialLogin) - # sociallogin.account = MagicMock() - # sociallogin.account.provider = "saml" - # sociallogin.account.extra_data = {} - # sociallogin.user = create_test_user - # sociallogin.connect = MagicMock() + sociallogin = MagicMock(spec=SocialLogin) + sociallogin.account = MagicMock() + sociallogin.provider = MagicMock() + sociallogin.provider.id = "saml" + sociallogin.account.extra_data = {} + sociallogin.user = create_test_user + sociallogin.connect = MagicMock() - # adapter.pre_social_login(rf.get("/"), sociallogin) + adapter.pre_social_login(rf.get("/"), sociallogin) - # call_args = sociallogin.connect.call_args - # assert call_args is not None + call_args = sociallogin.connect.call_args + assert call_args is not None - # called_request, called_user = call_args[0] - # assert called_request.path == "/" - # assert called_user.email == create_test_user.email + called_request, called_user = call_args[0] + assert called_request.path == "/" + assert called_user.email == create_test_user.email def test_pre_social_login_no_link_if_email_missing(self, rf): adapter = ProwlerSocialAccountAdapter() sociallogin = MagicMock(spec=SocialLogin) sociallogin.account = MagicMock() - sociallogin.account.provider = "github" + sociallogin.provider = MagicMock() + sociallogin.user = MagicMock() + sociallogin.provider.id = "saml" sociallogin.account.extra_data = {} sociallogin.connect = MagicMock() adapter.pre_social_login(rf.get("/"), sociallogin) sociallogin.connect.assert_not_called() - - # def test_save_user_saml_flow( - # self, - # rf, - # saml_setup, - # saml_sociallogin, - # ): - # adapter = ProwlerSocialAccountAdapter() - # request = rf.get("/") - # saml_sociallogin.user.email = saml_setup["email"] - # saml_sociallogin.account.extra_data = { - # "firstName": [], - # "lastName": [], - # "organization": [], - # "userType": [], - # } - - # tenant = Tenant.objects.using(MainRouter.admin_db).get( - # id=saml_setup["tenant_id"] - # ) - # saml_config = SAMLConfiguration.objects.using(MainRouter.admin_db).get( - # tenant=tenant - # ) - # assert saml_config.email_domain == saml_setup["domain"] - - # user = adapter.save_user(request, saml_sociallogin) - - # assert user.name == "N/A" - # assert user.company_name == "" - # assert user.email == saml_setup["email"] - # assert ( - # Membership.objects.using(MainRouter.admin_db) - # .filter(user=user, tenant=tenant) - # .exists() - # ) diff --git a/api/src/backend/api/tests/test_models.py b/api/src/backend/api/tests/test_models.py index f02cf7b9d2..79d405d4a7 100644 --- a/api/src/backend/api/tests/test_models.py +++ b/api/src/backend/api/tests/test_models.py @@ -1,6 +1,9 @@ import pytest +from allauth.socialaccount.models import SocialApp +from django.core.exceptions import ValidationError -from api.models import Resource, ResourceTag +from api.db_router import MainRouter +from api.models import Resource, ResourceTag, SAMLConfiguration, SAMLDomainIndex @pytest.mark.django_db @@ -122,147 +125,202 @@ class TestResourceModel: # assert Finding.objects.filter(uid=long_uid).exists() -# @pytest.mark.django_db -# class TestSAMLConfigurationModel: -# VALID_METADATA = """ -# -# -# -# -# -# FAKECERTDATA -# -# -# -# -# -# -# """ +@pytest.mark.django_db +class TestSAMLConfigurationModel: + VALID_METADATA = """ + + + + + + FAKECERTDATA + + + + + + + """ -# def test_creates_valid_configuration(self): -# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant A") -# config = SAMLConfiguration.objects.using(MainRouter.admin_db).create( -# email_domain="ssoexample.com", -# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA, -# tenant=tenant, -# ) + def test_creates_valid_configuration(self, tenants_fixture): + tenant = tenants_fixture[0] + config = SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain="ssoexample.com", + metadata_xml=TestSAMLConfigurationModel.VALID_METADATA, + tenant=tenant, + ) -# assert config.email_domain == "ssoexample.com" -# assert SocialApp.objects.filter(client_id="ssoexample.com").exists() + assert config.email_domain == "ssoexample.com" + assert SocialApp.objects.filter(client_id="ssoexample.com").exists() -# def test_email_domain_with_at_symbol_fails(self): -# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant B") -# config = SAMLConfiguration( -# email_domain="invalid@domain.com", -# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA, -# tenant=tenant, -# ) + def test_email_domain_with_at_symbol_fails(self, tenants_fixture): + tenant = tenants_fixture[0] + config = SAMLConfiguration( + email_domain="invalid@domain.com", + metadata_xml=TestSAMLConfigurationModel.VALID_METADATA, + tenant=tenant, + ) -# with pytest.raises(ValidationError) as exc_info: -# config.clean() + with pytest.raises(ValidationError) as exc_info: + config.clean() -# errors = exc_info.value.message_dict -# assert "email_domain" in errors -# assert "Domain must not contain @" in errors["email_domain"][0] + errors = exc_info.value.message_dict + assert "email_domain" in errors + assert "Domain must not contain @" in errors["email_domain"][0] -# def test_duplicate_email_domain_fails(self): -# tenant1 = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant C1") -# tenant2 = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant C2") + def test_duplicate_email_domain_fails(self, tenants_fixture): + tenant1, tenant2, *_ = tenants_fixture -# SAMLConfiguration.objects.using(MainRouter.admin_db).create( -# email_domain="duplicate.com", -# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA, -# tenant=tenant1, -# ) + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain="duplicate.com", + metadata_xml=TestSAMLConfigurationModel.VALID_METADATA, + tenant=tenant1, + ) -# config = SAMLConfiguration( -# email_domain="duplicate.com", -# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA, -# tenant=tenant2, -# ) + config = SAMLConfiguration( + email_domain="duplicate.com", + metadata_xml=TestSAMLConfigurationModel.VALID_METADATA, + tenant=tenant2, + ) -# with pytest.raises(ValidationError) as exc_info: -# config.clean() + with pytest.raises(ValidationError) as exc_info: + config.clean() -# errors = exc_info.value.message_dict -# assert "tenant" in errors -# assert "There is a problem with your email domain." in errors["tenant"][0] + errors = exc_info.value.message_dict + assert "tenant" in errors + assert "There is a problem with your email domain." in errors["tenant"][0] -# def test_duplicate_tenant_config_fails(self): -# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant D") + def test_duplicate_tenant_config_fails(self, tenants_fixture): + tenant = tenants_fixture[0] -# SAMLConfiguration.objects.using(MainRouter.admin_db).create( -# email_domain="unique1.com", -# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA, -# tenant=tenant, -# ) + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain="unique1.com", + metadata_xml=TestSAMLConfigurationModel.VALID_METADATA, + tenant=tenant, + ) -# config = SAMLConfiguration( -# email_domain="unique2.com", -# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA, -# tenant=tenant, -# ) + config = SAMLConfiguration( + email_domain="unique2.com", + metadata_xml=TestSAMLConfigurationModel.VALID_METADATA, + tenant=tenant, + ) -# with pytest.raises(ValidationError) as exc_info: -# config.clean() + with pytest.raises(ValidationError) as exc_info: + config.clean() -# errors = exc_info.value.message_dict -# assert "tenant" in errors -# assert ( -# "A SAML configuration already exists for this tenant." -# in errors["tenant"][0] -# ) + errors = exc_info.value.message_dict + assert "tenant" in errors + assert ( + "A SAML configuration already exists for this tenant." + in errors["tenant"][0] + ) -# def test_invalid_metadata_xml_fails(self): -# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant E") -# config = SAMLConfiguration( -# email_domain="brokenxml.com", -# metadata_xml="", -# tenant=tenant, -# ) + def test_invalid_metadata_xml_fails(self, tenants_fixture): + tenant = tenants_fixture[0] + config = SAMLConfiguration( + email_domain="brokenxml.com", + metadata_xml="", + tenant=tenant, + ) -# with pytest.raises(ValidationError) as exc_info: -# config._parse_metadata() + with pytest.raises(ValidationError) as exc_info: + config._parse_metadata() -# errors = exc_info.value.message_dict -# assert "metadata_xml" in errors -# assert "Invalid XML" in errors["metadata_xml"][0] -# assert "not well-formed" in errors["metadata_xml"][0] + errors = exc_info.value.message_dict + assert "metadata_xml" in errors + assert "Invalid XML" in errors["metadata_xml"][0] + assert "not well-formed" in errors["metadata_xml"][0] -# def test_metadata_missing_sso_fails(self): -# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant F") -# xml = """ -# -# """ -# config = SAMLConfiguration( -# email_domain="nosso.com", -# metadata_xml=xml, -# tenant=tenant, -# ) + def test_metadata_missing_sso_fails(self, tenants_fixture): + tenant = tenants_fixture[0] + xml = """ + + """ + config = SAMLConfiguration( + email_domain="nosso.com", + metadata_xml=xml, + tenant=tenant, + ) -# with pytest.raises(ValidationError) as exc_info: -# config._parse_metadata() + with pytest.raises(ValidationError) as exc_info: + config._parse_metadata() -# errors = exc_info.value.message_dict -# assert "metadata_xml" in errors -# assert "Missing SingleSignOnService" in errors["metadata_xml"][0] + errors = exc_info.value.message_dict + assert "metadata_xml" in errors + assert "Missing SingleSignOnService" in errors["metadata_xml"][0] -# def test_metadata_missing_certificate_fails(self): -# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant G") -# xml = """ -# -# -# -# """ -# config = SAMLConfiguration( -# email_domain="nocert.com", -# metadata_xml=xml, -# tenant=tenant, -# ) + def test_metadata_missing_certificate_fails(self, tenants_fixture): + tenant = tenants_fixture[0] + xml = """ + + + + """ + config = SAMLConfiguration( + email_domain="nocert.com", + metadata_xml=xml, + tenant=tenant, + ) -# with pytest.raises(ValidationError) as exc_info: -# config._parse_metadata() + with pytest.raises(ValidationError) as exc_info: + config._parse_metadata() -# errors = exc_info.value.message_dict -# assert "metadata_xml" in errors -# assert "X509Certificate" in errors["metadata_xml"][0] + errors = exc_info.value.message_dict + assert "metadata_xml" in errors + assert "X509Certificate" in errors["metadata_xml"][0] + + def test_deletes_saml_configuration_and_related_objects(self, tenants_fixture): + tenant = tenants_fixture[0] + email_domain = "deleteme.com" + + # Create the configuration + config = SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain=email_domain, + metadata_xml=TestSAMLConfigurationModel.VALID_METADATA, + tenant=tenant, + ) + + # Verify that the SocialApp and SAMLDomainIndex exist + assert SocialApp.objects.filter(client_id=email_domain).exists() + assert ( + SAMLDomainIndex.objects.using(MainRouter.admin_db) + .filter(email_domain=email_domain) + .exists() + ) + + # Delete the configuration + config.delete() + + # Verify that the configuration and its related objects are deleted + assert ( + not SAMLConfiguration.objects.using(MainRouter.admin_db) + .filter(pk=config.pk) + .exists() + ) + assert not SocialApp.objects.filter(client_id=email_domain).exists() + assert ( + not SAMLDomainIndex.objects.using(MainRouter.admin_db) + .filter(email_domain=email_domain) + .exists() + ) + + def test_duplicate_entity_id_fails_on_creation(self, tenants_fixture): + tenant1, tenant2, *_ = tenants_fixture + SAMLConfiguration.objects.using(MainRouter.admin_db).create( + email_domain="first.com", + metadata_xml=self.VALID_METADATA, + tenant=tenant1, + ) + + config = SAMLConfiguration( + email_domain="second.com", + metadata_xml=self.VALID_METADATA, + tenant=tenant2, + ) + + with pytest.raises(ValidationError) as exc_info: + config.save() + + errors = exc_info.value.message_dict + assert "metadata_xml" in errors + assert "There is a problem with your metadata." in errors["metadata_xml"][0] diff --git a/api/src/backend/api/tests/test_rbac.py b/api/src/backend/api/tests/test_rbac.py index 7365bae3d4..19ea256723 100644 --- a/api/src/backend/api/tests/test_rbac.py +++ b/api/src/backend/api/tests/test_rbac.py @@ -60,7 +60,7 @@ class TestUserViewSet: def test_create_user_with_all_permissions(self, authenticated_client_rbac): valid_user_payload = { "name": "test", - "password": "newpassword123", + "password": "Newpassword123@", "email": "new_user@test.com", } response = authenticated_client_rbac.post( @@ -74,7 +74,7 @@ class TestUserViewSet: ): valid_user_payload = { "name": "test", - "password": "newpassword123", + "password": "Newpassword123@", "email": "new_user@test.com", } response = authenticated_client_no_permissions_rbac.post( @@ -321,7 +321,7 @@ class TestProviderViewSet: @pytest.mark.django_db class TestLimitedVisibility: TEST_EMAIL = "rbac@rbac.com" - TEST_PASSWORD = "thisisapassword123" + TEST_PASSWORD = "Thisisapassword123@" @pytest.fixture def limited_admin_user( diff --git a/api/src/backend/api/tests/test_utils.py b/api/src/backend/api/tests/test_utils.py index 0777267484..352c8262fa 100644 --- a/api/src/backend/api/tests/test_utils.py +++ b/api/src/backend/api/tests/test_utils.py @@ -131,6 +131,21 @@ class TestInitializeProwlerProvider: initialize_prowler_provider(provider) mock_return_prowler_provider.return_value.assert_called_once_with(key="value") + @patch("api.utils.return_prowler_provider") + def test_initialize_prowler_provider_with_mutelist( + self, mock_return_prowler_provider + ): + provider = MagicMock() + provider.secret.secret = {"key": "value"} + mutelist_processor = MagicMock() + mutelist_processor.configuration = {"Mutelist": {"key": "value"}} + mock_return_prowler_provider.return_value = MagicMock() + + initialize_prowler_provider(provider, mutelist_processor) + mock_return_prowler_provider.return_value.assert_called_once_with( + key="value", mutelist_content={"key": "value"} + ) + class TestProwlerProviderConnectionTest: @patch("api.utils.return_prowler_provider") @@ -200,6 +215,25 @@ class TestGetProwlerProviderKwargs: expected_result = {**secret_dict, **expected_extra_kwargs} assert result == expected_result + def test_get_prowler_provider_kwargs_with_mutelist(self): + provider_uid = "provider_uid" + secret_dict = {"key": "value"} + secret_mock = MagicMock() + secret_mock.secret = secret_dict + + mutelist_processor = MagicMock() + mutelist_processor.configuration = {"Mutelist": {"key": "value"}} + + provider = MagicMock() + provider.provider = Provider.ProviderChoices.AWS.value + provider.secret = secret_mock + provider.uid = provider_uid + + result = get_prowler_provider_kwargs(provider, mutelist_processor) + + expected_result = {**secret_dict, "mutelist_content": {"key": "value"}} + assert result == expected_result + def test_get_prowler_provider_kwargs_unsupported_provider(self): # Setup provider_uid = "provider_uid" @@ -254,7 +288,7 @@ class TestValidateInvitation: assert result == invitation mock_db.get.assert_called_once_with( - token="VALID_TOKEN", email="user@example.com" + token="VALID_TOKEN", email__iexact="user@example.com" ) def test_invitation_not_found_raises_validation_error(self): @@ -269,7 +303,7 @@ class TestValidateInvitation: "invitation_token": "Invalid invitation code." } mock_db.get.assert_called_once_with( - token="INVALID_TOKEN", email="user@example.com" + token="INVALID_TOKEN", email__iexact="user@example.com" ) def test_invitation_not_found_raises_not_found(self): @@ -284,7 +318,7 @@ class TestValidateInvitation: assert exc_info.value.detail == "Invitation is not valid." mock_db.get.assert_called_once_with( - token="INVALID_TOKEN", email="user@example.com" + token="INVALID_TOKEN", email__iexact="user@example.com" ) def test_invitation_expired(self, invitation): @@ -332,5 +366,27 @@ class TestValidateInvitation: "invitation_token": "Invalid invitation code." } mock_db.get.assert_called_once_with( - token="VALID_TOKEN", email="different@example.com" + token="VALID_TOKEN", email__iexact="different@example.com" + ) + + def test_valid_invitation_uppercase_email(self): + """Test that validate_invitation works with case-insensitive email lookup.""" + uppercase_email = "USER@example.com" + + invitation = MagicMock(spec=Invitation) + invitation.token = "VALID_TOKEN" + invitation.email = uppercase_email + invitation.expires_at = datetime.now(timezone.utc) + timedelta(days=1) + invitation.state = Invitation.State.PENDING + invitation.tenant = MagicMock() + + with patch("api.utils.Invitation.objects.using") as mock_using: + mock_db = mock_using.return_value + mock_db.get.return_value = invitation + + result = validate_invitation("VALID_TOKEN", "user@example.com") + + assert result == invitation + mock_db.get.assert_called_once_with( + token="VALID_TOKEN", email__iexact="user@example.com" ) diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index fd744af4df..b064ee352f 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -5,29 +5,39 @@ import os import tempfile from datetime import datetime, timedelta, timezone from pathlib import Path +from types import SimpleNamespace from unittest.mock import ANY, MagicMock, Mock, patch +from urllib.parse import parse_qs, urlparse +from uuid import uuid4 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 django.conf import settings +from django.http import JsonResponse +from django.test import RequestFactory from django.urls import reverse from django_celery_results.models import TaskResult from rest_framework import status from rest_framework.response import Response from api.compliance import get_compliance_frameworks +from api.db_router import MainRouter from api.models import ( Integration, Invitation, Membership, + Processor, Provider, ProviderGroup, ProviderGroupMembership, ProviderSecret, Role, RoleProviderGroupRelationship, + SAMLConfiguration, + SAMLToken, Scan, StateChoices, Task, @@ -35,7 +45,7 @@ from api.models import ( UserRoleRelationship, ) from api.rls import Tenant -from api.v1.views import ComplianceOverviewViewSet +from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView TODAY = str(datetime.today().date()) @@ -83,7 +93,7 @@ class TestUserViewSet: def test_users_create(self, client): valid_user_payload = { "name": "test", - "password": "newpassword123", + "password": "NewPassword123!", "email": "NeWuSeR@example.com", } response = client.post( @@ -124,6 +134,10 @@ class TestUserViewSet: "password1", # Common password and too similar to a common password "dev12345", # Similar to username ("querty12" * 9) + "a", # Too long, 73 characters + "NewPassword123", # No special character + "newpassword123@", # No uppercase letter + "NEWPASSWORD123", # No lowercase letter + "NewPassword@", # No number ], ) def test_users_create_invalid_passwords(self, authenticated_client, password): @@ -154,7 +168,7 @@ class TestUserViewSet: # First user created; no errors should occur user_payload = { "name": "test_email_validator", - "password": "newpassword123", + "password": "Newpassword123@", "email": "nonexistentemail@prowler.com", } response = authenticated_client.post( @@ -164,7 +178,7 @@ class TestUserViewSet: user_payload = { "name": "test_email_validator", - "password": "newpassword123", + "password": "Newpassword123@", "email": email, } response = authenticated_client.post( @@ -257,6 +271,10 @@ class TestUserViewSet: # Fails UserAttributeSimilarityValidator (too similar to email) "dev12345", "test@prowler.com", + "NewPassword123", # No special character + "newpassword123@", # No uppercase letter + "NEWPASSWORD123", # No lowercase letter + "NewPassword@", # No number ], ) def test_users_partial_update_invalid_password( @@ -2029,6 +2047,104 @@ class TestProviderSecretViewSet: ) assert response.status_code == status.HTTP_400_BAD_REQUEST + def test_provider_secrets_partial_update_with_secret_type( + self, authenticated_client, provider_secret_fixture + ): + provider_secret, *_ = provider_secret_fixture + data = { + "data": { + "type": "provider-secrets", + "id": str(provider_secret.id), + "attributes": { + "name": "new_name", + "secret": { + "service_account_key": {}, + }, + "secret_type": "service_account", + }, + "relationships": { + "provider": { + "data": { + "type": "providers", + "id": str(provider_secret.provider.id), + } + } + }, + } + } + response = authenticated_client.patch( + reverse("providersecret-detail", kwargs={"pk": provider_secret.id}), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_200_OK + provider_secret.refresh_from_db() + assert provider_secret.name == "new_name" + assert provider_secret.secret == {"service_account_key": {}} + + def test_provider_secrets_partial_update_with_invalid_secret_type( + self, authenticated_client, provider_secret_fixture + ): + provider_secret, *_ = provider_secret_fixture + data = { + "data": { + "type": "provider-secrets", + "id": str(provider_secret.id), + "attributes": { + "name": "new_name", + "secret": { + "service_account_key": {}, + }, + "secret_type": "static", + }, + "relationships": { + "provider": { + "data": { + "type": "providers", + "id": str(provider_secret.provider.id), + } + } + }, + } + } + response = authenticated_client.patch( + reverse("providersecret-detail", kwargs={"pk": provider_secret.id}), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_provider_secrets_partial_update_without_secret_type_but_different( + self, authenticated_client, provider_secret_fixture + ): + provider_secret, *_ = provider_secret_fixture + data = { + "data": { + "type": "provider-secrets", + "id": str(provider_secret.id), + "attributes": { + "name": "new_name", + "secret": { + "service_account_key": {}, + }, + }, + "relationships": { + "provider": { + "data": { + "type": "providers", + "id": str(provider_secret.provider.id), + } + } + }, + } + } + response = authenticated_client.patch( + reverse("providersecret-detail", kwargs={"pk": provider_secret.id}), + data=json.dumps(data), + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + @pytest.mark.django_db class TestScanViewSet: @@ -3371,6 +3487,61 @@ class TestFindingViewSet: ] } + def test_findings_metadata_backfill( + self, authenticated_client, scans_fixture, findings_fixture + ): + scan = scans_fixture[0] + scan.unique_resource_count = 1 + scan.save() + + with patch( + "api.v1.views.backfill_scan_resource_summaries_task.apply_async" + ) as mock_backfill_task: + response = authenticated_client.get( + reverse("finding-metadata"), + {"filter[scan]": str(scan.id)}, + ) + assert response.status_code == status.HTTP_200_OK + mock_backfill_task.assert_called() + + def test_findings_metadata_backfill_no_resources( + self, authenticated_client, scans_fixture + ): + scan_id = str(scans_fixture[0].id) + with patch( + "api.v1.views.backfill_scan_resource_summaries_task.apply_async" + ) as mock_backfill_task: + response = authenticated_client.get( + reverse("finding-metadata"), + {"filter[scan]": scan_id}, + ) + assert response.status_code == status.HTTP_200_OK + mock_backfill_task.assert_not_called() + + def test_findings_metadata_latest_backfill( + self, authenticated_client, scans_fixture, findings_fixture + ): + scan = scans_fixture[0] + scan.unique_resource_count = 1 + scan.save() + + with patch( + "api.v1.views.backfill_scan_resource_summaries_task.apply_async" + ) as mock_backfill_task: + response = authenticated_client.get(reverse("finding-metadata_latest")) + assert response.status_code == status.HTTP_200_OK + mock_backfill_task.assert_called() + + def test_findings_metadata_latest_backfill_no_resources( + self, authenticated_client, scans_fixture + ): + with patch( + "api.v1.views.backfill_scan_resource_summaries_task.apply_async" + ) as mock_backfill_task: + response = authenticated_client.get(reverse("finding-metadata_latest")) + assert response.status_code == status.HTTP_200_OK + mock_backfill_task.assert_not_called() + def test_findings_latest(self, authenticated_client, latest_scan_finding): response = authenticated_client.get( reverse("finding-latest"), @@ -3787,7 +3958,7 @@ class TestInvitationViewSet: data = { "name": "test", - "password": "newpassword123", + "password": "Newpassword123@", "email": invitation.email, } assert invitation.state == Invitation.State.PENDING.value @@ -3879,7 +4050,7 @@ class TestInvitationViewSet: data = { "name": "test", - "password": "newpassword123", + "password": "Newpassword123@", "email": new_email, } @@ -5603,334 +5774,344 @@ class TestIntegrationViewSet: assert response.status_code == status.HTTP_400_BAD_REQUEST -# @pytest.mark.django_db -# class TestSAMLTokenValidation: -# def test_valid_token_returns_tokens(self, authenticated_client, create_test_user): -# user = create_test_user -# valid_token_data = { -# "access": "mock_access_token", -# "refresh": "mock_refresh_token", -# } -# saml_token = SAMLToken.objects.create( -# token=valid_token_data, -# user=user, -# expires_at=datetime.now(timezone.utc) + timedelta(seconds=10), -# ) +@pytest.mark.django_db +class TestSAMLTokenValidation: + def test_valid_token_returns_tokens(self, authenticated_client, create_test_user): + user = create_test_user + valid_token_data = { + "access": "mock_access_token", + "refresh": "mock_refresh_token", + } + saml_token = SAMLToken.objects.create( + token=valid_token_data, + user=user, + expires_at=datetime.now(timezone.utc) + timedelta(seconds=10), + ) -# url = reverse("token-saml") -# response = authenticated_client.post(f"{url}?id={saml_token.id}") + url = reverse("token-saml") + response = authenticated_client.post(f"{url}?id={saml_token.id}") -# assert response.status_code == status.HTTP_200_OK -# assert response.json() == {"data": valid_token_data} -# assert not SAMLToken.objects.filter(id=saml_token.id).exists() + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"data": valid_token_data} + assert not SAMLToken.objects.filter(id=saml_token.id).exists() -# def test_invalid_token_id_returns_404(self, authenticated_client): -# url = reverse("token-saml") -# response = authenticated_client.post(f"{url}?id={str(uuid4())}") + def test_invalid_token_id_returns_404(self, authenticated_client): + url = reverse("token-saml") + response = authenticated_client.post(f"{url}?id={str(uuid4())}") -# assert response.status_code == status.HTTP_404_NOT_FOUND -# assert response.json()["errors"]["detail"] == "Invalid token ID." + assert response.status_code == status.HTTP_404_NOT_FOUND + assert response.json()["errors"]["detail"] == "Invalid token ID." -# def test_expired_token_returns_400(self, authenticated_client, create_test_user): -# user = create_test_user -# expired_token_data = { -# "access": "expired_access_token", -# "refresh": "expired_refresh_token", -# } -# saml_token = SAMLToken.objects.create( -# token=expired_token_data, -# user=user, -# expires_at=datetime.now(timezone.utc) - timedelta(seconds=1), -# ) + def test_expired_token_returns_400(self, authenticated_client, create_test_user): + user = create_test_user + expired_token_data = { + "access": "expired_access_token", + "refresh": "expired_refresh_token", + } + saml_token = SAMLToken.objects.create( + token=expired_token_data, + user=user, + expires_at=datetime.now(timezone.utc) - timedelta(seconds=1), + ) -# url = reverse("token-saml") -# response = authenticated_client.post(f"{url}?id={saml_token.id}") + url = reverse("token-saml") + response = authenticated_client.post(f"{url}?id={saml_token.id}") -# assert response.status_code == status.HTTP_400_BAD_REQUEST -# assert response.json()["errors"]["detail"] == "Token expired." -# assert SAMLToken.objects.filter(id=saml_token.id).exists() + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["errors"]["detail"] == "Token expired." + assert SAMLToken.objects.filter(id=saml_token.id).exists() -# def test_token_can_be_used_only_once(self, authenticated_client, create_test_user): -# user = create_test_user -# token_data = { -# "access": "single_use_token", -# "refresh": "single_use_refresh", -# } -# saml_token = SAMLToken.objects.create( -# token=token_data, -# user=user, -# expires_at=datetime.now(timezone.utc) + timedelta(seconds=10), -# ) + def test_token_can_be_used_only_once(self, authenticated_client, create_test_user): + user = create_test_user + token_data = { + "access": "single_use_token", + "refresh": "single_use_refresh", + } + saml_token = SAMLToken.objects.create( + token=token_data, + user=user, + expires_at=datetime.now(timezone.utc) + timedelta(seconds=10), + ) -# url = reverse("token-saml") + url = reverse("token-saml") -# # First use: should succeed -# response1 = authenticated_client.post(f"{url}?id={saml_token.id}") -# assert response1.status_code == status.HTTP_200_OK + # First use: should succeed + response1 = authenticated_client.post(f"{url}?id={saml_token.id}") + assert response1.status_code == status.HTTP_200_OK -# # Second use: should fail (already deleted) -# response2 = authenticated_client.post(f"{url}?id={saml_token.id}") -# assert response2.status_code == status.HTTP_404_NOT_FOUND + # Second use: should fail (already deleted) + response2 = authenticated_client.post(f"{url}?id={saml_token.id}") + assert response2.status_code == status.HTTP_404_NOT_FOUND -# @pytest.mark.django_db -# class TestSAMLInitiateAPIView: -# def test_valid_email_domain_and_certificates( -# self, authenticated_client, saml_setup, monkeypatch -# ): -# monkeypatch.setenv("SAML_PUBLIC_CERT", "fake_cert") -# monkeypatch.setenv("SAML_PRIVATE_KEY", "fake_key") +@pytest.mark.django_db +class TestSAMLInitiateAPIView: + def test_valid_email_domain_and_certificates( + self, authenticated_client, saml_setup, monkeypatch + ): + monkeypatch.setenv("SAML_PUBLIC_CERT", "fake_cert") + monkeypatch.setenv("SAML_PRIVATE_KEY", "fake_key") -# url = reverse("api_saml_initiate") -# payload = {"email_domain": saml_setup["email"]} + url = reverse("api_saml_initiate") + payload = {"email_domain": saml_setup["email"]} -# response = authenticated_client.post(url, data=payload, format="json") + response = authenticated_client.post(url, data=payload, format="json") -# assert response.status_code == status.HTTP_302_FOUND -# assert ( -# reverse("saml_login", kwargs={"organization_slug": saml_setup["domain"]}) -# in response.url -# ) -# assert "SAMLRequest" not in response.url + assert response.status_code == status.HTTP_302_FOUND + assert ( + reverse("saml_login", kwargs={"organization_slug": saml_setup["domain"]}) + in response.url + ) + assert "SAMLRequest" not in response.url -# def test_invalid_email_domain(self, authenticated_client): -# url = reverse("api_saml_initiate") -# payload = {"email_domain": "user@unauthorized.com"} + def test_invalid_email_domain(self, authenticated_client): + url = reverse("api_saml_initiate") + payload = {"email_domain": "user@unauthorized.com"} -# response = authenticated_client.post(url, data=payload, format="json") + response = authenticated_client.post(url, data=payload, format="json") -# assert response.status_code == status.HTTP_403_FORBIDDEN -# assert response.json()["errors"]["detail"] == "Unauthorized domain." + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["errors"]["detail"] == "Unauthorized domain." -# @pytest.mark.django_db -# class TestSAMLConfigurationViewSet: -# def test_list_saml_configurations(self, authenticated_client, saml_setup): -# config = SAMLConfiguration.objects.get( -# email_domain=saml_setup["email"].split("@")[-1] -# ) -# response = authenticated_client.get(reverse("saml-config-list")) -# assert response.status_code == status.HTTP_200_OK -# assert ( -# response.json()["data"][0]["attributes"]["email_domain"] -# == config.email_domain -# ) +@pytest.mark.django_db +class TestSAMLConfigurationViewSet: + def test_list_saml_configurations(self, authenticated_client, saml_setup): + config = SAMLConfiguration.objects.get( + email_domain=saml_setup["email"].split("@")[-1] + ) + response = authenticated_client.get(reverse("saml-config-list")) + assert response.status_code == status.HTTP_200_OK + assert ( + response.json()["data"][0]["attributes"]["email_domain"] + == config.email_domain + ) -# def test_retrieve_saml_configuration(self, authenticated_client, saml_setup): -# config = SAMLConfiguration.objects.get( -# email_domain=saml_setup["email"].split("@")[-1] -# ) -# response = authenticated_client.get( -# reverse("saml-config-detail", kwargs={"pk": config.id}) -# ) -# assert response.status_code == status.HTTP_200_OK -# assert ( -# response.json()["data"]["attributes"]["metadata_xml"] == config.metadata_xml -# ) + def test_retrieve_saml_configuration(self, authenticated_client, saml_setup): + config = SAMLConfiguration.objects.get( + email_domain=saml_setup["email"].split("@")[-1] + ) + response = authenticated_client.get( + reverse("saml-config-detail", kwargs={"pk": config.id}) + ) + assert response.status_code == status.HTTP_200_OK + assert ( + response.json()["data"]["attributes"]["metadata_xml"] == config.metadata_xml + ) -# def test_create_saml_configuration(self, authenticated_client, tenants_fixture): -# payload = { -# "email_domain": "newdomain.com", -# "metadata_xml": """ -# -# -# -# -# -# TEST -# -# -# -# urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress -# -# -# -# -# """, -# } -# response = authenticated_client.post( -# reverse("saml-config-list"), data=payload, format="json" -# ) -# assert response.status_code == status.HTTP_201_CREATED -# assert SAMLConfiguration.objects.filter(email_domain="newdomain.com").exists() + def test_create_saml_configuration(self, authenticated_client, tenants_fixture): + payload = { + "email_domain": "newdomain.com", + "metadata_xml": """ + + + + + + TEST + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + + + """, + } + response = authenticated_client.post( + reverse("saml-config-list"), data=payload, format="json" + ) + assert response.status_code == status.HTTP_201_CREATED + assert SAMLConfiguration.objects.filter(email_domain="newdomain.com").exists() -# def test_update_saml_configuration(self, authenticated_client, saml_setup): -# config = SAMLConfiguration.objects.get( -# email_domain=saml_setup["email"].split("@")[-1] -# ) -# payload = { -# "data": { -# "type": "saml-configurations", -# "id": str(config.id), -# "attributes": { -# "metadata_xml": """ -# -# -# -# -# -# TEST2 -# -# -# -# urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress -# -# -# -# -# """ -# }, -# } -# } -# response = authenticated_client.patch( -# reverse("saml-config-detail", kwargs={"pk": config.id}), -# data=payload, -# content_type="application/vnd.api+json", -# ) -# assert response.status_code == status.HTTP_200_OK -# config.refresh_from_db() -# assert ( -# config.metadata_xml.strip() -# == payload["data"]["attributes"]["metadata_xml"].strip() -# ) + def test_update_saml_configuration(self, authenticated_client, saml_setup): + config = SAMLConfiguration.objects.get( + email_domain=saml_setup["email"].split("@")[-1] + ) + payload = { + "data": { + "type": "saml-configurations", + "id": str(config.id), + "attributes": { + "metadata_xml": """ + + + + + + TEST2 + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + + + """ + }, + } + } + response = authenticated_client.patch( + reverse("saml-config-detail", kwargs={"pk": config.id}), + data=payload, + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_200_OK + config.refresh_from_db() + assert ( + config.metadata_xml.strip() + == payload["data"]["attributes"]["metadata_xml"].strip() + ) -# def test_delete_saml_configuration(self, authenticated_client, saml_setup): -# config = SAMLConfiguration.objects.get( -# email_domain=saml_setup["email"].split("@")[-1] -# ) -# response = authenticated_client.delete( -# reverse("saml-config-detail", kwargs={"pk": config.id}) -# ) -# assert response.status_code == status.HTTP_204_NO_CONTENT -# assert not SAMLConfiguration.objects.filter(id=config.id).exists() + def test_delete_saml_configuration(self, authenticated_client, saml_setup): + config = SAMLConfiguration.objects.get( + email_domain=saml_setup["email"].split("@")[-1] + ) + response = authenticated_client.delete( + reverse("saml-config-detail", kwargs={"pk": config.id}) + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not SAMLConfiguration.objects.filter(id=config.id).exists() -# @pytest.mark.django_db -# class TestTenantFinishACSView: -# def test_dispatch_skips_if_user_not_authenticated(self): -# request = RequestFactory().get( -# reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) -# ) -# request.user = type("Anonymous", (), {"is_authenticated": False})() +@pytest.mark.django_db +class TestTenantFinishACSView: + def test_dispatch_skips_if_user_not_authenticated(self, monkeypatch): + monkeypatch.setenv("AUTH_URL", "http://localhost") + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + ) + request.user = type("Anonymous", (), {"is_authenticated": False})() -# with patch( -# "allauth.socialaccount.providers.saml.views.get_app_or_404" -# ) as mock_get_app: -# mock_get_app.return_value = SocialApp( -# provider="saml", -# client_id="testtenant", -# name="Test App", -# settings={}, -# ) + with patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app: + mock_get_app.return_value = SocialApp( + provider="saml", + client_id="testtenant", + name="Test App", + settings={}, + ) -# view = TenantFinishACSView.as_view() -# response = view(request, organization_slug="testtenant") + view = TenantFinishACSView.as_view() + response = view(request, organization_slug="testtenant") -# assert response.status_code in [200, 302] + assert response.status_code in [200, 302] -# def test_dispatch_skips_if_social_app_not_found(self, users_fixture): -# request = RequestFactory().get( -# reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) -# ) -# request.user = users_fixture[0] + def test_dispatch_skips_if_social_app_not_found(self, users_fixture, monkeypatch): + monkeypatch.setenv("AUTH_URL", "http://localhost") + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + ) + request.user = users_fixture[0] -# with patch( -# "allauth.socialaccount.providers.saml.views.get_app_or_404" -# ) as mock_get_app: -# mock_get_app.return_value = SocialApp( -# provider="saml", -# client_id="testtenant", -# name="Test App", -# settings={}, -# ) + with patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app: + mock_get_app.return_value = SocialApp( + provider="saml", + client_id="testtenant", + name="Test App", + settings={}, + ) -# view = TenantFinishACSView.as_view() -# response = view(request, organization_slug="testtenant") + view = TenantFinishACSView.as_view() + response = view(request, organization_slug="testtenant") -# assert isinstance(response, JsonResponse) or response.status_code in [200, 302] + assert isinstance(response, JsonResponse) or response.status_code in [200, 302] -# def test_dispatch_sets_user_profile_and_assigns_role_and_creates_token( -# self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch -# ): -# monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") -# user = create_test_user -# original_email = user.email -# original_name = user.name -# original_company = user.company_name -# user.email = f"doe@{saml_setup['email']}" + def test_dispatch_sets_user_profile_and_assigns_role_and_creates_token( + self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch + ): + monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete") + user = create_test_user + original_name = user.name + original_company = user.company_name + user.company_name = "testing_company" + user.is_authenticate = True -# social_account = SocialAccount( -# user=user, -# provider="saml", -# extra_data={ -# "firstName": ["John"], -# "lastName": ["Doe"], -# "organization": ["TestOrg"], -# "userType": ["saml_default_role"], -# }, -# ) + social_account = SocialAccount( + user=user, + provider="saml", + extra_data={ + "firstName": ["John"], + "lastName": ["Doe"], + "organization": ["testing_company"], + "userType": ["no_permissions"], + }, + ) -# request = RequestFactory().get( -# reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) -# ) -# request.user = user + request = RequestFactory().get( + reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"}) + ) + request.user = user -# with ( -# patch( -# "allauth.socialaccount.providers.saml.views.get_app_or_404" -# ) as mock_get_app_or_404, -# patch("allauth.socialaccount.models.SocialApp.objects.get"), -# patch( -# "allauth.socialaccount.models.SocialAccount.objects.get" -# ) as mock_sa_get, -# ): -# mock_get_app_or_404.return_value = MagicMock( -# provider="saml", client_id="testtenant", name="Test App", settings={} -# ) -# mock_sa_get.return_value = social_account + with ( + patch( + "allauth.socialaccount.providers.saml.views.get_app_or_404" + ) as mock_get_app_or_404, + patch( + "allauth.socialaccount.models.SocialApp.objects.get" + ) as mock_socialapp_get, + patch( + "allauth.socialaccount.models.SocialAccount.objects.get" + ) as mock_sa_get, + patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get, + patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get, + patch("api.models.User.objects.get") as mock_user_get, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", client_id="testtenant", name="Test App", settings={} + ) + mock_sa_get.return_value = social_account + mock_socialapp_get.return_value = MagicMock(provider_id="saml") + mock_saml_domain_get.return_value = SimpleNamespace( + tenant_id=tenants_fixture[0].id + ) + mock_saml_config_get.return_value = MagicMock() + mock_user_get.return_value = user -# view = TenantFinishACSView.as_view() -# response = view(request, organization_slug="testtenant") + view = TenantFinishACSView.as_view() + response = view(request, organization_slug="testtenant") -# assert response.status_code == 302 + assert response.status_code == 302 -# expected_callback_host = "localhost" -# parsed_url = urlparse(response.url) -# assert parsed_url.netloc == expected_callback_host -# query_params = parse_qs(parsed_url.query) -# assert "id" in query_params + expected_callback_host = "localhost" + parsed_url = urlparse(response.url) + assert parsed_url.netloc == expected_callback_host + query_params = parse_qs(parsed_url.query) + assert "id" in query_params -# token_id = query_params["id"][0] -# token_obj = SAMLToken.objects.get(id=token_id) -# assert token_obj.user == user -# assert not token_obj.is_expired() + token_id = query_params["id"][0] + token_obj = SAMLToken.objects.get(id=token_id) + assert token_obj.user == user + assert not token_obj.is_expired() -# user.refresh_from_db() -# assert user.name == "John Doe" -# assert user.company_name == "TestOrg" + user.refresh_from_db() + assert user.name == "John Doe" + assert user.company_name == "testing_company" -# role = Role.objects.using(MainRouter.admin_db).get(name="saml_default_role") -# assert role.tenant == tenants_fixture[0] + role = Role.objects.using(MainRouter.admin_db).get(name="no_permissions") + assert role.tenant == tenants_fixture[0] -# assert ( -# UserRoleRelationship.objects.using(MainRouter.admin_db) -# .filter(user=user, tenant_id=tenants_fixture[0].id) -# .exists() -# ) + assert ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=user, tenant_id=tenants_fixture[0].id) + .exists() + ) -# # Membership should have been created with default role -# membership = Membership.objects.using(MainRouter.admin_db).get( -# user=user, tenant=tenants_fixture[0] -# ) -# assert membership.role == Membership.RoleChoices.MEMBER -# assert membership.user == user -# assert membership.tenant == tenants_fixture[0] + membership = Membership.objects.using(MainRouter.admin_db).get( + user=user, tenant=tenants_fixture[0] + ) + assert membership.role == Membership.RoleChoices.MEMBER + assert membership.user == user + assert membership.tenant == tenants_fixture[0] -# # Restore original user state -# user.email = original_email -# user.name = original_name -# user.company_name = original_company -# user.save() + user.name = original_name + user.company_name = original_company + user.save() @pytest.mark.django_db @@ -6263,3 +6444,186 @@ class TestLighthouseConfigViewSet: reverse("lighthouseconfiguration-connection", kwargs={"pk": "random_id"}) ) assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +class TestProcessorViewSet: + valid_mutelist_configuration = """Mutelist: + Accounts: + '*': + Checks: + iam_user_hardware_mfa_enabled: + Regions: + - '*' + Resources: + - '*' + """ + + def test_list_processors(self, authenticated_client, processor_fixture): + response = authenticated_client.get(reverse("processor-list")) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + + def test_retrieve_processor(self, authenticated_client, processor_fixture): + processor = processor_fixture + response = authenticated_client.get( + reverse("processor-detail", kwargs={"pk": processor.id}) + ) + assert response.status_code == status.HTTP_200_OK + + def test_create_processor_valid(self, authenticated_client): + payload = { + "data": { + "type": "processors", + "attributes": { + "processor_type": "mutelist", + "configuration": self.valid_mutelist_configuration, + }, + }, + } + response = authenticated_client.post( + reverse("processor-list"), + data=payload, + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_201_CREATED + + @pytest.mark.parametrize( + "invalid_configuration", + [ + None, + "", + "invalid configuration", + {"invalid": "configuration"}, + ], + ) + def test_create_processor_invalid( + self, authenticated_client, invalid_configuration + ): + payload = { + "data": { + "type": "processors", + "attributes": { + "processor_type": "mutelist", + "configuration": invalid_configuration, + }, + }, + } + response = authenticated_client.post( + reverse("processor-list"), + data=payload, + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_update_processor_valid(self, authenticated_client, processor_fixture): + processor = processor_fixture + payload = { + "data": { + "type": "processors", + "id": str(processor.id), + "attributes": { + "configuration": { + "Mutelist": { + "Accounts": { + "1234567890": { + "Checks": { + "iam_user_hardware_mfa_enabled": { + "Regions": ["*"], + "Resources": ["*"], + } + } + } + } + } + }, + }, + }, + } + response = authenticated_client.patch( + reverse("processor-detail", kwargs={"pk": processor.id}), + data=payload, + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_200_OK + processor.refresh_from_db() + assert ( + processor.configuration["Mutelist"]["Accounts"]["1234567890"] + == payload["data"]["attributes"]["configuration"]["Mutelist"]["Accounts"][ + "1234567890" + ] + ) + + @pytest.mark.parametrize( + "invalid_configuration", + [ + None, + "", + "invalid configuration", + {"invalid": "configuration"}, + ], + ) + def test_update_processor_invalid( + self, authenticated_client, processor_fixture, invalid_configuration + ): + processor = processor_fixture + payload = { + "data": { + "type": "processors", + "id": str(processor.id), + "attributes": { + "configuration": invalid_configuration, + }, + }, + } + response = authenticated_client.patch( + reverse("processor-detail", kwargs={"pk": processor.id}), + data=payload, + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_delete_processor(self, authenticated_client, processor_fixture): + processor = processor_fixture + response = authenticated_client.delete( + reverse("processor-detail", kwargs={"pk": processor.id}) + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Processor.objects.filter(id=processor.id).exists() + + def test_processors_filters(self, authenticated_client, processor_fixture): + response = authenticated_client.get( + reverse("processor-list"), + {"filter[processor_type]": "mutelist"}, + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 1 + assert response.json()["data"][0]["attributes"]["processor_type"] == "mutelist" + + def test_processors_filters_invalid(self, authenticated_client): + response = authenticated_client.get( + reverse("processor-list"), + {"filter[processor_type]": "invalid"}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_processors_create_another_with_same_type( + self, authenticated_client, processor_fixture + ): + pass + + payload = { + "data": { + "type": "processors", + "attributes": { + "processor_type": "mutelist", + "configuration": self.valid_mutelist_configuration, + }, + }, + } + response = authenticated_client.post( + reverse("processor-list"), + data=payload, + content_type="application/vnd.api+json", + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/api/src/backend/api/utils.py b/api/src/backend/api/utils.py index ce3f4feb52..5a601ef7c9 100644 --- a/api/src/backend/api/utils.py +++ b/api/src/backend/api/utils.py @@ -7,7 +7,7 @@ from rest_framework.exceptions import NotFound, ValidationError from api.db_router import MainRouter from api.exceptions import InvitationTokenExpiredException -from api.models import Invitation, Provider, Resource +from api.models import Invitation, Processor, Provider, Resource from api.v1.serializers import FindingMetadataSerializer from prowler.providers.aws.aws_provider import AwsProvider from prowler.providers.azure.azure_provider import AzureProvider @@ -83,11 +83,14 @@ def return_prowler_provider( return prowler_provider -def get_prowler_provider_kwargs(provider: Provider) -> dict: +def get_prowler_provider_kwargs( + provider: Provider, mutelist_processor: Processor | None = None +) -> dict: """Get the Prowler provider kwargs based on the given provider type. Args: provider (Provider): The provider object containing the provider type and associated secret. + mutelist_processor (Processor): The mutelist processor object containing the mutelist configuration. Returns: dict: The provider kwargs for the corresponding provider class. @@ -105,16 +108,24 @@ def get_prowler_provider_kwargs(provider: Provider) -> dict: } elif provider.provider == Provider.ProviderChoices.KUBERNETES.value: prowler_provider_kwargs = {**prowler_provider_kwargs, "context": provider.uid} + + if mutelist_processor: + mutelist_content = mutelist_processor.configuration.get("Mutelist", {}) + if mutelist_content: + prowler_provider_kwargs["mutelist_content"] = mutelist_content + return prowler_provider_kwargs def initialize_prowler_provider( provider: Provider, + mutelist_processor: Processor | None = None, ) -> AwsProvider | AzureProvider | GcpProvider | KubernetesProvider | M365Provider: """Initialize a Prowler provider instance based on the given provider type. Args: provider (Provider): The provider object containing the provider type and associated secrets. + mutelist_processor (Processor): The mutelist processor object containing the mutelist configuration. Returns: AwsProvider | AzureProvider | GcpProvider | KubernetesProvider | M365Provider: An instance of the corresponding provider class @@ -122,7 +133,7 @@ def initialize_prowler_provider( provider's secrets. """ prowler_provider = return_prowler_provider(provider) - prowler_provider_kwargs = get_prowler_provider_kwargs(provider) + prowler_provider_kwargs = get_prowler_provider_kwargs(provider, mutelist_processor) return prowler_provider(**prowler_provider_kwargs) @@ -187,7 +198,7 @@ def validate_invitation( # Admin DB connector is used to bypass RLS protection since the invitation belongs to a tenant the user # is not a member of yet invitation = Invitation.objects.using(MainRouter.admin_db).get( - token=invitation_token, email=email + token=invitation_token, email__iexact=email ) except Invitation.DoesNotExist: if raise_not_found: diff --git a/api/src/backend/api/v1/serializer_utils/base.py b/api/src/backend/api/v1/serializer_utils/base.py new file mode 100644 index 0000000000..2e51ebc55e --- /dev/null +++ b/api/src/backend/api/v1/serializer_utils/base.py @@ -0,0 +1,23 @@ +import yaml +from rest_framework_json_api import serializers +from rest_framework_json_api.serializers import ValidationError + + +class BaseValidateSerializer(serializers.Serializer): + def validate(self, data): + if hasattr(self, "initial_data"): + initial_data = set(self.initial_data.keys()) - {"id", "type"} + unknown_keys = initial_data - set(self.fields.keys()) + if unknown_keys: + raise ValidationError(f"Invalid fields: {unknown_keys}") + return data + + +class YamlOrJsonField(serializers.JSONField): + def to_internal_value(self, data): + if isinstance(data, str): + try: + data = yaml.safe_load(data) + except yaml.YAMLError as exc: + raise serializers.ValidationError("Invalid YAML format") from exc + return super().to_internal_value(data) diff --git a/api/src/backend/api/v1/serializer_utils/integrations.py b/api/src/backend/api/v1/serializer_utils/integrations.py index ec57d14874..a42ca8dcfe 100644 --- a/api/src/backend/api/v1/serializer_utils/integrations.py +++ b/api/src/backend/api/v1/serializer_utils/integrations.py @@ -1,19 +1,7 @@ from drf_spectacular.utils import extend_schema_field from rest_framework_json_api import serializers -from rest_framework_json_api.serializers import ValidationError - -class BaseValidateSerializer(serializers.Serializer): - def validate(self, data): - if hasattr(self, "initial_data"): - initial_data = set(self.initial_data.keys()) - {"id", "type"} - unknown_keys = initial_data - set(self.fields.keys()) - if unknown_keys: - raise ValidationError(f"Invalid fields: {unknown_keys}") - return data - - -# Integrations +from api.v1.serializer_utils.base import BaseValidateSerializer class S3ConfigSerializer(BaseValidateSerializer): diff --git a/api/src/backend/api/v1/serializer_utils/processors.py b/api/src/backend/api/v1/serializer_utils/processors.py new file mode 100644 index 0000000000..4022f3f2bc --- /dev/null +++ b/api/src/backend/api/v1/serializer_utils/processors.py @@ -0,0 +1,21 @@ +from drf_spectacular.utils import extend_schema_field + +from api.v1.serializer_utils.base import YamlOrJsonField + +from prowler.lib.mutelist.mutelist import mutelist_schema + + +@extend_schema_field( + { + "oneOf": [ + { + "type": "object", + "title": "Mutelist", + "properties": {"Mutelist": mutelist_schema}, + "additionalProperties": False, + }, + ] + } +) +class ProcessorConfigField(YamlOrJsonField): + pass diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index 858a9c0456..2c9b044d51 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -7,6 +7,7 @@ from django.contrib.auth.models import update_last_login from django.contrib.auth.password_validation import validate_password 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.serializers import ValidationError from rest_framework_simplejwt.exceptions import TokenError @@ -21,6 +22,7 @@ from api.models import ( InvitationRoleRelationship, LighthouseConfiguration, Membership, + Processor, Provider, ProviderGroup, ProviderGroupMembership, @@ -29,6 +31,7 @@ from api.models import ( ResourceTag, Role, RoleProviderGroupRelationship, + SAMLConfiguration, Scan, StateChoices, StatusChoices, @@ -43,7 +46,9 @@ from api.v1.serializer_utils.integrations import ( IntegrationCredentialField, S3ConfigSerializer, ) +from api.v1.serializer_utils.processors import ProcessorConfigField from api.v1.serializer_utils.providers import ProviderSecretField +from prowler.lib.mutelist.mutelist import Mutelist # Tokens @@ -129,6 +134,12 @@ class TokenSerializer(BaseTokenSerializer): class TokenSocialLoginSerializer(BaseTokenSerializer): email = serializers.EmailField(write_only=True) + tenant_id = serializers.UUIDField( + write_only=True, + required=False, + help_text="If not provided, the tenant ID of the first membership that was added" + " to the user will be used.", + ) # Output tokens refresh = serializers.CharField(read_only=True) @@ -850,6 +861,7 @@ class ScanSerializer(RLSSerializer): "completed_at", "scheduled_at", "next_scan_at", + "processor", "url", ] @@ -1013,8 +1025,8 @@ class ResourceSerializer(RLSSerializer): } included_serializers = { - "findings": "api.v1.serializers.FindingSerializer", - "provider": "api.v1.serializers.ProviderSerializer", + "findings": "api.v1.serializers.FindingIncludeSerializer", + "provider": "api.v1.serializers.ProviderIncludeSerializer", } @extend_schema_field( @@ -1037,7 +1049,7 @@ class ResourceSerializer(RLSSerializer): class ResourceIncludeSerializer(RLSSerializer): """ - Serializer for the Resource model. + Serializer for the included Resource model. """ tags = serializers.SerializerMethodField() @@ -1103,6 +1115,7 @@ class FindingSerializer(RLSSerializer): "updated_at", "first_seen_at", "muted", + "muted_reason", "url", # Relationships "scan", @@ -1115,6 +1128,28 @@ class FindingSerializer(RLSSerializer): } +class FindingIncludeSerializer(RLSSerializer): + """ + Serializer for the include Finding model. + """ + + class Meta: + model = Finding + fields = [ + "id", + "uid", + "status", + "severity", + "check_id", + "check_metadata", + "inserted_at", + "updated_at", + "first_seen_at", + "muted", + "muted_reason", + ] + + # To be removed when the related endpoint is removed as well class FindingDynamicFilterSerializer(serializers.Serializer): services = serializers.ListField(child=serializers.CharField(), allow_empty=True) @@ -1308,12 +1343,13 @@ class ProviderSecretUpdateSerializer(BaseWriteProviderSecretSerializer): "inserted_at": {"read_only": True}, "updated_at": {"read_only": True}, "provider": {"read_only": True}, - "secret_type": {"read_only": True}, + "secret_type": {"required": False}, } def validate(self, attrs): provider = self.instance.provider - secret_type = self.instance.secret_type + # To allow updating a secret with the same type without making the `secret_type` mandatory + secret_type = attrs.get("secret_type") or self.instance.secret_type secret = attrs.get("secret") validated_attrs = super().validate(attrs) @@ -2064,26 +2100,148 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer): return super().update(instance, validated_data) +# Processors + + +class ProcessorSerializer(RLSSerializer): + """ + Serializer for the Processor model. + """ + + configuration = ProcessorConfigField() + + class Meta: + model = Processor + fields = [ + "id", + "inserted_at", + "updated_at", + "processor_type", + "configuration", + "url", + ] + + +class ProcessorCreateSerializer(RLSSerializer, BaseWriteSerializer): + configuration = ProcessorConfigField(required=True) + + class Meta: + model = Processor + fields = [ + "inserted_at", + "updated_at", + "processor_type", + "configuration", + ] + extra_kwargs = { + "inserted_at": {"read_only": True}, + "updated_at": {"read_only": True}, + } + validators = [ + UniqueTogetherValidator( + queryset=Processor.objects.all(), + fields=["processor_type"], + message="A processor with the same type already exists.", + ) + ] + + def validate(self, attrs): + validated_attrs = super().validate(attrs) + self.validate_processor_data(attrs) + return validated_attrs + + def validate_processor_data(self, attrs): + processor_type = attrs.get("processor_type") + configuration = attrs.get("configuration") + if processor_type == "mutelist": + self.validate_mutelist_configuration(configuration) + + def validate_mutelist_configuration(self, configuration): + if not isinstance(configuration, dict): + raise serializers.ValidationError("Invalid Mutelist configuration.") + + mutelist_configuration = configuration.get("Mutelist", {}) + + if not mutelist_configuration: + raise serializers.ValidationError( + "Invalid Mutelist configuration: 'Mutelist' is a required property." + ) + + try: + Mutelist.validate_mutelist(mutelist_configuration, raise_on_exception=True) + return + except Exception as error: + raise serializers.ValidationError( + f"Invalid Mutelist configuration: {error}" + ) + + +class ProcessorUpdateSerializer(BaseWriteSerializer): + configuration = ProcessorConfigField(required=True) + + class Meta: + model = Processor + fields = [ + "inserted_at", + "updated_at", + "configuration", + ] + extra_kwargs = { + "inserted_at": {"read_only": True}, + "updated_at": {"read_only": True}, + } + + def validate(self, attrs): + validated_attrs = super().validate(attrs) + self.validate_processor_data(attrs) + return validated_attrs + + def validate_processor_data(self, attrs): + processor_type = self.instance.processor_type + configuration = attrs.get("configuration") + if processor_type == "mutelist": + self.validate_mutelist_configuration(configuration) + + def validate_mutelist_configuration(self, configuration): + if not isinstance(configuration, dict): + raise serializers.ValidationError("Invalid Mutelist configuration.") + + mutelist_configuration = configuration.get("Mutelist", {}) + + if not mutelist_configuration: + raise serializers.ValidationError( + "Invalid Mutelist configuration: 'Mutelist' is a required property." + ) + + try: + Mutelist.validate_mutelist(mutelist_configuration, raise_on_exception=True) + return + except Exception as error: + raise serializers.ValidationError( + f"Invalid Mutelist configuration: {error}" + ) + + # SSO -# class SamlInitiateSerializer(serializers.Serializer): -# email_domain = serializers.CharField() +class SamlInitiateSerializer(serializers.Serializer): + email_domain = serializers.CharField() -# class JSONAPIMeta: -# resource_name = "saml-initiate" + class JSONAPIMeta: + resource_name = "saml-initiate" -# class SamlMetadataSerializer(serializers.Serializer): -# class JSONAPIMeta: -# resource_name = "saml-meta" +class SamlMetadataSerializer(serializers.Serializer): + class JSONAPIMeta: + resource_name = "saml-meta" -# class SAMLConfigurationSerializer(RLSSerializer): -# class Meta: -# model = SAMLConfiguration -# fields = ["id", "email_domain", "metadata_xml", "created_at", "updated_at"] -# read_only_fields = ["id", "created_at", "updated_at"] +class SAMLConfigurationSerializer(RLSSerializer): + class Meta: + model = SAMLConfiguration + fields = ["id", "email_domain", "metadata_xml", "created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] class LighthouseConfigSerializer(RLSSerializer): diff --git a/api/src/backend/api/v1/urls.py b/api/src/backend/api/v1/urls.py index 8c07b10470..8ee4458fc1 100644 --- a/api/src/backend/api/v1/urls.py +++ b/api/src/backend/api/v1/urls.py @@ -1,9 +1,11 @@ +from allauth.socialaccount.providers.saml.views import ACSView, MetadataView, SLSView from django.urls import include, path from drf_spectacular.views import SpectacularRedocView from rest_framework_nested import routers from api.v1.views import ( ComplianceOverviewViewSet, + CustomSAMLLoginView, CustomTokenObtainView, CustomTokenRefreshView, CustomTokenSwitchTenantView, @@ -16,6 +18,7 @@ from api.v1.views import ( LighthouseConfigViewSet, MembershipViewSet, OverviewViewSet, + ProcessorViewSet, ProviderGroupProvidersRelationshipView, ProviderGroupViewSet, ProviderSecretViewSet, @@ -23,10 +26,14 @@ from api.v1.views import ( ResourceViewSet, RoleProviderGroupRelationshipView, RoleViewSet, + SAMLConfigurationViewSet, + SAMLInitiateAPIView, + SAMLTokenValidateView, ScanViewSet, ScheduleViewSet, SchemaView, TaskViewSet, + TenantFinishACSView, TenantMembersViewSet, TenantViewSet, UserRoleRelationshipView, @@ -50,7 +57,8 @@ router.register( router.register(r"overviews", OverviewViewSet, basename="overview") router.register(r"schedules", ScheduleViewSet, basename="schedule") router.register(r"integrations", IntegrationViewSet, basename="integration") -# router.register(r"saml-config", SAMLConfigurationViewSet, basename="saml-config") +router.register(r"processors", ProcessorViewSet, basename="processor") +router.register(r"saml-config", SAMLConfigurationViewSet, basename="saml-config") router.register( r"lighthouse-configurations", LighthouseConfigViewSet, @@ -119,24 +127,36 @@ urlpatterns = [ ), name="provider_group-providers-relationship", ), - # API endpoint to start SAML SSO flow (WIP) - # path( - # "auth/saml/initiate/", SAMLInitiateAPIView.as_view(), name="api_saml_initiate" - # ), - # # Custom SAML endpoints (must come before allauth.urls) (WIP) - # path( - # "accounts/saml//login/", - # CustomSAMLLoginView.as_view(), - # name="saml_login", - # ), - # path( - # "accounts/saml//acs/finish/", - # TenantFinishACSView.as_view(), - # name="saml_finish_acs", - # ), - # Allauth SAML endpoints for tenants (WIP) - # path("accounts/", include("allauth.urls")), - # path("tokens/saml", SAMLTokenValidateView.as_view(), name="token-saml"), + # API endpoint to start SAML SSO flow + path( + "auth/saml/initiate/", SAMLInitiateAPIView.as_view(), name="api_saml_initiate" + ), + path( + "accounts/saml//login/", + CustomSAMLLoginView.as_view(), + name="saml_login", + ), + path( + "accounts/saml//acs/", + ACSView.as_view(), + name="saml_acs", + ), + path( + "accounts/saml//acs/finish/", + TenantFinishACSView.as_view(), + name="saml_finish_acs", + ), + path( + "accounts/saml//sls/", + SLSView.as_view(), + name="saml_sls", + ), + path( + "accounts/saml//metadata/", + MetadataView.as_view(), + name="saml_metadata", + ), + path("tokens/saml", SAMLTokenValidateView.as_view(), name="token-saml"), path("tokens/google", GoogleSocialLoginView.as_view(), name="token-google"), path("tokens/github", GithubSocialLoginView.as_view(), name="token-github"), path("", include(router.urls)), diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 6fe771ebfc..4f7075ff78 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -1,10 +1,13 @@ import glob import os from datetime import datetime, timedelta, timezone +from urllib.parse import urljoin import sentry_sdk +from allauth.socialaccount.models import SocialAccount, SocialApp from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter +from allauth.socialaccount.providers.saml.views import FinishACSView, LoginView from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError from celery.result import AsyncResult from config.env import env @@ -20,6 +23,7 @@ from django.db import transaction from django.db.models import Count, Exists, F, OuterRef, Prefetch, Q, Sum from django.db.models.functions import Coalesce from django.http import HttpResponse +from django.shortcuts import redirect from django.urls import reverse from django.utils.dateparse import parse_date from django.utils.decorators import method_decorator @@ -64,6 +68,7 @@ from api.compliance import ( get_compliance_frameworks, ) from api.db_router import MainRouter +from api.db_utils import rls_transaction from api.exceptions import TaskFailedException from api.filters import ( ComplianceOverviewFilter, @@ -72,6 +77,7 @@ from api.filters import ( InvitationFilter, LatestFindingFilter, MembershipFilter, + ProcessorFilter, ProviderFilter, ProviderGroupFilter, ProviderSecretFilter, @@ -92,6 +98,7 @@ from api.models import ( Invitation, LighthouseConfiguration, Membership, + Processor, Provider, ProviderGroup, ProviderGroupMembership, @@ -101,6 +108,9 @@ from api.models import ( ResourceScanSummary, Role, RoleProviderGroupRelationship, + SAMLConfiguration, + SAMLDomainIndex, + SAMLToken, Scan, ScanSummary, SeverityChoices, @@ -142,6 +152,9 @@ from api.v1.serializers import ( OverviewProviderSerializer, OverviewServiceSerializer, OverviewSeveritySerializer, + ProcessorCreateSerializer, + ProcessorSerializer, + ProcessorUpdateSerializer, ProviderCreateSerializer, ProviderGroupCreateSerializer, ProviderGroupMembershipSerializer, @@ -157,6 +170,8 @@ from api.v1.serializers import ( RoleProviderGroupRelationshipSerializer, RoleSerializer, RoleUpdateSerializer, + SAMLConfigurationSerializer, + SamlInitiateSerializer, ScanComplianceReportSerializer, ScanCreateSerializer, ScanReportSerializer, @@ -337,6 +352,11 @@ class SchemaView(SpectacularAPIView): "description": "Endpoints for managing Lighthouse configurations, including creation, retrieval, " "updating, and deletion of configurations such as OpenAI keys, models, and business context.", }, + { + "name": "Processor", + "description": "Endpoints for managing post-processors used to process Prowler findings, including " + "registration, configuration, and deletion of post-processing actions.", + }, ] return super().get(request, *args, **kwargs) @@ -393,240 +413,254 @@ class GithubSocialLoginView(SocialLoginView): return original_response -# @extend_schema(exclude=True) -# class SAMLTokenValidateView(GenericAPIView): -# resource_name = "tokens" -# http_method_names = ["post"] +@extend_schema(exclude=True) +class SAMLTokenValidateView(GenericAPIView): + resource_name = "tokens" + http_method_names = ["post"] -# def post(self, request): -# token_id = request.query_params.get("id", "invalid") -# try: -# saml_token = SAMLToken.objects.using(MainRouter.admin_db).get(id=token_id) -# except SAMLToken.DoesNotExist: -# return Response({"detail": "Invalid token ID."}, status=404) + def post(self, request): + token_id = request.query_params.get("id", "invalid") + try: + saml_token = SAMLToken.objects.using(MainRouter.admin_db).get(id=token_id) + except SAMLToken.DoesNotExist: + return Response({"detail": "Invalid token ID."}, status=404) -# if saml_token.is_expired(): -# return Response({"detail": "Token expired."}, status=400) + if saml_token.is_expired(): + return Response({"detail": "Token expired."}, status=400) -# token_data = saml_token.token -# # Currently we don't store the tokens in the database, so we delete the token after use -# saml_token.delete() + token_data = saml_token.token + # Currently we don't store the tokens in the database, so we delete the token after use + saml_token.delete() -# return Response(token_data, status=200) + return Response(token_data, status=200) -# @extend_schema(exclude=True) -# class CustomSAMLLoginView(LoginView): -# def dispatch(self, request, *args, **kwargs): -# """ -# Convert GET requests to POST to bypass allauth's confirmation screen. +@extend_schema(exclude=True) +class CustomSAMLLoginView(LoginView): + def dispatch(self, request, *args, **kwargs): + """ + Convert GET requests to POST to bypass allauth's confirmation screen. -# Why this is necessary: -# - django-allauth requires POST for social logins to prevent open redirect attacks -# - SAML login links typically use GET requests (e.g., ) -# - This conversion allows seamless login without user-facing confirmation + Why this is necessary: + - django-allauth requires POST for social logins to prevent open redirect attacks + - SAML login links typically use GET requests (e.g., ) + - This conversion allows seamless login without user-facing confirmation -# Security considerations: -# 1. Preserves CSRF protection: Original POST handling remains intact -# 2. Avoids global SOCIALACCOUNT_LOGIN_ON_GET=True which would: -# - Enable GET logins for ALL providers (security risk) -# - Potentially expose open redirect vulnerabilities -# 3. SAML payloads remain signed/encrypted regardless of HTTP method -# 4. No sensitive parameters are exposed in URLs (copied to POST body) + Security considerations: + 1. Preserves CSRF protection: Original POST handling remains intact + 2. Avoids global SOCIALACCOUNT_LOGIN_ON_GET=True which would: + - Enable GET logins for ALL providers (security risk) + - Potentially expose open redirect vulnerabilities + 3. SAML payloads remain signed/encrypted regardless of HTTP method + 4. No sensitive parameters are exposed in URLs (copied to POST body) -# This approach maintains security while providing better UX. -# """ -# if request.method == "GET": -# # Convert GET to POST while preserving parameters -# request.method = "POST" -# # Safe because SAML validates signatures -# request.POST = request.GET.copy() -# return super().dispatch(request, *args, **kwargs) + This approach maintains security while providing better UX. + """ + if request.method == "GET": + # Convert GET to POST while preserving parameters + request.method = "POST" + return super().dispatch(request, *args, **kwargs) -# @extend_schema(exclude=True) -# class SAMLInitiateAPIView(GenericAPIView): -# serializer_class = SamlInitiateSerializer -# permission_classes = [] +@extend_schema(exclude=True) +class SAMLInitiateAPIView(GenericAPIView): + serializer_class = SamlInitiateSerializer + permission_classes = [] -# def post(self, request, *args, **kwargs): -# # Validate the input payload and extract the domain -# serializer = self.get_serializer(data=request.data) -# serializer.is_valid(raise_exception=True) -# email = serializer.validated_data["email_domain"] -# domain = email.split("@", 1)[-1].lower() + def post(self, request, *args, **kwargs): + # Validate the input payload and extract the domain + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + email = serializer.validated_data["email_domain"] + domain = email.split("@", 1)[-1].lower() -# # Retrieve the SAML configuration for the given email domain -# try: -# check = SAMLDomainIndex.objects.get(email_domain=domain) -# with rls_transaction(str(check.tenant_id)): -# config = SAMLConfiguration.objects.get(tenant_id=str(check.tenant_id)) -# except (SAMLDomainIndex.DoesNotExist, SAMLConfiguration.DoesNotExist): -# return Response( -# {"detail": "Unauthorized domain."}, status=status.HTTP_403_FORBIDDEN -# ) + # Retrieve the SAML configuration for the given email domain + try: + check = SAMLDomainIndex.objects.get(email_domain=domain) + with rls_transaction(str(check.tenant_id)): + config = SAMLConfiguration.objects.get(tenant_id=str(check.tenant_id)) + except (SAMLDomainIndex.DoesNotExist, SAMLConfiguration.DoesNotExist): + return Response( + {"detail": "Unauthorized domain."}, status=status.HTTP_403_FORBIDDEN + ) -# # Check certificates are not empty (TODO: Validate certificates) -# # saml_public_cert = os.getenv("SAML_PUBLIC_CERT", "").strip() -# # saml_private_key = os.getenv("SAML_PRIVATE_KEY", "").strip() + # Check certificates are not empty (TODO: Validate certificates) + # saml_public_cert = os.getenv("SAML_PUBLIC_CERT", "").strip() + # saml_private_key = os.getenv("SAML_PRIVATE_KEY", "").strip() -# # if not saml_public_cert or not saml_private_key: -# # return Response( -# # {"detail": "SAML configuration is invalid: missing certificates."}, -# # status=status.HTTP_403_FORBIDDEN, -# # ) + # if not saml_public_cert or not saml_private_key: + # return Response( + # {"detail": "SAML configuration is invalid: missing certificates."}, + # status=status.HTTP_403_FORBIDDEN, + # ) -# # Build the SAML login URL using the configured API host -# api_host = os.getenv("API_BASE_URL") -# login_path = reverse( -# "saml_login", kwargs={"organization_slug": config.email_domain} -# ) -# login_url = urljoin(api_host, login_path) + # Build the SAML login URL using the configured API host + api_host = os.getenv("API_BASE_URL") + login_path = reverse( + "saml_login", kwargs={"organization_slug": config.email_domain} + ) + login_url = urljoin(api_host, login_path) -# return redirect(login_url) + return redirect(login_url) -# @extend_schema_view( -# list=extend_schema( -# tags=["SAML"], -# summary="List all SSO configurations", -# description="Returns all the SAML-based SSO configurations associated with the current tenant.", -# ), -# retrieve=extend_schema( -# tags=["SAML"], -# summary="Retrieve SSO configuration details", -# description="Returns the details of a specific SAML configuration belonging to the current tenant.", -# ), -# create=extend_schema( -# tags=["SAML"], -# summary="Create the SSO configuration", -# description="Creates a new SAML SSO configuration for the current tenant, including email domain and metadata XML.", -# ), -# partial_update=extend_schema( -# tags=["SAML"], -# summary="Update the SSO configuration", -# description="Partially updates an existing SAML SSO configuration. Supports changes to email domain and metadata XML.", -# ), -# destroy=extend_schema( -# tags=["SAML"], -# summary="Delete the SSO configuration", -# description="Deletes an existing SAML SSO configuration associated with the current tenant.", -# ), -# ) -# @method_decorator(CACHE_DECORATOR, name="retrieve") -# @method_decorator(CACHE_DECORATOR, name="list") -# class SAMLConfigurationViewSet(BaseRLSViewSet): -# """ -# ViewSet for managing SAML SSO configurations per tenant. +@extend_schema_view( + list=extend_schema( + tags=["SAML"], + summary="List all SSO configurations", + description="Returns all the SAML-based SSO configurations associated with the current tenant.", + ), + retrieve=extend_schema( + tags=["SAML"], + summary="Retrieve SSO configuration details", + description="Returns the details of a specific SAML configuration belonging to the current tenant.", + ), + create=extend_schema( + tags=["SAML"], + summary="Create the SSO configuration", + description="Creates a new SAML SSO configuration for the current tenant, including email domain and metadata XML.", + ), + partial_update=extend_schema( + tags=["SAML"], + summary="Update the SSO configuration", + description="Partially updates an existing SAML SSO configuration. Supports changes to email domain and metadata XML.", + ), + destroy=extend_schema( + tags=["SAML"], + summary="Delete the SSO configuration", + description="Deletes an existing SAML SSO configuration associated with the current tenant.", + ), +) +@method_decorator(CACHE_DECORATOR, name="retrieve") +@method_decorator(CACHE_DECORATOR, name="list") +class SAMLConfigurationViewSet(BaseRLSViewSet): + """ + ViewSet for managing SAML SSO configurations per tenant. -# This endpoint allows authorized users to perform CRUD operations on SAMLConfiguration, -# which define how a tenant integrates with an external SAML Identity Provider (IdP). + This endpoint allows authorized users to perform CRUD operations on SAMLConfiguration, + which define how a tenant integrates with an external SAML Identity Provider (IdP). -# Typical use cases include: -# - Listing all existing configurations for auditing or UI display. -# - Retrieving a single configuration to show setup details. -# - Creating or updating a configuration to onboard or modify SAML integration. -# - Deleting a configuration when deactivating SAML for a tenant. -# """ + Typical use cases include: + - Listing all existing configurations for auditing or UI display. + - Retrieving a single configuration to show setup details. + - Creating or updating a configuration to onboard or modify SAML integration. + - Deleting a configuration when deactivating SAML for a tenant. + """ -# serializer_class = SAMLConfigurationSerializer -# required_permissions = [Permissions.MANAGE_INTEGRATIONS] -# queryset = SAMLConfiguration.objects.all() + serializer_class = SAMLConfigurationSerializer + required_permissions = [Permissions.MANAGE_INTEGRATIONS] + queryset = SAMLConfiguration.objects.all() -# def get_queryset(self): -# # If called during schema generation, return an empty queryset -# if getattr(self, "swagger_fake_view", False): -# return SAMLConfiguration.objects.none() -# return SAMLConfiguration.objects.filter(tenant=self.request.tenant_id) + def get_queryset(self): + # If called during schema generation, return an empty queryset + if getattr(self, "swagger_fake_view", False): + return SAMLConfiguration.objects.none() + return SAMLConfiguration.objects.filter(tenant=self.request.tenant_id) -# class TenantFinishACSView(FinishACSView): -# def dispatch(self, request, organization_slug): -# response = super().dispatch(request, organization_slug) -# user = getattr(request, "user", None) -# if not user or not user.is_authenticated: -# return response +class TenantFinishACSView(FinishACSView): + def dispatch(self, request, organization_slug): + super().dispatch(request, organization_slug) + user = getattr(request, "user", None) + if not user or not user.is_authenticated: + callback_url = env.str("AUTH_URL") + return redirect(f"{callback_url}?sso_saml_failed=true") -# try: -# social_app = SocialApp.objects.get( -# provider="saml", client_id=organization_slug -# ) -# social_account = SocialAccount.objects.get( -# user=user, provider=social_app.provider -# ) -# except (SocialApp.DoesNotExist, SocialAccount.DoesNotExist): -# return response + # Defensive check to avoid edge case failures due to inconsistent or incomplete data in the database + # This handles scenarios like partially deleted or missing related objects + try: + check = SAMLDomainIndex.objects.get(email_domain=organization_slug) + with rls_transaction(str(check.tenant_id)): + SAMLConfiguration.objects.get(tenant_id=str(check.tenant_id)) + social_app = SocialApp.objects.get( + provider="saml", client_id=organization_slug + ) + user_id = User.objects.get(email=str(user)).id + social_account = SocialAccount.objects.get( + user=str(user_id), provider=social_app.provider_id + ) + except ( + SAMLDomainIndex.DoesNotExist, + SAMLConfiguration.DoesNotExist, + SocialApp.DoesNotExist, + SocialAccount.DoesNotExist, + User.DoesNotExist, + ): + callback_url = env.str("AUTH_URL") + return redirect(f"{callback_url}?sso_saml_failed=true") -# extra = social_account.extra_data -# user.first_name = ( -# extra.get("firstName", [""])[0] if extra.get("firstName") else "" -# ) -# user.last_name = extra.get("lastName", [""])[0] if extra.get("lastName") else "" -# user.company_name = ( -# extra.get("organization", [""])[0] if extra.get("organization") else "" -# ) -# user.name = f"{user.first_name} {user.last_name}".strip() -# if user.name == "": -# user.name = "N/A" -# user.save() + extra = social_account.extra_data + user.first_name = ( + extra.get("firstName", [""])[0] if extra.get("firstName") else "" + ) + user.last_name = extra.get("lastName", [""])[0] if extra.get("lastName") else "" + user.company_name = ( + extra.get("organization", [""])[0] if extra.get("organization") else "" + ) + user.name = f"{user.first_name} {user.last_name}".strip() + if user.name == "": + user.name = "N/A" + user.save() -# email_domain = user.email.split("@")[-1] -# tenant = ( -# SAMLConfiguration.objects.using(MainRouter.admin_db) -# .get(email_domain=email_domain) -# .tenant -# ) -# role_name = ( -# extra.get("userType", ["saml_default_role"])[0].strip() -# if extra.get("userType") -# else "saml_default_role" -# ) -# try: -# role = Role.objects.using(MainRouter.admin_db).get( -# name=role_name, tenant=tenant -# ) -# except Role.DoesNotExist: -# role = Role.objects.using(MainRouter.admin_db).create( -# name=role_name, -# tenant=tenant, -# manage_users=False, -# manage_account=False, -# manage_billing=False, -# manage_providers=False, -# manage_integrations=False, -# manage_scans=False, -# unlimited_visibility=False, -# ) -# UserRoleRelationship.objects.using(MainRouter.admin_db).filter( -# user=user, -# tenant_id=tenant.id, -# ).delete() -# UserRoleRelationship.objects.using(MainRouter.admin_db).create( -# user=user, -# role=role, -# tenant_id=tenant.id, -# ) -# membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create( -# user=user, -# tenant=tenant, -# defaults={ -# "user": user, -# "tenant": tenant, -# "role": Membership.RoleChoices.MEMBER, -# }, -# ) + email_domain = user.email.split("@")[-1] + tenant = ( + SAMLConfiguration.objects.using(MainRouter.admin_db) + .get(email_domain=email_domain) + .tenant + ) + role_name = ( + extra.get("userType", ["no_permissions"])[0].strip() + if extra.get("userType") + else "no_permissions" + ) + try: + role = Role.objects.using(MainRouter.admin_db).get( + name=role_name, tenant=tenant + ) + except Role.DoesNotExist: + role = Role.objects.using(MainRouter.admin_db).create( + name=role_name, + tenant=tenant, + manage_users=False, + manage_account=False, + manage_billing=False, + manage_providers=False, + manage_integrations=False, + manage_scans=False, + unlimited_visibility=False, + ) + UserRoleRelationship.objects.using(MainRouter.admin_db).filter( + user=user, + tenant_id=tenant.id, + ).delete() + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=user, + role=role, + tenant_id=tenant.id, + ) + membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create( + user=user, + tenant=tenant, + defaults={ + "user": user, + "tenant": tenant, + "role": Membership.RoleChoices.MEMBER, + }, + ) -# serializer = TokenSocialLoginSerializer(data={"email": user.email}) -# serializer.is_valid(raise_exception=True) + serializer = TokenSocialLoginSerializer( + data={"email": user.email, "tenant_id": str(tenant.id)} + ) + serializer.is_valid(raise_exception=True) -# token_data = serializer.validated_data -# saml_token = SAMLToken.objects.using(MainRouter.admin_db).create( -# token=token_data, user=user -# ) -# callback_url = env.str("SAML_SSO_CALLBACK_URL") -# redirect_url = f"{callback_url}?id={saml_token.id}" + token_data = serializer.validated_data + saml_token = SAMLToken.objects.using(MainRouter.admin_db).create( + token=token_data, user=user + ) + callback_url = env.str("SAML_SSO_CALLBACK_URL") + redirect_url = f"{callback_url}?id={saml_token.id}" -# return redirect(redirect_url) + return redirect(redirect_url) @extend_schema_view( @@ -2127,9 +2161,12 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): # ToRemove: Temporary fallback mechanism if not queryset.exists(): - scan_ids = Scan.objects.filter( + raw_scans_ids = Scan.objects.filter( tenant_id=tenant_id, **scan_based_filters - ).values_list("id", flat=True) + ).values_list("id", "unique_resource_count") + scan_ids = [ + scan_id for scan_id, count in raw_scans_ids if count and count > 0 + ] for scan_id in scan_ids: backfill_scan_resource_summaries_task.apply_async( kwargs={"tenant_id": tenant_id, "scan_id": scan_id} @@ -2215,7 +2252,12 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): .order_by("provider_id", "-inserted_at") .distinct("provider_id") ) - latest_scans_ids = list(latest_scans_queryset.values_list("id", flat=True)) + raw_latest_scans_ids = list( + latest_scans_queryset.values_list("id", "unique_resource_count") + ) + latest_scans_ids = [ + scan_id for scan_id, count in raw_latest_scans_ids if count and count > 0 + ] queryset = ResourceScanSummary.objects.filter( tenant_id=tenant_id, @@ -3553,3 +3595,54 @@ class LighthouseConfigViewSet(BaseRLSViewSet): ) }, ) + + +@extend_schema_view( + list=extend_schema( + tags=["Processor"], + summary="List all processors", + description="Retrieve a list of all configured processors with options for filtering by various criteria.", + ), + retrieve=extend_schema( + tags=["Processor"], + summary="Retrieve processor details", + description="Fetch detailed information about a specific processor by its ID.", + ), + create=extend_schema( + tags=["Processor"], + summary="Create a new processor", + description="Register a new processor with the system, providing necessary configuration details. There can " + "only be one processor of each type per tenant.", + ), + partial_update=extend_schema( + tags=["Processor"], + summary="Partially update a processor", + description="Modify certain fields of an existing processor without affecting other settings.", + ), + destroy=extend_schema( + tags=["Processor"], + summary="Delete a processor", + description="Remove a processor from the system by its ID.", + ), +) +@method_decorator(CACHE_DECORATOR, name="list") +@method_decorator(CACHE_DECORATOR, name="retrieve") +class ProcessorViewSet(BaseRLSViewSet): + queryset = Processor.objects.all() + serializer_class = ProcessorSerializer + http_method_names = ["get", "post", "patch", "delete"] + filterset_class = ProcessorFilter + ordering = ["processor_type", "-inserted_at"] + # RBAC required permissions + required_permissions = [Permissions.MANAGE_ACCOUNT] + + def get_queryset(self): + queryset = Processor.objects.filter(tenant_id=self.request.tenant_id) + return queryset + + def get_serializer_class(self): + if self.action == "create": + return ProcessorCreateSerializer + elif self.action == "partial_update": + return ProcessorUpdateSerializer + return super().get_serializer_class() diff --git a/api/src/backend/api/validators.py b/api/src/backend/api/validators.py index 135a0fd6c0..543406f202 100644 --- a/api/src/backend/api/validators.py +++ b/api/src/backend/api/validators.py @@ -1,3 +1,5 @@ +import string + from django.core.exceptions import ValidationError from django.utils.translation import gettext as _ @@ -20,3 +22,89 @@ class MaximumLengthValidator: return _( f"Your password must contain no more than {self.max_length} characters." ) + + +class SpecialCharactersValidator: + def __init__(self, special_characters=None, min_special_characters=1): + # Use string.punctuation if no custom characters provided + self.special_characters = special_characters or string.punctuation + self.min_special_characters = min_special_characters + + def validate(self, password, user=None): + if ( + sum(1 for char in password if char in self.special_characters) + < self.min_special_characters + ): + raise ValidationError( + _("This password must contain at least one special character."), + code="password_no_special_characters", + params={ + "special_characters": self.special_characters, + "min_special_characters": self.min_special_characters, + }, + ) + + def get_help_text(self): + return _( + f"Your password must contain at least one special character from: {self.special_characters}" + ) + + +class UppercaseValidator: + def __init__(self, min_uppercase=1): + self.min_uppercase = min_uppercase + + def validate(self, password, user=None): + if sum(1 for char in password if char.isupper()) < self.min_uppercase: + raise ValidationError( + _( + "This password must contain at least %(min_uppercase)d uppercase letter." + ), + code="password_no_uppercase_letters", + params={"min_uppercase": self.min_uppercase}, + ) + + def get_help_text(self): + return _( + f"Your password must contain at least {self.min_uppercase} uppercase letter." + ) + + +class LowercaseValidator: + def __init__(self, min_lowercase=1): + self.min_lowercase = min_lowercase + + def validate(self, password, user=None): + if sum(1 for char in password if char.islower()) < self.min_lowercase: + raise ValidationError( + _( + "This password must contain at least %(min_lowercase)d lowercase letter." + ), + code="password_no_lowercase_letters", + params={"min_lowercase": self.min_lowercase}, + ) + + def get_help_text(self): + return _( + f"Your password must contain at least {self.min_lowercase} lowercase letter." + ) + + +class NumericValidator: + def __init__(self, min_numeric=1): + self.min_numeric = min_numeric + + def validate(self, password, user=None): + if sum(1 for char in password if char.isdigit()) < self.min_numeric: + raise ValidationError( + _( + "This password must contain at least %(min_numeric)d numeric character." + ), + code="password_no_numeric_characters", + params={"min_numeric": self.min_numeric}, + ) + + def get_help_text(self): + return _( + f"Your password must contain at least {self.min_numeric} numeric character." + ) diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 47c6320ce1..514dcfc6ff 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -11,6 +11,7 @@ SECRET_KEY = env("SECRET_KEY", default="secret") DEBUG = env.bool("DJANGO_DEBUG", default=False) ALLOWED_HOSTS = ["localhost", "127.0.0.1"] SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +USE_X_FORWARDED_HOST = True # Application definition @@ -158,6 +159,30 @@ AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, + { + "NAME": "api.validators.SpecialCharactersValidator", + "OPTIONS": { + "min_special_characters": 1, + }, + }, + { + "NAME": "api.validators.UppercaseValidator", + "OPTIONS": { + "min_uppercase": 1, + }, + }, + { + "NAME": "api.validators.LowercaseValidator", + "OPTIONS": { + "min_lowercase": 1, + }, + }, + { + "NAME": "api.validators.NumericValidator", + "OPTIONS": { + "min_numeric": 1, + }, + }, ] SIMPLE_JWT = { @@ -248,3 +273,7 @@ X_FRAME_OPTIONS = "DENY" SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin" DJANGO_DELETION_BATCH_SIZE = env.int("DJANGO_DELETION_BATCH_SIZE", 5000) + +# SAML requirement +CSRF_COOKIE_SECURE = True +SESSION_COOKIE_SECURE = True diff --git a/api/src/backend/config/settings/sentry.py b/api/src/backend/config/settings/sentry.py index 648324707f..f6752f0646 100644 --- a/api/src/backend/config/settings/sentry.py +++ b/api/src/backend/config/settings/sentry.py @@ -4,6 +4,7 @@ from config.env import env IGNORED_EXCEPTIONS = [ # Provider is not connected due to credentials errors "is not connected", + "ProviderConnectionError", # Authentication Errors from AWS "InvalidToken", "AccessDeniedException", @@ -16,7 +17,7 @@ IGNORED_EXCEPTIONS = [ "InternalServerErrorException", "AccessDenied", "No Shodan API Key", # Shodan Check - "RequestLimitExceeded", # For now we don't want to log the RequestLimitExceeded errors + "RequestLimitExceeded", # For now, we don't want to log the RequestLimitExceeded errors "ThrottlingException", "Rate exceeded", "SubscriptionRequiredException", @@ -42,7 +43,9 @@ IGNORED_EXCEPTIONS = [ "AWSAccessKeyIDInvalidError", "AWSSessionTokenExpiredError", "EndpointConnectionError", # AWS Service is not available in a region - "Pool is closed", # The following comes from urllib3: eu-west-1 -- HTTPClientError[126]: An HTTP Client raised an unhandled exception: AWSHTTPSConnectionPool(host='hostname.s3.eu-west-1.amazonaws.com', port=443): Pool is closed. + # The following comes from urllib3: eu-west-1 -- HTTPClientError[126]: An HTTP Client raised an + # unhandled exception: AWSHTTPSConnectionPool(host='hostname.s3.eu-west-1.amazonaws.com', port=443): Pool is closed. + "Pool is closed", # Authentication Errors from GCP "ClientAuthenticationError", "AuthorizationFailed", @@ -71,7 +74,7 @@ IGNORED_EXCEPTIONS = [ def before_send(event, hint): """ - before_send handles the Sentry events in order to sent them or not + before_send handles the Sentry events in order to send them or not """ # Ignore logs with the ignored_exceptions # https://docs.python.org/3/library/logging.html#logrecord-objects diff --git a/api/src/backend/config/settings/social_login.py b/api/src/backend/config/settings/social_login.py index ae9deb363a..d6b5b53df2 100644 --- a/api/src/backend/config/settings/social_login.py +++ b/api/src/backend/config/settings/social_login.py @@ -25,9 +25,18 @@ SOCIALACCOUNT_EMAIL_AUTHENTICATION = True SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True SOCIALACCOUNT_ADAPTER = "api.adapters.ProwlerSocialAccountAdapter" -# SAML keys (TODO: Validate certificates) -# SAML_PUBLIC_CERT = env("SAML_PUBLIC_CERT", default="") -# SAML_PRIVATE_KEY = env("SAML_PRIVATE_KEY", default="") + +# def inline(pem: str) -> str: +# return "".join( +# line.strip() +# for line in pem.splitlines() +# if "CERTIFICATE" not in line and "KEY" not in line +# ) + + +# # SAML keys (TODO: Validate certificates) +# SAML_PUBLIC_CERT = inline(env("SAML_PUBLIC_CERT", default="")) +# SAML_PRIVATE_KEY = inline(env("SAML_PRIVATE_KEY", default="")) SOCIALACCOUNT_PROVIDERS = { "google": { @@ -60,17 +69,14 @@ SOCIALACCOUNT_PROVIDERS = { "entity_id": "urn:prowler.com:sp", }, "advanced": { + # TODO: Validate certificates # "x509cert": SAML_PUBLIC_CERT, # "private_key": SAML_PRIVATE_KEY, # "authn_request_signed": True, - # "want_assertion_signed": True, # "want_message_signed": True, + # "want_assertion_signed": True, + "reject_idp_initiated_sso": False, "name_id_format": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "authn_request_signed": False, - "logout_request_signed": False, - "logout_response_signed": False, - "want_assertion_encrypted": False, - "want_name_id_encrypted": False, }, }, } diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index e4a03b78cb..6024fb3474 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -1,8 +1,9 @@ import logging from datetime import datetime, timedelta, timezone -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest +from allauth.socialaccount.models import SocialLogin from django.conf import settings from django.db import connection as django_connection from django.db import connections as django_connections @@ -22,12 +23,15 @@ from api.models import ( Invitation, LighthouseConfiguration, Membership, + Processor, Provider, ProviderGroup, ProviderSecret, Resource, ResourceTag, Role, + SAMLConfiguration, + SAMLDomainIndex, Scan, ScanSummary, StateChoices, @@ -388,6 +392,19 @@ def providers_fixture(tenants_fixture): return provider1, provider2, provider3, provider4, provider5, provider6 +@pytest.fixture +def processor_fixture(tenants_fixture): + tenant, *_ = tenants_fixture + processor = Processor.objects.create( + tenant_id=tenant.id, + processor_type="mutelist", + configuration="Mutelist:\n Accounts:\n *:\n Checks:\n iam_user_hardware_mfa_enabled:\n " + " Regions:\n - *\n Resources:\n - *", + ) + + return processor + + @pytest.fixture def provider_groups_fixture(tenants_fixture): tenant, *_ = tenants_fixture @@ -1118,62 +1135,62 @@ def latest_scan_finding(authenticated_client, providers_fixture, resources_fixtu return finding -# @pytest.fixture -# def saml_setup(tenants_fixture): -# tenant_id = tenants_fixture[0].id -# domain = "example.com" +@pytest.fixture +def saml_setup(tenants_fixture): + tenant_id = tenants_fixture[0].id + domain = "prowler.com" -# SAMLDomainIndex.objects.create(email_domain=domain, tenant_id=tenant_id) + SAMLDomainIndex.objects.create(email_domain=domain, tenant_id=tenant_id) -# metadata_xml = """ -# -# -# -# -# -# TEST -# -# -# -# urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress -# -# -# -# -# """ -# SAMLConfiguration.objects.create( -# tenant_id=str(tenant_id), -# email_domain=domain, -# metadata_xml=metadata_xml, -# ) + metadata_xml = """ + + + + + + TEST + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + + + """ + SAMLConfiguration.objects.create( + tenant_id=str(tenant_id), + email_domain=domain, + metadata_xml=metadata_xml, + ) -# return { -# "email": f"user@{domain}", -# "domain": domain, -# "tenant_id": tenant_id, -# } + return { + "email": f"user@{domain}", + "domain": domain, + "tenant_id": tenant_id, + } -# @pytest.fixture -# def saml_sociallogin(users_fixture): -# user = users_fixture[0] -# user.email = "samlsso@acme.com" -# extra_data = { -# "firstName": ["Test"], -# "lastName": ["User"], -# "organization": ["Prowler"], -# "userType": ["member"], -# } +@pytest.fixture +def saml_sociallogin(users_fixture): + user = users_fixture[0] + user.email = "samlsso@acme.com" + extra_data = { + "firstName": ["Test"], + "lastName": ["User"], + "organization": ["Prowler"], + "userType": ["member"], + } -# account = MagicMock() -# account.provider = "saml" -# account.extra_data = extra_data + account = MagicMock() + account.provider = "saml" + account.extra_data = extra_data -# sociallogin = MagicMock(spec=SocialLogin) -# sociallogin.account = account -# sociallogin.user = user + sociallogin = MagicMock(spec=SocialLogin) + sociallogin.account = account + sociallogin.user = user -# return sociallogin + return sociallogin def get_authorization_header(access_token: str) -> dict: diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 500d9201c8..118d62693c 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -14,9 +14,11 @@ from api.compliance import ( generate_scan_compliance, ) from api.db_utils import create_objects_in_batches, rls_transaction +from api.exceptions import ProviderConnectionError from api.models import ( ComplianceRequirementOverview, Finding, + Processor, Provider, Resource, ResourceScanSummary, @@ -132,14 +134,28 @@ def perform_prowler_scan( scan_instance.started_at = datetime.now(tz=timezone.utc) scan_instance.save() + # Find the mutelist processor if it exists + with rls_transaction(tenant_id): + try: + mutelist_processor = Processor.objects.get( + tenant_id=tenant_id, processor_type=Processor.ProcessorChoices.MUTELIST + ) + except Processor.DoesNotExist: + mutelist_processor = None + except Exception as e: + logger.error(f"Error processing mutelist rules: {e}") + mutelist_processor = None + try: with rls_transaction(tenant_id): try: - prowler_provider = initialize_prowler_provider(provider_instance) + prowler_provider = initialize_prowler_provider( + provider_instance, mutelist_processor + ) provider_instance.connected = True except Exception as e: provider_instance.connected = False - exc = ValueError( + exc = ProviderConnectionError( f"Provider {provider_instance.provider} is not connected: {e}" ) finally: @@ -274,6 +290,9 @@ def perform_prowler_scan( if not last_first_seen_at: last_first_seen_at = datetime.now(tz=timezone.utc) + # If the finding is muted at this time the reason must be the configured Mutelist + muted_reason = "Muted by mutelist" if finding.muted else None + # Create the finding finding_instance = Finding.objects.create( tenant_id=tenant_id, @@ -289,6 +308,7 @@ def perform_prowler_scan( scan=scan_instance, first_seen_at=last_first_seen_at, muted=finding.muted, + muted_reason=muted_reason, compliance=finding.compliance, ) finding_instance.add_resources([resource_instance]) diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index 998e6773be..addb2c247e 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -37,6 +37,26 @@ from prowler.lib.outputs.finding import Finding as FindingOutput logger = get_task_logger(__name__) +def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str): + """ + Helper function to perform tasks after a scan is completed. + + Args: + tenant_id (str): The tenant ID under which the scan was performed. + scan_id (str): The ID of the scan that was performed. + provider_id (str): The primary key of the Provider instance that was scanned. + """ + create_compliance_requirements_task.apply_async( + kwargs={"tenant_id": tenant_id, "scan_id": scan_id} + ) + chain( + perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id), + generate_outputs_task.si( + scan_id=scan_id, provider_id=provider_id, tenant_id=tenant_id + ), + ).apply_async() + + @shared_task(base=RLSTask, name="provider-connection-check") @set_tenant def check_provider_connection_task(provider_id: str): @@ -103,13 +123,7 @@ def perform_scan_task( checks_to_execute=checks_to_execute, ) - chain( - perform_scan_summary_task.si(tenant_id, scan_id), - create_compliance_requirements_task.si(tenant_id=tenant_id, scan_id=scan_id), - generate_outputs.si( - scan_id=scan_id, provider_id=provider_id, tenant_id=tenant_id - ), - ).apply_async() + _perform_scan_complete_tasks(tenant_id, scan_id, provider_id) return result @@ -214,20 +228,12 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): scheduler_task_id=periodic_task_instance.id, ) - chain( - perform_scan_summary_task.si(tenant_id, scan_instance.id), - create_compliance_requirements_task.si( - tenant_id=tenant_id, scan_id=str(scan_instance.id) - ), - generate_outputs.si( - scan_id=str(scan_instance.id), provider_id=provider_id, tenant_id=tenant_id - ), - ).apply_async() + _perform_scan_complete_tasks(tenant_id, str(scan_instance.id), provider_id) return result -@shared_task(name="scan-summary") +@shared_task(name="scan-summary", queue="overview") def perform_scan_summary_task(tenant_id: str, scan_id: str): return aggregate_findings(tenant_id=tenant_id, scan_id=scan_id) @@ -243,7 +249,7 @@ def delete_tenant_task(tenant_id: str): queue="scan-reports", ) @set_tenant(keep_tenant=True) -def generate_outputs(scan_id: str, provider_id: str, tenant_id: str): +def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str): """ Process findings in batches and generate output files in multiple formats. @@ -381,7 +387,7 @@ def backfill_scan_resource_summaries_task(tenant_id: str, scan_id: str): return backfill_resource_scan_summaries(tenant_id=tenant_id, scan_id=scan_id) -@shared_task(base=RLSTask, name="scan-compliance-overviews") +@shared_task(base=RLSTask, name="scan-compliance-overviews", queue="overview") def create_compliance_requirements_task(tenant_id: str, scan_id: str): """ Creates detailed compliance requirement records for a scan. diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 9577e66a09..474543a82b 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -12,6 +12,7 @@ from tasks.jobs.scan import ( ) from tasks.utils import CustomEncoder +from api.exceptions import ProviderConnectionError from api.models import ( ComplianceRequirementOverview, Finding, @@ -158,6 +159,7 @@ class TestPerformScan: assert scan_finding.raw_result == finding.raw assert scan_finding.muted assert scan_finding.compliance == finding.compliance + assert scan_finding.muted_reason == "Muted by mutelist" assert scan_resource.tenant == tenant assert scan_resource.uid == finding.resource_uid @@ -203,7 +205,7 @@ class TestPerformScan: provider_id = str(provider.id) checks_to_execute = ["check1", "check2"] - with pytest.raises(ValueError): + with pytest.raises(ProviderConnectionError): perform_prowler_scan(tenant_id, scan_id, provider_id, checks_to_execute) scan.refresh_from_db() diff --git a/api/src/backend/tasks/tests/test_tasks.py b/api/src/backend/tasks/tests/test_tasks.py index 5f7c3fd94f..def57e11b1 100644 --- a/api/src/backend/tasks/tests/test_tasks.py +++ b/api/src/backend/tasks/tests/test_tasks.py @@ -3,9 +3,10 @@ from pathlib import Path from unittest.mock import MagicMock, patch import pytest -from tasks.tasks import generate_outputs +from tasks.tasks import _perform_scan_complete_tasks, generate_outputs_task +# TODO Move this to outputs/reports jobs @pytest.mark.django_db class TestGenerateOutputs: def setup_method(self): @@ -17,7 +18,7 @@ class TestGenerateOutputs: with patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter: mock_filter.return_value.exists.return_value = False - result = generate_outputs( + result = generate_outputs_task( scan_id=self.scan_id, provider_id=self.provider_id, tenant_id=self.tenant_id, @@ -99,7 +100,7 @@ class TestGenerateOutputs: mock_compress.return_value = "/tmp/zipped.zip" mock_upload.return_value = "s3://bucket/zipped.zip" - result = generate_outputs( + result = generate_outputs_task( scan_id=self.scan_id, provider_id=self.provider_id, tenant_id=self.tenant_id, @@ -150,7 +151,7 @@ class TestGenerateOutputs: True, ] - result = generate_outputs( + result = generate_outputs_task( scan_id="scan", provider_id="provider", tenant_id=self.tenant_id, @@ -208,7 +209,7 @@ class TestGenerateOutputs: {"aws": [(lambda x: True, MagicMock())]}, ), ): - generate_outputs( + generate_outputs_task( scan_id=self.scan_id, provider_id=self.provider_id, tenant_id=self.tenant_id, @@ -276,7 +277,7 @@ class TestGenerateOutputs: } }, ): - result = generate_outputs( + result = generate_outputs_task( scan_id=self.scan_id, provider_id=self.provider_id, tenant_id=self.tenant_id, @@ -346,7 +347,7 @@ class TestGenerateOutputs: ): mock_summary.return_value.exists.return_value = True - result = generate_outputs( + result = generate_outputs_task( scan_id=self.scan_id, provider_id=self.provider_id, tenant_id=self.tenant_id, @@ -407,9 +408,31 @@ class TestGenerateOutputs: ), ): with caplog.at_level("ERROR"): - generate_outputs( + generate_outputs_task( scan_id=self.scan_id, provider_id=self.provider_id, tenant_id=self.tenant_id, ) assert "Error deleting output files" in caplog.text + + +class TestScanCompleteTasks: + @patch("tasks.tasks.create_compliance_requirements_task.apply_async") + @patch("tasks.tasks.perform_scan_summary_task.si") + @patch("tasks.tasks.generate_outputs_task.si") + def test_scan_complete_tasks( + self, mock_outputs_task, mock_scan_summary_task, mock_compliance_tasks + ): + _perform_scan_complete_tasks("tenant-id", "scan-id", "provider-id") + mock_compliance_tasks.assert_called_once_with( + kwargs={"tenant_id": "tenant-id", "scan_id": "scan-id"}, + ) + mock_scan_summary_task.assert_called_once_with( + scan_id="scan-id", + tenant_id="tenant-id", + ) + mock_outputs_task.assert_called_once_with( + scan_id="scan-id", + provider_id="provider-id", + tenant_id="tenant-id", + ) diff --git a/docs/developer-guide/lighthouse.md b/docs/developer-guide/lighthouse.md new file mode 100644 index 0000000000..e0a8d939dc --- /dev/null +++ b/docs/developer-guide/lighthouse.md @@ -0,0 +1,134 @@ +# Extending Prowler Lighthouse + +This guide helps developers customize and extend Prowler Lighthouse by adding or modifying AI agents. + +## Understanding AI Agents + +AI agents combine Large Language Models (LLMs) with specialized tools that provide environmental context. These tools can include API calls, system command execution, or any function-wrapped capability. + +### Types of AI Agents + +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. + +???+ note + To learn more about AI agents, read [Anthropic's blog post on building effective agents](https://www.anthropic.com/engineering/building-effective-agents). + +### LLM Dependency + +The autonomous nature of agents depends on the underlying LLM. Autonomous agents using identical system prompts and tools but powered by different LLM providers might approach user queries differently. Agent with one LLM might solve a problem efficiently, while with another it might take a different route or fail entirely. + +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 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: + +- 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 +- Agents only access data the current user is authorized to view +- Session management operates automatically, ensuring Role-Based Access Control (RBAC) is maintained + +## Available Prowler AI Agents + +The following specialized AI agents are available in Prowler: + +### Agent Overview + +- **provider_agent**: Fetches information about cloud providers connected to Prowler +- **user_info_agent**: Retrieves information about Prowler users +- **scans_agent**: Fetches information about Prowler scans +- **compliance_agent**: Retrieves compliance overviews across scans +- **findings_agent**: Fetches information about individual findings across scans +- **overview_agent**: Retrieves overview information (providers, findings by status and severity, etc.) + +## How to Add New Capabilities + +### Updating the Supervisor Prompt + +The supervisor agent controls system behavior, tone, and capabilities. You can find the supervisor prompt at: [https://github.com/prowler-cloud/prowler/blob/master/ui/lib/lighthouse/prompts.ts](https://github.com/prowler-cloud/prowler/blob/master/ui/lib/lighthouse/prompts.ts) + +#### Supervisor Prompt Modifications + +Modifying the supervisor prompt allows you to: + +- Change personality or response style +- Add new high-level capabilities +- Modify task delegation to specialized agents +- Set up guardrails (query types to answer or decline) + +???+ note + The supervisor agent should not have its own tools. This design keeps the system modular and maintainable. + +### How to Create New Specialized Agents + +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: + +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 + Ensure that the new agent's capabilities don't collide with existing agents. For example, if there's already a *findings_agent* that talks to findings APIs don't create a new agent to do the same. + +2. Create necessary tools for the agents to access specific data or perform actions. A tool is a specialized function that extends the capabilities of LLM by allowing it to access external data or APIs. A tool is triggered by LLM based on the description of the tool and the user's query. +For example, the description of `getScanTool` is "Fetches detailed information about a specific scan by its ID." If the description doesn't convey what the tool is capable of doing, LLM will not invoke the function. If the description of `getScanTool` was set to something random or not set at all, LLM will not answer queries like "Give me the critical issues from the scan ID xxxxxxxxxxxxxxx" +???+ note + Ensure that one tool is added to one agent only. Adding tools is optional. There can be agents with no tools at all. + +3. Use the `createReactAgent` function to define a new agent. For example, the rolesAgent name is "roles_agent" and has access to call tools "*getRolesTool*" and "*getRoleTool*" +```js +const rolesAgent = createReactAgent({ + llm: llm, + tools: [getRolesTool, getRoleTool], + name: "roles_agent", + prompt: rolesAgentPrompt, +}); +``` + +4. Create a detailed prompt defining the agent's purpose and capabilities. + +5. Add the new agent to the available agents list: +```js +const agents = [ + userInfoAgent, + providerAgent, + overviewAgent, + scansAgent, + complianceAgent, + findingsAgent, + rolesAgent, // New agent added here +]; +// Create supervisor workflow +const workflow = createSupervisor({ + agents: agents, + llm: supervisorllm, + prompt: supervisorPrompt, + outputMode: "last_message", +}); +``` + +6. Update the supervisor's system prompt to summarize the new agent's capabilities. + +### Best Practices for Agent Development + +When developing new agents or capabilities: + +- **Clear Responsibility Boundaries**: Each agent should have a defined purpose with minimal overlap. No two agents should access the same tools or different tools accessing the same Prowler APIs. +- **Minimal Data Access**: Agents should only request the data they need, keeping requests specific to minimize context window usage, cost, and response time. +- **Thorough Prompting:** Ensure agent prompts include clear instructions about: + - The agent's purpose and limitations + - How to use its tools + - How to format responses for the supervisor + - Error handling procedures (Optional) +- **Security Considerations:** Agents should never modify data or access sensitive information like secrets or credentials. +- **Testing:** Thoroughly test new agents with various queries before deploying to production. diff --git a/docs/getting-started/requirements.md b/docs/getting-started/requirements.md index 49677119b2..1405477533 100644 --- a/docs/getting-started/requirements.md +++ b/docs/getting-started/requirements.md @@ -156,7 +156,7 @@ Follow the instructions in the [Create Prowler Service Principal](../tutorials/m If you don't add the external API permissions described in the mentioned section above you will only be able to run the checks that work through MS Graph. This means that you won't run all the provider. -If you want to scan all the checks from M365 you will need to use the recommended authentication method or add the external API permissions. +If you want to scan all the checks from M365 you will need to add the required permissions to the service principal application. Refer to the [Needed permissions](/docs/tutorials/microsoft365/getting-started-m365.md#needed-permissions) section for more information. ### Service Principal and User Credentials authentication @@ -172,7 +172,7 @@ export M365_USER="your_email@example.com" export M365_PASSWORD="examplepassword" ``` -These two new environment variables are **required** to execute the PowerShell modules needed to retrieve information from M365 services. Prowler uses Service Principal authentication to access Microsoft Graph and user credentials to authenticate to Microsoft PowerShell modules. +These two new environment variables are **required** in this authentication method to execute the PowerShell modules needed to retrieve information from M365 services. Prowler uses Service Principal authentication to access Microsoft Graph and user credentials to authenticate to Microsoft PowerShell modules. - `M365_USER` should be your Microsoft account email using the **assigned domain in the tenant**. This means it must look like `example@YourCompany.onmicrosoft.com` or `example@YourCompany.com`, but it must be the exact domain assigned to that user in the tenant. @@ -208,30 +208,56 @@ Since this is a delegated permission authentication method, necessary permission ### Needed permissions -Prowler for M365 requires two types of permission scopes to be set (if you want to run the full provider including PowerShell checks). Both must be configured using Microsoft Entra ID: +Prowler for M365 requires different permission scopes depending on the authentication method you choose. The permissions must be configured using Microsoft Entra ID: -- **Service Principal Application Permissions**: These are set at the **application** level and are used to retrieve data from the identity being assessed: - - `AuditLog.Read.All`: Required for Entra service. - - `Directory.Read.All`: Required for all services. - - `Policy.Read.All`: Required for all services. - - `SharePointTenantSettings.Read.All`: Required for SharePoint service. - - `User.Read` (IMPORTANT: this must be set as **delegated**): Required for the sign-in. - - `Exchange.ManageAsApp` from external API `Office 365 Exchange Online`: Required for Exchange PowerShell module app authentication. You also need to assign the `Exchange Administrator` role to the app. - - `application_access` from external API `Skype and Teams Tenant Admin API`: Required for Teams PowerShell module app authentication. +#### For Service Principal Authentication (`--sp-env-auth`) - Recommended - ???+ note - You can replace `Directory.Read.All` with `Domain.Read.All` is a more restrictive permission but you won't be able to run the Entra checks related with DirectoryRoles and GetUsers. +When using service principal authentication, you need to add the following **Application Permissions** configured to: - > If you do this you will need to add also the `Organization.Read.All` permission to the service principal application in order to authenticate. +**Microsoft Graph API Permissions:** +- `AuditLog.Read.All`: Required for Entra service. +- `Directory.Read.All`: Required for all services. +- `Policy.Read.All`: Required for all services. +- `SharePointTenantSettings.Read.All`: Required for SharePoint service. +- `User.Read` (IMPORTANT: this must be set as **delegated**): Required for the sign-in. +**External API Permissions:** +- `Exchange.ManageAsApp` from external API `Office 365 Exchange Online`: Required for Exchange PowerShell module app authentication. You also need to assign the `Exchange Administrator` role to the app. +- `application_access` from external API `Skype and Teams Tenant Admin API`: Required for Teams PowerShell module app authentication. +???+ note + You can replace `Directory.Read.All` with `Domain.Read.All` is a more restrictive permission but you won't be able to run the Entra checks related with DirectoryRoles and GetUsers. + > If you do this you will need to add also the `Organization.Read.All` permission to the service principal application in order to authenticate. -- **Powershell Modules Permissions** (if using user credentials): These are set at the `M365_USER` level, so the user used to run Prowler must have one of the following roles: - - `Global Reader` (recommended): this allows you to read all roles needed. - - `Exchange Administrator` and `Teams Administrator`: user needs both roles but with this [roles](https://learn.microsoft.com/en-us/exchange/permissions-exo/permissions-exo#microsoft-365-permissions-in-exchange-online) you can access to the same information as a Global Reader (since only read access is needed, Global Reader is recommended). +???+ warning + With service principal only authentication, you can only run checks that work through MS Graph API. Some checks that require PowerShell modules will not be executed. -In order to know how to assign those permissions and roles follow the instructions in the Microsoft Entra ID [permissions](../tutorials/microsoft365/getting-started-m365.md#grant-required-api-permissions) and [roles](../tutorials/microsoft365/getting-started-m365.md#assign-required-roles-to-your-user) section. +#### For Service Principal + User Credentials Authentication (`--env-auth`) + +When using service principal with user credentials authentication, you need **both** sets of permissions: + +**1. Service Principal Application Permissions**: +- You **will need** all the Microsoft Graph API permissions listed above. +- You **won't need** the External API permissions listed above. + +**2. User-Level Permissions**: These are set at the `M365_USER` level, so the user used to run Prowler must have one of the following roles: +- `Global Reader` (recommended): this allows you to read all roles needed. +- `Exchange Administrator` and `Teams Administrator`: user needs both roles but with this [roles](https://learn.microsoft.com/en-us/exchange/permissions-exo/permissions-exo#microsoft-365-permissions-in-exchange-online) you can access to the same information as a Global Reader (since only read access is needed, Global Reader is recommended). + +???+ note + This is the **recommended authentication method** because it allows you to run the full M365 provider including PowerShell checks, providing complete coverage of all available security checks. + +#### For Browser Authentication (`--browser-auth`) + +When using browser authentication, permissions are delegated to the user, so the user must have the appropriate permissions rather than the application. + +???+ warning + With browser authentication, you will only be able to run checks that work through MS Graph API. PowerShell module checks will not be executed. + +--- + +**To assign these permissions and roles**, follow the instructions in the Microsoft Entra ID [permissions](../tutorials/microsoft365/getting-started-m365.md#grant-required-api-permissions) and [roles](../tutorials/microsoft365/getting-started-m365.md#assign-required-roles-to-your-user) section. ### Supported PowerShell versions diff --git a/docs/img/m365-credentials.png b/docs/img/m365-credentials.png index 87191751a8..ab912f9d8e 100644 Binary files a/docs/img/m365-credentials.png and b/docs/img/m365-credentials.png differ diff --git a/docs/tutorials/github/authentication.md b/docs/tutorials/github/authentication.md index 29f7795aba..5b589921ec 100644 --- a/docs/tutorials/github/authentication.md +++ b/docs/tutorials/github/authentication.md @@ -37,7 +37,7 @@ prowler github --github-app-id app_id --github-app-key app_key If no login method is explicitly provided, Prowler will automatically attempt to authenticate using environment variables in the following order of precedence: 1. `GITHUB_PERSONAL_ACCESS_TOKEN` -2. `OAUTH_APP_TOKEN` +2. `GITHUB_OAUTH_APP_TOKEN` 3. `GITHUB_APP_ID` and `GITHUB_APP_KEY` ???+ note diff --git a/docs/tutorials/img/lighthouse-architecture.png b/docs/tutorials/img/lighthouse-architecture.png new file mode 100644 index 0000000000..63202ce7c7 Binary files /dev/null and b/docs/tutorials/img/lighthouse-architecture.png differ diff --git a/docs/tutorials/img/lighthouse-config.png b/docs/tutorials/img/lighthouse-config.png new file mode 100644 index 0000000000..8bae46f51b Binary files /dev/null and b/docs/tutorials/img/lighthouse-config.png differ diff --git a/docs/tutorials/img/lighthouse-feature1.png b/docs/tutorials/img/lighthouse-feature1.png new file mode 100644 index 0000000000..4568f5752a Binary files /dev/null and b/docs/tutorials/img/lighthouse-feature1.png differ diff --git a/docs/tutorials/img/lighthouse-feature2.png b/docs/tutorials/img/lighthouse-feature2.png new file mode 100644 index 0000000000..01e72bbaf4 Binary files /dev/null and b/docs/tutorials/img/lighthouse-feature2.png differ diff --git a/docs/tutorials/img/lighthouse-feature3.png b/docs/tutorials/img/lighthouse-feature3.png new file mode 100644 index 0000000000..a40177cf18 Binary files /dev/null and b/docs/tutorials/img/lighthouse-feature3.png differ diff --git a/docs/tutorials/img/lighthouse-intro.png b/docs/tutorials/img/lighthouse-intro.png new file mode 100644 index 0000000000..38d3f0819c Binary files /dev/null and b/docs/tutorials/img/lighthouse-intro.png differ diff --git a/docs/tutorials/microsoft365/authentication.md b/docs/tutorials/microsoft365/authentication.md index f8bd9d13d4..c63b8d734c 100644 --- a/docs/tutorials/microsoft365/authentication.md +++ b/docs/tutorials/microsoft365/authentication.md @@ -12,7 +12,7 @@ This allows Prowler to authenticate against Microsoft 365 using the following me To launch the tool first you need to specify which method is used through the following flags: ```console -# To use service principal (app) authentication and Microsoft user credentials (to use PowerShell) +# To use service principal (app) authentication and Microsoft user credentials prowler m365 --env-auth # To use service principal authentication @@ -25,4 +25,4 @@ prowler m365 --az-cli-auth prowler m365 --browser-auth --tenant-id "XXXXXXXX" ``` -To use Prowler you need to set up also the permissions required to access your resources in your Microsoft 365 account, to more details refer to [Requirements](../../getting-started/requirements.md#microsoft-365) +To use Prowler you need to set up also the permissions required to access your resources in your Microsoft 365 account, to more details refer to [Requirements](../../getting-started/requirements.md#needed-permissions-2) diff --git a/docs/tutorials/microsoft365/getting-started-m365.md b/docs/tutorials/microsoft365/getting-started-m365.md index ef9a50da30..935e45ad5c 100644 --- a/docs/tutorials/microsoft365/getting-started-m365.md +++ b/docs/tutorials/microsoft365/getting-started-m365.md @@ -253,6 +253,8 @@ This method is not recommended because it requires a user with MFA enabled and M - `Client ID` - `Tenant ID` - `AZURE_CLIENT_SECRET` from earlier + + If you are using user authentication, also add: - `M365_USER` the user using the correct assigned domain, more info [here](../../getting-started/requirements.md#service-principal-and-user-credentials-authentication-recommended) - `M365_PASSWORD` the password of the user diff --git a/docs/tutorials/microsoft365/img/m365-credentials.png b/docs/tutorials/microsoft365/img/m365-credentials.png index 87191751a8..ab912f9d8e 100644 Binary files a/docs/tutorials/microsoft365/img/m365-credentials.png and b/docs/tutorials/microsoft365/img/m365-credentials.png differ diff --git a/docs/tutorials/prowler-app-lighthouse.md b/docs/tutorials/prowler-app-lighthouse.md new file mode 100644 index 0000000000..52e94cfe9d --- /dev/null +++ b/docs/tutorials/prowler-app-lighthouse.md @@ -0,0 +1,204 @@ +# Prowler Lighthouse + +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 + +## How It Works + +Prowler Lighthouse uses OpenAI's language models and integrates with your Prowler security findings data. + +Here's what's happening behind the scenes: + +- The system uses a multi-agent architecture built with [LanggraphJS](https://github.com/langchain-ai/langgraphjs) for LLM logic and [Vercel AI SDK UI](https://sdk.vercel.ai/docs/ai-sdk-ui/overview) for frontend chatbot. +- 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. +- 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 + +???+ 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: + +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 + +### Adding Business Context + +The optional business context field lets you provide additional information to help Lighthouse understand your environment and priorities, including: + +- Your organization's cloud security goals +- Information about account owners or responsible teams +- Compliance requirements for your organization +- Current security initiatives or focus areas + +Better context leads to more relevant responses and prioritization that aligns with your needs. + +## Capabilities + +Prowler Lighthouse is designed to be your AI security team member, with capabilities including: + +### Natural Language Querying + +Ask questions in plain English about your security findings. Examples: + +- "What are my highest risk findings?" +- "Show me all S3 buckets with public access." +- "What security issues were found in my production accounts?" + +Natural language querying + +### Detailed Remediation Guidance + +Get tailored step-by-step instructions for fixing security issues: + +- Clear explanations of the problem and its impact +- Commands or console steps to implement fixes +- Alternative approaches with different solutions + +Detailed Remediation + +### Enhanced Context and Analysis + +Lighthouse 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 +- Connect related findings to show broader security patterns + +Business Context + +Contextual Responses + +## Important Notes + +Prowler Lighthouse 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. +- **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). + +### 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: + +#### Accessible API Endpoints + +**User Management:** + +- List all users - `/api/v1/users` +- Retrieve the current user's information - `/api/v1/users/me` + +**Provider Management:** + +- List all providers - `/api/v1/providers` +- Retrieve data from a provider - `/api/v1/providers/{id}` + +**Scan Management:** + +- List all scans - `/api/v1/scans` +- Retrieve data from a specific scan - `/api/v1/scans/{id}` + +**Resource Management:** + +- List all resources - `/api/v1/resources` +- Retrieve data for a resource - `/api/v1/resources/{id}` + +**Findings Management:** + +- List all findings - `/api/v1/findings` +- Retrieve data from a specific finding - `/api/v1/findings/{id}` +- Retrieve metadata values from findings - `/api/v1/findings/metadata` + +**Overview Data:** + +- Get aggregated findings data - `/api/v1/overviews/findings` +- Get findings data by severity - `/api/v1/overviews/findings_severity` +- Get aggregated provider data - `/api/v1/overviews/providers` +- Get findings data by service - `/api/v1/overviews/services` + +**Compliance Management:** + +- List compliance overviews for a scan - `/api/v1/compliance-overviews` +- Retrieve data from a specific compliance overview - `/api/v1/compliance-overviews/{id}` + +#### Excluded API Endpoints + +Not all Prowler API endpoints are integrated with Lighthouse. 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.) + +**Excluded Endpoints:** + +**User Management:** + +- List specific users information - `/api/v1/users/{id}` +- List user memberships - `/api/v1/users/{user_pk}/memberships` +- Retrieve membership data from the user - `/api/v1/users/{user_pk}/memberships/{id}` + +**Tenant Management:** + +- List all tenants - `/api/v1/tenants` +- Retrieve data from a tenant - `/api/v1/tenants/{id}` +- List tenant memberships - `/api/v1/tenants/{tenant_pk}/memberships` +- List all invitations - `/api/v1/tenants/invitations` +- Retrieve data from tenant invitation - `/api/v1/tenants/invitations/{id}` + +**Security and Configuration:** + +- List all secrets - `/api/v1/providers/secrets` +- Retrieve data from a secret - `/api/v1/providers/secrets/{id}` +- List all provider groups - `/api/v1/provider-groups` +- Retrieve data from a provider group - `/api/v1/provider-groups/{id}` + +**Reports and Tasks:** + +- Download zip report - `/api/v1/scans/{v1}/report` +- List all tasks - `/api/v1/tasks` +- Retrieve data from a specific task - `/api/v1/tasks/{id}` + +**Lighthouse Configuration:** + +- List OpenAI configuration - `/api/v1/lighthouse-config` +- Retrieve OpenAI key and configuration - `/api/v1/lighthouse-config/{id}` + +???+ note + Agents only have access to hit GET endpoints. They don't have access to other HTTP methods. + +## FAQs + +**1. Why only OpenAI models?** + +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. +- **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. + +**2. Why a multi-agent supervisor model?** + +Context windows are limited. While demo data fits inside the context window, querying real-world data often exceeds it. A multi-agent architecture is used so different agents fetch different sizes of data and respond with the minimum required data to the supervisor. This spreads the context window usage across agents. + +**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. + +**4. Can the Lighthouse 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-sso.md b/docs/tutorials/prowler-app-sso.md index 0c3089291c..2ca898beb4 100644 --- a/docs/tutorials/prowler-app-sso.md +++ b/docs/tutorials/prowler-app-sso.md @@ -1,6 +1,6 @@ # Configuring SAML Single Sign-On (SSO) in Prowler -This guide explains how to enable and test SAML SSO integration in Prowler. It includes environment setup, certificate configuration, API endpoints, and how to configure Okta as your Identity Provider (IdP). +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). --- @@ -20,26 +20,6 @@ Update this variable to specify which domains Django should accept incoming requ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,prowler-api,mycompany.prowler ``` -# SAML Certificates - -To enable SAML support, you must provide a public certificate and private key to allow Prowler to sign SAML requests and validate responses. - -### Why is this necessary? - -SAML relies on digital signatures to verify trust between the Identity Provider (IdP) and the Service Provider (SP). Prowler acts as the SP and must use a certificate to sign outbound authentication requests. - -### Add to your .env file: - -```env -SAML_PUBLIC_CERT="-----BEGIN CERTIFICATE----- -...your certificate here... ------END CERTIFICATE-----" - -SAML_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- -...your private key here... ------END PRIVATE KEY-----" -``` - # SAML Configuration API You can manage SAML settings via the API. Prowler provides full CRUD support for tenant-specific SAML configuration. @@ -60,7 +40,7 @@ You can manage SAML settings via the API. Prowler provides full CRUD support for ### Description -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 and the required certificates are present, it responds with an HTTP 302 redirect to the appropriate saml_login endpoint for the organization. +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. - POST /api/v1/accounts/saml/initiate/ @@ -78,7 +58,7 @@ This endpoint receives an email and checks if there is an active SAML configurat • 302 FOUND: Redirects to the SAML login URL associated with the organization. - • 403 FORBIDDEN: The domain is not authorized or SAML certificates are missing from the configuration. + • 403 FORBIDDEN: The domain is not authorized. ### Validation logic @@ -86,8 +66,6 @@ This endpoint receives an email and checks if there is an active SAML configurat • Retrieves the related SAMLConfiguration object via tenant_id. - • Verifies that SAML_PUBLIC_CERT and SAML_PRIVATE_KEY environment variables are set. - # SAML Integration: Testing Guide @@ -95,26 +73,7 @@ This document outlines the process for testing the SAML integration functionalit --- -## 1. Generate Self-Signed Certificate and Private Key - -First, generate a self-signed certificate and corresponding private key using OpenSSL: - -```bash -openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ - -keyout saml_private_key.pem \ - -out saml_public_cert.pem \ - -subj "/C=US/ST=Test/L=Test/O=Test/OU=Test/CN=localhost" -``` - -## 2. Add Certificate Values to .env - -Paste the generated values into your .env file: -``` -SAML_PUBLIC_CERT= -SAML_PRIVATE_KEY= -``` - -## 3. Start Ngrok and Update ALLOWED_HOSTS +## 1. Start Ngrok and Update ALLOWED_HOSTS Start ngrok on port 8080: ``` @@ -127,7 +86,7 @@ Then, copy the generated ngrok URL and include it in the ALLOWED_HOSTS setting. ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"]) ``` -## 4. Configure the Identity Provider (IdP) +## 2. Configure the Identity Provider (IdP) Start your environment and configure your IdP. You will need to download the IdP's metadata XML file. @@ -137,7 +96,7 @@ Your Assertion Consumer Service (ACS) URL must follow this format: https:///api/v1/accounts/saml//acs/ ``` -## 5. IdP Attribute Mapping +## 3. IdP Attribute Mapping The following fields are expected from the IdP: @@ -151,7 +110,7 @@ The following fields are expected from the IdP: These values are dynamic. If the values change in the IdP, they will be updated on the next login. -## 6. SAML Configuration API (POST) +## 4. SAML Configuration API (POST) SAML configuration is managed via a CRUD API. Use the following POST request to create a new configuration: @@ -171,7 +130,7 @@ curl --location 'http://localhost:8080/api/v1/saml-config' \ }' ``` -## 7. SAML SSO Callback Configuration +## 5. SAML SSO Callback Configuration ### Environment Variable Configuration @@ -201,7 +160,7 @@ AUTH_URL="" - 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. -## 8. Start SAML Login Flow +## 6. Start SAML Login Flow Once everything is configured, start the SAML login process by visiting the following URL: @@ -211,6 +170,6 @@ https:///api/v1/accounts/saml//login/?email= diff --git a/mkdocs.yml b/mkdocs.yml index 17b44eb336..c0adae11db 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 + - Lighthouse: tutorials/prowler-app-lighthouse.md - CLI: - Miscellaneous: tutorials/misc.md - Reporting: tutorials/reporting.md @@ -106,6 +107,8 @@ nav: - Getting Started: tutorials/microsoft365/getting-started-m365.md - Authentication: tutorials/microsoft365/authentication.md - Use of PowerShell: tutorials/microsoft365/use-of-powershell.md + - GitHub: + - Authentication: tutorials/github/authentication.md - IaC: - Getting Started: tutorials/iac/getting-started-iac.md - Developer Guide: @@ -117,6 +120,7 @@ nav: - Outputs: developer-guide/outputs.md - Integrations: developer-guide/integrations.md - Compliance: developer-guide/security-compliance-framework.md + - Lighthouse: developer-guide/lighthouse.md - Provider Specific Details: - AWS: developer-guide/aws-details.md - Azure: developer-guide/azure-details.md diff --git a/poetry.lock b/poetry.lock index ababbb8ef8..6977137741 100644 --- a/poetry.lock +++ b/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.2 and should not be changed by hand. [[package]] name = "about-time" @@ -666,6 +666,42 @@ azure-common = ">=1.1,<2.0" azure-mgmt-core = ">=1.3.0,<2.0.0" msrest = ">=0.6.21" +[[package]] +name = "azure-mgmt-recoveryservices" +version = "3.1.0" +description = "Microsoft Azure Recovery Services Client Library for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "azure_mgmt_recoveryservices-3.1.0-py3-none-any.whl", hash = "sha256:21c58afdf4ae66806783e95f8cd17e3bec31be7178c48784db21f0b05de7fa66"}, + {file = "azure_mgmt_recoveryservices-3.1.0.tar.gz", hash = "sha256:7f2db98401708cf145322f50bc491caf7967bec4af3bf7b0984b9f07d3092687"}, +] + +[package.dependencies] +azure-common = ">=1.1" +azure-mgmt-core = ">=1.5.0" +isodate = ">=0.6.1" +typing-extensions = ">=4.6.0" + +[[package]] +name = "azure-mgmt-recoveryservicesbackup" +version = "9.2.0" +description = "Microsoft Azure Recovery Services Backup Management Client Library for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "azure_mgmt_recoveryservicesbackup-9.2.0-py3-none-any.whl", hash = "sha256:c0002858d0166b6a10189a1fd580a49c83dc31b111e98010a5b2ea0f767dfff1"}, + {file = "azure_mgmt_recoveryservicesbackup-9.2.0.tar.gz", hash = "sha256:c402b3e22a6c3879df56bc37e0063142c3352c5102599ff102d19824f1b32b29"}, +] + +[package.dependencies] +azure-common = ">=1.1" +azure-mgmt-core = ">=1.3.2" +isodate = ">=0.6.1" +typing-extensions = ">=4.6.0" + [[package]] name = "azure-mgmt-resource" version = "23.3.0" @@ -1675,21 +1711,18 @@ xml-validation = ["lxml (>=4,<6)"] [[package]] name = "dash" -version = "2.18.2" +version = "3.1.1" description = "A Python framework for building reactive web-apps. Developed by Plotly." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "dash-2.18.2-py3-none-any.whl", hash = "sha256:0ce0479d1bc958e934630e2de7023b8a4558f23ce1f9f5a4b34b65eb3903a869"}, - {file = "dash-2.18.2.tar.gz", hash = "sha256:20e8404f73d0fe88ce2eae33c25bbc513cbe52f30d23a401fa5f24dbb44296c8"}, + {file = "dash-3.1.1-py3-none-any.whl", hash = "sha256:66fff37e79c6aa114cd55aea13683d1e9afe0e3f96b35388baca95ff6cfdad23"}, + {file = "dash-3.1.1.tar.gz", hash = "sha256:916b31cec46da0a3339da0e9df9f446126aa7f293c0544e07adf9fe4ba060b18"}, ] [package.dependencies] -dash-core-components = "2.0.0" -dash-html-components = "2.0.0" -dash-table = "5.0.0" -Flask = ">=1.0.4,<3.1" +Flask = ">=1.0.4,<3.2" importlib-metadata = "*" nest-asyncio = "*" plotly = ">=5.0.0" @@ -1697,11 +1730,12 @@ requests = "*" retrying = "*" setuptools = "*" typing-extensions = ">=4.1.1" -Werkzeug = "<3.1" +Werkzeug = "<3.2" [package.extras] -celery = ["celery[redis] (>=5.1.2)", "redis (>=3.5.3)"] -ci = ["black (==22.3.0)", "dash-dangerously-set-inner-html", "dash-flow-example (==0.0.5)", "flake8 (==7.0.0)", "flaky (==3.8.1)", "flask-talisman (==1.0.0)", "jupyterlab (<4.0.0)", "mimesis (<=11.1.0)", "mock (==4.0.3)", "numpy (<=1.26.3)", "openpyxl", "orjson (==3.10.3)", "pandas (>=1.4.0)", "pyarrow", "pylint (==3.0.3)", "pytest-mock", "pytest-rerunfailures", "pytest-sugar (==0.9.6)", "pyzmq (==25.1.2)", "xlrd (>=2.0.1)"] +async = ["flask[async]"] +celery = ["celery[redis] (>=5.1.2,<5.4.0)", "kombu (<5.4.0)", "redis (>=3.5.3,<=5.0.4)"] +ci = ["black (==22.3.0)", "flake8 (==7.0.0)", "flaky (==3.8.1)", "flask-talisman (==1.0.0)", "ipython (<9.0.0)", "jupyterlab (<4.0.0)", "mimesis (<=11.1.0)", "mock (==4.0.3)", "mypy (==1.15.0) ; python_version >= \"3.12\"", "numpy (<=1.26.3)", "openpyxl", "orjson (==3.10.3)", "pandas (>=1.4.0)", "pyarrow", "pylint (==3.0.3)", "pyright (==1.1.398) ; python_version >= \"3.7\"", "pytest-mock", "pytest-rerunfailures", "pytest-sugar (==0.9.6)", "pyzmq (==25.1.2)", "xlrd (>=2.0.1)"] compress = ["flask-compress"] dev = ["PyYAML (>=5.4.1)", "coloredlogs (>=15.0.1)", "fire (>=0.4.0)"] diskcache = ["diskcache (>=5.2.1)", "multiprocess (>=0.70.12)", "psutil (>=5.8.0)"] @@ -1709,57 +1743,21 @@ testing = ["beautifulsoup4 (>=4.8.2)", "cryptography", "dash-testing-stub (>=0.0 [[package]] name = "dash-bootstrap-components" -version = "1.6.0" +version = "2.0.3" description = "Bootstrap themed components for use in Plotly Dash" optional = false -python-versions = "<4,>=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "dash_bootstrap_components-1.6.0-py3-none-any.whl", hash = "sha256:97f0f47b38363f18863e1b247462229266ce12e1e171cfb34d3c9898e6e5cd1e"}, - {file = "dash_bootstrap_components-1.6.0.tar.gz", hash = "sha256:960a1ec9397574792f49a8241024fa3cecde0f5930c971a3fc81f016cbeb1095"}, + {file = "dash_bootstrap_components-2.0.3-py3-none-any.whl", hash = "sha256:82754d3d001ad5482b8a82b496c7bf98a1c68d2669d607a89dda7ec627304af5"}, + {file = "dash_bootstrap_components-2.0.3.tar.gz", hash = "sha256:5c161b04a6e7ed19a7d54e42f070c29fd6c385d5a7797e7a82999aa2fc15b1de"}, ] [package.dependencies] -dash = ">=2.0.0" +dash = ">=3.0.4" [package.extras] -pandas = ["numpy", "pandas"] - -[[package]] -name = "dash-core-components" -version = "2.0.0" -description = "Core component suite for Dash" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "dash_core_components-2.0.0-py3-none-any.whl", hash = "sha256:52b8e8cce13b18d0802ee3acbc5e888cb1248a04968f962d63d070400af2e346"}, - {file = "dash_core_components-2.0.0.tar.gz", hash = "sha256:c6733874af975e552f95a1398a16c2ee7df14ce43fa60bb3718a3c6e0b63ffee"}, -] - -[[package]] -name = "dash-html-components" -version = "2.0.0" -description = "Vanilla HTML components for Dash" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "dash_html_components-2.0.0-py3-none-any.whl", hash = "sha256:b42cc903713c9706af03b3f2548bda4be7307a7cf89b7d6eae3da872717d1b63"}, - {file = "dash_html_components-2.0.0.tar.gz", hash = "sha256:8703a601080f02619a6390998e0b3da4a5daabe97a1fd7a9cebc09d015f26e50"}, -] - -[[package]] -name = "dash-table" -version = "5.0.0" -description = "Dash table" -optional = false -python-versions = "*" -groups = ["main"] -files = [ - {file = "dash_table-5.0.0-py3-none-any.whl", hash = "sha256:19036fa352bb1c11baf38068ec62d172f0515f73ca3276c79dee49b95ddc16c9"}, - {file = "dash_table-5.0.0.tar.gz", hash = "sha256:18624d693d4c8ef2ddec99a6f167593437a7ea0bf153aa20f318c170c5bc7308"}, -] +pandas = ["numpy (>=2.0.2)", "pandas (>=2.2.3)"] [[package]] name = "decorator" @@ -2039,23 +2037,24 @@ pyflakes = ">=3.2.0,<3.3.0" [[package]] name = "flask" -version = "3.0.3" +version = "3.1.1" description = "A simple framework for building complex web applications." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "flask-3.0.3-py3-none-any.whl", hash = "sha256:34e815dfaa43340d1d15a5c3a02b8476004037eb4840b34910c6e21679d288f3"}, - {file = "flask-3.0.3.tar.gz", hash = "sha256:ceb27b0af3823ea2737928a4d99d125a06175b8512c445cbd9a9ce200ef76842"}, + {file = "flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c"}, + {file = "flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e"}, ] [package.dependencies] -blinker = ">=1.6.2" +blinker = ">=1.9.0" click = ">=8.1.3" importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} -itsdangerous = ">=2.1.2" -Jinja2 = ">=3.1.2" -Werkzeug = ">=3.0.0" +itsdangerous = ">=2.2.0" +jinja2 = ">=3.1.2" +markupsafe = ">=2.1.1" +werkzeug = ">=3.1.0" [package.extras] async = ["asgiref (>=3.2)"] @@ -2678,6 +2677,8 @@ python-versions = "*" groups = ["dev"] files = [ {file = "jsonpath-ng-1.7.0.tar.gz", hash = "sha256:f6f5f7fd4e5ff79c785f1573b394043b39849fb2bb47bcead935d12b00beab3c"}, + {file = "jsonpath_ng-1.7.0-py2-none-any.whl", hash = "sha256:898c93fc173f0c336784a3fa63d7434297544b7198124a68f9a3ef9597b0ae6e"}, + {file = "jsonpath_ng-1.7.0-py3-none-any.whl", hash = "sha256:f3d7f9e848cba1b6da28c55b1c26ff915dc9e0b1ba7e752a53d6da8d5cbd00b6"}, ] [package.dependencies] @@ -6350,14 +6351,14 @@ test = ["websockets"] [[package]] name = "werkzeug" -version = "3.0.6" +version = "3.1.3" description = "The comprehensive WSGI web application library." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "werkzeug-3.0.6-py3-none-any.whl", hash = "sha256:1bc0c2310d2fbb07b1dd1105eba2f7af72f322e1e455f2f93c993bee8c8a5f17"}, - {file = "werkzeug-3.0.6.tar.gz", hash = "sha256:a8dd59d4de28ca70471a34cba79bed5f7ef2e036a76b3ab0835474246eb41f8d"}, + {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, + {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, ] [package.dependencies] @@ -6622,4 +6623,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">3.9.1,<3.13" -content-hash = "c442552635c8e904d1c7a50f4787c8e90ec90787960ee1867f2235a7aa2205f0" +content-hash = "4b0eee5566caf8e9d1e2e6fe8ac37733b29dd4275c2d65ac5291fa3acd514d9e" diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index d122c8a292..c1ee43f193 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -6,11 +6,26 @@ All notable changes to the **Prowler SDK** are documented in this file. ### Added - `storage_smb_channel_encryption_with_secure_algorithm` check for Azure provider [(#8123)](https://github.com/prowler-cloud/prowler/pull/8123) +- `storage_smb_protocol_version_is_latest` check for Azure provider [(#8128)](https://github.com/prowler-cloud/prowler/pull/8128) +- `vm_backup_enabled` check for Azure provider [(#8182)](https://github.com/prowler-cloud/prowler/pull/8182) +- `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) ### Changed ### Fixed -- fix(iam): detect wildcarded ARNs in sts:AssumeRole policy resources [(#8164)](https://github.com/prowler-cloud/prowler/pull/8164) +- Add GitHub provider to lateral panel in documentation and change -h environment variable output [(#8246)](https://github.com/prowler-cloud/prowler/pull/8246) + +--- + +## [v5.8.1] (Prowler 5.8.1) + +### Fixed +- Detect wildcarded ARNs in sts:AssumeRole policy resources [(#8164)](https://github.com/prowler-cloud/prowler/pull/8164) +- List all streams and `firehose_stream_encrypted_at_rest` logic [(#8213)](https://github.com/prowler-cloud/prowler/pull/8213) +- Allow empty values for http_endpoint in templates [(#8184)](https://github.com/prowler-cloud/prowler/pull/8184) +- Convert all Azure Storage models to Pydantic models to avoid serialization issues [(#8222)](https://github.com/prowler-cloud/prowler/pull/8222) --- diff --git a/prowler/providers/aws/aws_regions_by_service.json b/prowler/providers/aws/aws_regions_by_service.json index 1172e88e10..a56854b214 100644 --- a/prowler/providers/aws/aws_regions_by_service.json +++ b/prowler/providers/aws/aws_regions_by_service.json @@ -1985,6 +1985,7 @@ "eu-central-2", "eu-north-1", "eu-south-1", + "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", @@ -5479,25 +5480,37 @@ "ap-northeast-2", "ap-northeast-3", "ap-south-1", + "ap-south-2", "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", + "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-7", "ca-central-1", + "ca-west-1", "eu-central-1", "eu-central-2", "eu-north-1", "eu-south-1", + "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "il-central-1", + "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", "us-west-1", "us-west-2" ], - "aws-cn": [], + "aws-cn": [ + "cn-north-1", + "cn-northwest-1" + ], "aws-us-gov": [ "us-gov-east-1", "us-gov-west-1" @@ -5513,18 +5526,27 @@ "ap-northeast-2", "ap-northeast-3", "ap-south-1", + "ap-south-2", "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", + "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-7", "ca-central-1", + "ca-west-1", "eu-central-1", "eu-central-2", "eu-north-1", "eu-south-1", + "eu-south-2", "eu-west-1", "eu-west-2", "eu-west-3", + "il-central-1", + "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -7946,6 +7968,7 @@ "sa-east-1", "us-east-1", "us-east-2", + "us-west-1", "us-west-2" ], "aws-cn": [], @@ -9829,6 +9852,13 @@ ] } }, + "sagemakerautopilot": { + "regions": { + "aws": [], + "aws-cn": [], + "aws-us-gov": [] + } + }, "savingsplans": { "regions": { "aws": [ diff --git a/prowler/providers/aws/services/ec2/ec2_launch_template_imdsv2_required/ec2_launch_template_imdsv2_required.py b/prowler/providers/aws/services/ec2/ec2_launch_template_imdsv2_required/ec2_launch_template_imdsv2_required.py index a974128061..b89c7a821a 100644 --- a/prowler/providers/aws/services/ec2/ec2_launch_template_imdsv2_required/ec2_launch_template_imdsv2_required.py +++ b/prowler/providers/aws/services/ec2/ec2_launch_template_imdsv2_required/ec2_launch_template_imdsv2_required.py @@ -18,7 +18,10 @@ class ec2_launch_template_imdsv2_required(Check): and version.template_data.http_tokens == "required" ): versions_with_imdsv2_required.append(str(version.version_number)) - elif version.template_data.http_endpoint == "disabled": + elif ( + version.template_data.http_endpoint == "disabled" + or not version.template_data.http_endpoint + ): versions_with_metadata_disabled.append(str(version.version_number)) else: versions_with_no_imdsv2.append(str(version.version_number)) diff --git a/prowler/providers/aws/services/firehose/firehose_service.py b/prowler/providers/aws/services/firehose/firehose_service.py index d496da9c4e..0ec1c95242 100644 --- a/prowler/providers/aws/services/firehose/firehose_service.py +++ b/prowler/providers/aws/services/firehose/firehose_service.py @@ -25,18 +25,47 @@ class Firehose(AWSService): def _list_delivery_streams(self, regional_client): logger.info("Firehose - Listing delivery streams...") try: - for stream_name in regional_client.list_delivery_streams()[ - "DeliveryStreamNames" - ]: - stream_arn = f"arn:{self.audited_partition}:firehose:{regional_client.region}:{self.audited_account}:deliverystream/{stream_name}" - if not self.audit_resources or ( - is_resource_filtered(stream_arn, self.audit_resources) - ): - self.delivery_streams[stream_arn] = DeliveryStream( - arn=stream_arn, - name=stream_name, - region=regional_client.region, + # Manual pagination using ExclusiveStartDeliveryStreamName + # This ensures we get all streams alphabetically without duplicates + exclusive_start_delivery_stream_name = None + processed_streams = set() + + while True: + kwargs = {} + if exclusive_start_delivery_stream_name: + kwargs["ExclusiveStartDeliveryStreamName"] = ( + exclusive_start_delivery_stream_name ) + + response = regional_client.list_delivery_streams(**kwargs) + stream_names = response.get("DeliveryStreamNames", []) + + for stream_name in stream_names: + if stream_name in processed_streams: + continue + + processed_streams.add(stream_name) + stream_arn = f"arn:{self.audited_partition}:firehose:{regional_client.region}:{self.audited_account}:deliverystream/{stream_name}" + + if not self.audit_resources or ( + is_resource_filtered(stream_arn, self.audit_resources) + ): + self.delivery_streams[stream_arn] = DeliveryStream( + arn=stream_arn, + name=stream_name, + region=regional_client.region, + ) + + if not response.get("HasMoreDeliveryStreams", False): + break + + # Set the starting point for the next page (last stream name from current batch) + # ExclusiveStartDeliveryStreamName will start after this stream alphabetically + if stream_names: + exclusive_start_delivery_stream_name = stream_names[-1] + else: + break + except ClientError as error: logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -61,13 +90,45 @@ class Firehose(AWSService): describe_stream = self.regional_clients[ stream.region ].describe_delivery_stream(DeliveryStreamName=stream.name) + encryption_config = describe_stream.get( "DeliveryStreamDescription", {} ).get("DeliveryStreamEncryptionConfiguration", {}) + stream.kms_encryption = EncryptionStatus( encryption_config.get("Status", "DISABLED") ) stream.kms_key_arn = encryption_config.get("KeyARN", "") + + stream.delivery_stream_type = describe_stream.get( + "DeliveryStreamDescription", {} + ).get("DeliveryStreamType", "") + + source_config = describe_stream.get("DeliveryStreamDescription", {}).get( + "Source", {} + ) + stream.source = Source( + direct_put=DirectPutSourceDescription( + troughput_hint_in_mb_per_sec=source_config.get( + "DirectPutSourceDescription", {} + ).get("TroughputHintInMBPerSec", 0) + ), + kinesis_stream=KinesisStreamSourceDescription( + kinesis_stream_arn=source_config.get( + "KinesisStreamSourceDescription", {} + ).get("KinesisStreamARN", "") + ), + msk=MSKSourceDescription( + msk_cluster_arn=source_config.get("MSKSourceDescription", {}).get( + "MSKClusterARN", "" + ) + ), + database=DatabaseSourceDescription( + endpoint=source_config.get("DatabaseSourceDescription", {}).get( + "Endpoint", "" + ) + ), + ) except ClientError as error: logger.error( f"{stream.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -85,6 +146,39 @@ class EncryptionStatus(Enum): DISABLING_FAILED = "DISABLING_FAILED" +class DirectPutSourceDescription(BaseModel): + """Model for the DirectPut source of a Firehose stream""" + + troughput_hint_in_mb_per_sec: int = Field(default_factory=int) + + +class KinesisStreamSourceDescription(BaseModel): + """Model for the KinesisStream source of a Firehose stream""" + + kinesis_stream_arn: str = Field(default_factory=str) + + +class MSKSourceDescription(BaseModel): + """Model for the MSK source of a Firehose stream""" + + msk_cluster_arn: str = Field(default_factory=str) + + +class DatabaseSourceDescription(BaseModel): + """Model for the Database source of a Firehose stream""" + + endpoint: str = Field(default_factory=str) + + +class Source(BaseModel): + """Model for the source of a Firehose stream""" + + direct_put: Optional[DirectPutSourceDescription] + kinesis_stream: Optional[KinesisStreamSourceDescription] + msk: Optional[MSKSourceDescription] + database: Optional[DatabaseSourceDescription] + + class DeliveryStream(BaseModel): """Model for a Firehose Delivery Stream""" @@ -94,3 +188,5 @@ class DeliveryStream(BaseModel): kms_key_arn: Optional[str] = Field(default_factory=str) kms_encryption: Optional[str] = Field(default_factory=str) tags: Optional[List[Dict[str, str]]] = Field(default_factory=list) + delivery_stream_type: Optional[str] = Field(default_factory=str) + source: Source = Field(default_factory=Source) diff --git a/prowler/providers/aws/services/firehose/firehose_stream_encrypted_at_rest/firehose_stream_encrypted_at_rest.py b/prowler/providers/aws/services/firehose/firehose_stream_encrypted_at_rest/firehose_stream_encrypted_at_rest.py index 8154a84b66..417d893260 100644 --- a/prowler/providers/aws/services/firehose/firehose_stream_encrypted_at_rest/firehose_stream_encrypted_at_rest.py +++ b/prowler/providers/aws/services/firehose/firehose_stream_encrypted_at_rest/firehose_stream_encrypted_at_rest.py @@ -3,6 +3,8 @@ from typing import List from prowler.lib.check.models import Check, Check_Report_AWS from prowler.providers.aws.services.firehose.firehose_client import firehose_client from prowler.providers.aws.services.firehose.firehose_service import EncryptionStatus +from prowler.providers.aws.services.kinesis.kinesis_client import kinesis_client +from prowler.providers.aws.services.kinesis.kinesis_service import EncryptionType class firehose_stream_encrypted_at_rest(Check): @@ -22,14 +24,22 @@ class firehose_stream_encrypted_at_rest(Check): findings = [] for stream in firehose_client.delivery_streams.values(): report = Check_Report_AWS(metadata=self.metadata(), resource=stream) - report.status = "PASS" - report.status_extended = ( - f"Firehose Stream {stream.name} does have at rest encryption enabled." - ) + report.status = "FAIL" + report.status_extended = f"Firehose Stream {stream.name} does not have at rest encryption enabled or the source stream is not encrypted." - if stream.kms_encryption != EncryptionStatus.ENABLED: - report.status = "FAIL" - report.status_extended = f"Firehose Stream {stream.name} does not have at rest encryption enabled." + # Encrypted Kinesis Stream source + if stream.delivery_stream_type == "KinesisStreamAsSource": + source_stream = kinesis_client.streams.get( + stream.source.kinesis_stream.kinesis_stream_arn + ) + if source_stream.encrypted_at_rest != EncryptionType.NONE: + report.status = "PASS" + report.status_extended = f"Firehose Stream {stream.name} does not have at rest encryption enabled but the source stream {source_stream.name} has at rest encryption enabled." + + # Check if the stream has encryption enabled directly + elif stream.kms_encryption == EncryptionStatus.ENABLED: + report.status = "PASS" + report.status_extended = f"Firehose Stream {stream.name} does have at rest encryption enabled." findings.append(report) diff --git a/prowler/providers/azure/services/recovery/recovery_client.py b/prowler/providers/azure/services/recovery/recovery_client.py new file mode 100644 index 0000000000..5c17199237 --- /dev/null +++ b/prowler/providers/azure/services/recovery/recovery_client.py @@ -0,0 +1,4 @@ +from prowler.providers.azure.services.recovery.recovery_service import Recovery +from prowler.providers.common.provider import Provider + +recovery_client = Recovery(Provider.get_global_provider()) diff --git a/prowler/providers/azure/services/recovery/recovery_service.py b/prowler/providers/azure/services/recovery/recovery_service.py new file mode 100644 index 0000000000..e39a6fc1f7 --- /dev/null +++ b/prowler/providers/azure/services/recovery/recovery_service.py @@ -0,0 +1,101 @@ +from typing import Optional + +from azure.mgmt.recoveryservices import RecoveryServicesClient +from azure.mgmt.recoveryservicesbackup import RecoveryServicesBackupClient +from azure.mgmt.recoveryservicesbackup.activestamp.models import DataSourceType +from pydantic import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.providers.azure.azure_provider import AzureProvider +from prowler.providers.azure.lib.service.service import AzureService + + +class BackupItem(BaseModel): + """Minimal BackupItem: only essential identifying and descriptive fields.""" + + id: str + name: str + workload_type: Optional[DataSourceType] + + +class BackupVault(BaseModel): + """Minimal BackupVault: only essential identifying fields and its backup items.""" + + id: str + name: str + location: str + backup_protected_items: dict[str, BackupItem] = Field(default_factory=dict) + + +class Recovery(AzureService): + def __init__(self, provider: AzureProvider): + super().__init__(RecoveryServicesClient, provider) + self.vaults: dict[str, dict[str, BackupVault]] = self._get_vaults() + RecoveryBackup(provider, self.vaults) + + def _get_vaults(self) -> dict[str, dict[str, BackupVault]]: + """ + Retrieve all Recovery Services vaults for each subscription. + + Returns: + Nested dictionary of vaults by subscription. + """ + logger.info("Recovery - Getting Recovery Services vaults...") + vaults_dict: dict[str, dict[str, BackupVault]] = {} + try: + vaults_dict: dict[str, dict[str, BackupVault]] = {} + for subscription_name, client in self.clients.items(): + vaults = client.vaults.list_by_subscription_id() + vaults_dict[subscription_name] = {} + for vault in vaults: + vault_obj = BackupVault( + id=vault.id, + name=vault.name, + location=vault.location, + ) + vaults_dict[subscription_name][vault_obj.id] = vault_obj + except Exception as error: + logger.error( + f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return vaults_dict + + +class RecoveryBackup(AzureService): + def __init__( + self, provider: AzureProvider, vaults: dict[str, dict[str, BackupVault]] + ): + super().__init__(RecoveryServicesBackupClient, provider) + for subscription_name, vaults in vaults.items(): + for vault in vaults.values(): + vault.backup_protected_items = self._get_backup_protected_items( + subscription_name=subscription_name, vault=vault + ) + + def _get_backup_protected_items( + self, subscription_name: str, vault: BackupVault + ) -> dict[str, BackupItem]: + """ + Retrieve all backup protected items for a given vault. + """ + logger.info("Recovery - Getting backup protected items...") + backup_protected_items_dict: dict[str, BackupItem] = {} + try: + backup_protected_items = self.clients[ + subscription_name + ].backup_protected_items.list( + vault_name=vault.name, + resource_group_name=vault.id.split("/")[4], + ) + for item in backup_protected_items: + item_properties = getattr(item, "properties", None) + backup_protected_items_dict[item.id] = BackupItem( + id=item.id, + name=item.name, + workload_type=( + item_properties.workload_type if item_properties else None + ), + ) + except Exception as e: + logger.error(f"Recovery - Error getting backup protected items: {e}") + return backup_protected_items_dict diff --git a/prowler/providers/azure/services/storage/lib/__init__.py b/prowler/providers/azure/services/storage/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/storage/lib/constants.py b/prowler/providers/azure/services/storage/lib/constants.py new file mode 100644 index 0000000000..225d7bc5e2 --- /dev/null +++ b/prowler/providers/azure/services/storage/lib/constants.py @@ -0,0 +1,3 @@ +"""Constants for the storage service.""" + +LATEST_SMB_VERSION = "SMB3.1.1" diff --git a/prowler/providers/azure/services/storage/storage_service.py b/prowler/providers/azure/services/storage/storage_service.py index 67fcc70e07..887fb80b7b 100644 --- a/prowler/providers/azure/services/storage/storage_service.py +++ b/prowler/providers/azure/services/storage/storage_service.py @@ -1,6 +1,5 @@ -from dataclasses import dataclass from enum import Enum -from typing import List, Optional +from typing import Optional from azure.mgmt.storage import StorageManagementClient from pydantic import BaseModel @@ -33,7 +32,7 @@ class Storage(AzureService): resouce_group_name = None key_expiration_period_in_days = None if storage_account.key_policy: - key_expiration_period_in_days = ( + key_expiration_period_in_days = int( storage_account.key_policy.key_expiration_period_in_days ) replication_settings = ReplicationSettings(storage_account.sku.name) @@ -173,6 +172,20 @@ class Storage(AzureService): None, ) + smb_supported_versions_raw = getattr( + getattr( + getattr( + file_service_properties, + "protocol_settings", + None, + ), + "smb", + None, + ), + "versions", + None, + ) + account.file_service_properties = FileServiceProperties( id=file_service_properties.id, name=file_service_properties.name, @@ -194,7 +207,12 @@ class Storage(AzureService): smb_channel_encryption_raw.rstrip(";").split(";") if smb_channel_encryption_raw else [] - ) + ), + supported_versions=( + smb_supported_versions_raw.rstrip(";").split(";") + if smb_supported_versions_raw + else [] + ), ), ) except Exception as error: @@ -203,30 +221,26 @@ class Storage(AzureService): ) -@dataclass -class DeleteRetentionPolicy: +class DeleteRetentionPolicy(BaseModel): enabled: bool days: int -@dataclass -class BlobProperties: +class BlobProperties(BaseModel): id: str name: str type: str - default_service_version: str container_delete_retention_policy: DeleteRetentionPolicy - versioning_enabled: bool = False + default_service_version: Optional[str] = None + versioning_enabled: Optional[bool] = None -@dataclass -class NetworkRuleSet: +class NetworkRuleSet(BaseModel): bypass: str default_action: str -@dataclass -class PrivateEndpointConnection: +class PrivateEndpointConnection(BaseModel): id: str name: str type: str @@ -245,6 +259,7 @@ class ReplicationSettings(Enum): class SMBProtocolSettings(BaseModel): channel_encryption: list[str] + supported_versions: list[str] class FileServiceProperties(BaseModel): @@ -255,20 +270,19 @@ class FileServiceProperties(BaseModel): smb_protocol_settings: SMBProtocolSettings -@dataclass -class Account: +class Account(BaseModel): id: str name: str + location: str resouce_group_name: str enable_https_traffic_only: bool - infrastructure_encryption: bool + infrastructure_encryption: Optional[bool] = None allow_blob_public_access: bool network_rule_set: NetworkRuleSet encryption_type: str minimum_tls_version: str - private_endpoint_connections: List[PrivateEndpointConnection] - key_expiration_period_in_days: str - location: str + private_endpoint_connections: list[PrivateEndpointConnection] + key_expiration_period_in_days: Optional[int] = None replication_settings: ReplicationSettings = ReplicationSettings.STANDARD_LRS allow_cross_tenant_replication: bool = True allow_shared_key_access: bool = True diff --git a/prowler/providers/azure/services/storage/storage_smb_protocol_version_is_latest/__init__.py b/prowler/providers/azure/services/storage/storage_smb_protocol_version_is_latest/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest.metadata.json b/prowler/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest.metadata.json new file mode 100644 index 0000000000..0dea47ba46 --- /dev/null +++ b/prowler/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "storage_smb_protocol_version_is_latest", + "CheckTitle": "Ensure SMB protocol version for file shares is set to the latest version.", + "CheckType": [], + "ServiceName": "storage", + "SubServiceName": "", + "ResourceIdTemplate": "/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.Storage/storageAccounts/{storageAccountName}/fileServices/default", + "Severity": "medium", + "ResourceType": "AzureStorageAccount", + "Description": "Ensure that SMB file shares are configured to use only the latest SMB protocol version.", + "Risk": "Allowing older SMB protocol versions may expose file shares to known vulnerabilities and security risks.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/storage/files/files-smb-protocol#smb-security-settings", + "Remediation": { + "Code": { + "CLI": "az storage account file-service-properties update --resource-group --account-name --versions ", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Configure your Azure Storage Account file shares to allow only the latest SMB protocol version.", + "Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/StorageAccounts/latest-smb-protocol-version.html" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest.py b/prowler/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest.py new file mode 100644 index 0000000000..19f2d37765 --- /dev/null +++ b/prowler/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest.py @@ -0,0 +1,48 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.storage.lib.constants import LATEST_SMB_VERSION +from prowler.providers.azure.services.storage.storage_client import storage_client + + +class storage_smb_protocol_version_is_latest(Check): + """ + Ensure SMB protocol version for file shares is set to the latest version. + + This check evaluates whether SMB file shares are configured to use only the latest SMB protocol version. + - PASS: Storage account allows only the latest SMB protocol version for file shares. + - FAIL: Storage account allows other SMB protocol versions for file shares. + """ + + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + for subscription, storage_accounts in storage_client.storage_accounts.items(): + for account in storage_accounts: + if getattr(account, "file_service_properties", None) and getattr( + account.file_service_properties.smb_protocol_settings, + "supported_versions", + None, + ): + report = Check_Report_Azure( + metadata=self.metadata(), + resource=account.file_service_properties, + ) + report.subscription = subscription + report.resource_name = account.name + report.location = account.location + if ( + len( + account.file_service_properties.smb_protocol_settings.supported_versions + ) + == 1 + and account.file_service_properties.smb_protocol_settings.supported_versions[ + 0 + ] + == LATEST_SMB_VERSION + ): + report.status = "PASS" + report.status_extended = f"Storage account {account.name} from subscription {subscription} allows only the latest SMB protocol version ({LATEST_SMB_VERSION}) for file shares." + else: + report.status = "FAIL" + report.status_extended = f"Storage account {account.name} from subscription {subscription} allows SMB protocol versions: {', '.join(account.file_service_properties.smb_protocol_settings.supported_versions) if account.file_service_properties.smb_protocol_settings.supported_versions else 'None'}. Only the latest SMB protocol version ({LATEST_SMB_VERSION}) should be allowed." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/vm/vm_backup_enabled/__init__.py b/prowler/providers/azure/services/vm/vm_backup_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.metadata.json b/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.metadata.json new file mode 100644 index 0000000000..331609596f --- /dev/null +++ b/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "vm_backup_enabled", + "CheckTitle": "Ensure Backups are enabled for Azure Virtual Machines", + "CheckType": [], + "ServiceName": "vm", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Microsoft.Compute/virtualMachines", + "Description": "Ensure that Microsoft Azure Backup service is in use for your Azure virtual machines (VMs) to protect against accidental deletion or corruption.", + "Risk": "Without Azure Backup enabled, VMs are at risk of data loss due to accidental deletion, corruption, or other failures, and recovery options are limited.", + "RelatedUrl": "https://docs.microsoft.com/en-us/azure/backup/backup-overview", + "Remediation": { + "Code": { + "CLI": "az backup protection enable-for-vm --resource-group --vm --vault-name --policy-name DefaultPolicy", + "NativeIaC": "", + "Other": "https://learn.microsoft.com/en-us/azure/backup/quick-backup-vm-portal", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable Azure Backup for each VM by associating it with a Recovery Services vault and a backup policy.", + "Url": "https://docs.microsoft.com/en-us/azure/backup/quick-backup-vm-portal" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.py b/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.py new file mode 100644 index 0000000000..e9da4662ed --- /dev/null +++ b/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.py @@ -0,0 +1,50 @@ +from azure.mgmt.recoveryservicesbackup.activestamp.models import DataSourceType + +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.recovery.recovery_client import recovery_client +from prowler.providers.azure.services.vm.vm_client import vm_client + + +class vm_backup_enabled(Check): + """ + Ensure that Microsoft Azure Backup service is in use for your Azure virtual machines (VMs). + + This check evaluates whether each Azure VM in the subscription is protected by Azure Backup. + + - PASS: The VM is protected by Azure Backup (present in a Recovery Services vault). + - FAIL: The VM is not protected by Azure Backup (not present in any Recovery Services vault). + """ + + def execute(self) -> list[Check_Report_Azure]: + """Execute Azure VM backup enabled check. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for subscription_name, vms in vm_client.virtual_machines.items(): + vaults = recovery_client.vaults.get(subscription_name, {}) + for vm in vms.values(): + found = False + found_vault_name = None + for vault in vaults.values(): + for backup_item in vault.backup_protected_items.values(): + if ( + backup_item.workload_type == DataSourceType.VM + and backup_item.name.split(";")[-1] == vm.resource_name + ): + found = True + found_vault_name = vault.name + break + if found: + break + report = Check_Report_Azure(metadata=self.metadata(), resource=vm) + report.subscription = subscription_name + if found: + report.status = "PASS" + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} is protected by Azure Backup (vault: {found_vault_name})." + else: + report.status = "FAIL" + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} is not protected by Azure Backup." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/vm/vm_ensure_using_approved_images/__init__.py b/prowler/providers/azure/services/vm/vm_ensure_using_approved_images/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images.metadata.json b/prowler/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images.metadata.json new file mode 100644 index 0000000000..c1b8605612 --- /dev/null +++ b/prowler/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "vm_ensure_using_approved_images", + "CheckTitle": "Ensure that Azure VMs are using an approved machine image.", + "CheckType": [], + "ServiceName": "vm", + "SubServiceName": "image", + "ResourceIdTemplate": "/subscriptions//resourceGroups//providers/Microsoft.Compute/images/", + "Severity": "medium", + "ResourceType": "Microsoft.Compute/images", + "Description": "Ensure that all your Azure virtual machine instances are launched from approved machine images only.", + "Risk": "An approved machine image is a custom virtual machine (VM) image that contains a pre-configured OS and a well-defined stack of server software approved by Azure, fully configured to run your application. Using approved (golden) machine images to launch new VM instances within your Azure cloud environment brings major benefits such as fast and stable application deployment and scaling, secure application stack upgrades, and versioning.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/virtual-machines/windows/create-vm-generalized-managed", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/approved-machine-image.html", + "Terraform": "" + }, + "Recommendation": { + "Text": "Re-create the required VM instances using the approved (golden) machine image.", + "Url": "https://docs.microsoft.com/en-us/azure/virtual-machines/windows/create-vm-generalized-managed" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "This check only validates if the VM was launched from a custom image. It does not validate the image content or security baseline." +} diff --git a/prowler/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images.py b/prowler/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images.py new file mode 100644 index 0000000000..4f6c378777 --- /dev/null +++ b/prowler/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images.py @@ -0,0 +1,33 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.vm.vm_client import vm_client + + +class vm_ensure_using_approved_images(Check): + """ + Ensure that Azure VMs are using an approved (custom) machine image. + + This check evaluates whether Azure Virtual Machines are launched from an approved (custom) machine image by checking the image reference ID format. + + - PASS: The Azure VM is using an approved custom machine image. + - FAIL: The Azure VM is not using an approved custom machine image. + """ + + def execute(self): + findings = [] + for subscription_name, vms in vm_client.virtual_machines.items(): + for vm in vms.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=vm) + report.subscription = subscription_name + image_id = getattr(vm, "image_reference", None) + if ( + image_id + and image_id.startswith("/subscriptions/") + and "/providers/Microsoft.Compute/images/" in image_id + ): + report.status = "PASS" + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} is using an approved machine image: {image_id.split('/')[-1]}." + else: + report.status = "FAIL" + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} is not using an approved machine image." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/__init__.py b/prowler/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication.metadata.json b/prowler/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication.metadata.json new file mode 100644 index 0000000000..3067ca5736 --- /dev/null +++ b/prowler/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "vm_linux_enforce_ssh_authentication", + "CheckTitle": "Ensure SSH key authentication is enforced on Linux-based Virtual Machines", + "CheckType": [], + "ServiceName": "vm", + "SubServiceName": "", + "ResourceIdTemplate": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}", + "Severity": "high", + "ResourceType": "Microsoft.Compute/virtualMachines", + "Description": "Ensure that Azure Linux-based virtual machines are configured to use SSH keys by disabling password authentication.", + "Risk": "Allowing password-based SSH authentication increases the risk of brute-force attacks and unauthorized access. Enforcing SSH key authentication ensures only users with the private key can access the VM.", + "RelatedUrl": "https://docs.microsoft.com/en-us/azure/virtual-machines/linux/create-ssh-keys-detailed", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/ssh-authentication-type.html", + "Terraform": "" + }, + "Recommendation": { + "Text": "Recreate Linux VMs with SSH key authentication enabled and password authentication disabled.", + "Url": "https://docs.microsoft.com/en-us/azure/virtual-machines/linux/create-ssh-keys-detailed" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication.py b/prowler/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication.py new file mode 100644 index 0000000000..c31b2c9a18 --- /dev/null +++ b/prowler/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication.py @@ -0,0 +1,29 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.vm.vm_client import vm_client + + +class vm_linux_enforce_ssh_authentication(Check): + """ + Ensure that Azure Linux-based virtual machines are configured to use SSH keys (password authentication is disabled). + + This check evaluates whether disablePasswordAuthentication is set to True for Linux VMs to ensure only SSH key authentication is allowed. + - PASS: VM has password authentication disabled (SSH key authentication enforced). + - FAIL: VM has password authentication enabled (password-based SSH allowed). + """ + + def execute(self) -> list[Check_Report_Azure]: + findings = [] + for subscription_name, vms in vm_client.virtual_machines.items(): + for vm in vms.values(): + if vm.linux_configuration: + report = Check_Report_Azure(metadata=self.metadata(), resource=vm) + report.subscription = subscription_name + + if vm.linux_configuration.disable_password_authentication: + report.status = "PASS" + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} has password authentication disabled (SSH key authentication enforced)." + else: + report.status = "FAIL" + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} has password authentication enabled (password-based SSH allowed)." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/__init__.py b/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.metadata.json b/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.metadata.json new file mode 100644 index 0000000000..d73e44fd10 --- /dev/null +++ b/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "vm_scaleset_associated_with_load_balancer", + "CheckTitle": "VM Scale Set Is Associated With Load Balancer", + "CheckType": [], + "ServiceName": "vm", + "SubServiceName": "scaleset", + "ResourceIdTemplate": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachineScaleSets/{vmScaleSetName}", + "Severity": "medium", + "ResourceType": "Microsoft.Compute/virtualMachineScaleSets", + "Description": "Ensure that your Azure virtual machine scale sets are using load balancers for traffic distribution.", + "Risk": "Without load balancer integration, Azure virtual machine scale sets may experience reduced availability and potential service disruptions during traffic spikes or instance failures, leading to degraded user experience and potential business impact.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/virtual-network/network-overview", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/associated-load-balancers.html", + "Terraform": "" + }, + "Recommendation": { + "Text": "Attach a load balancer to your Azure virtual machine scale set to ensure high availability and optimal traffic distribution.", + "Url": "https://docs.microsoft.com/en-us/azure/load-balancer/load-balancer-overview" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.py b/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.py new file mode 100644 index 0000000000..c6150f1d7f --- /dev/null +++ b/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.vm.vm_client import vm_client + + +class vm_scaleset_associated_with_load_balancer(Check): + """ + Ensure that Azure virtual machine scale sets are associated with a load balancer backend pool. + + This check evaluates whether each VM scale set is associated with at least one load balancer backend pool. + - PASS: The scale set is associated with a load balancer backend pool. + - FAIL: The scale set is not associated with any load balancer backend pool. + """ + + def execute(self): + findings = [] + for subscription, scale_sets in vm_client.vm_scale_sets.items(): + for scale_set in scale_sets.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=scale_set + ) + report.subscription = subscription + report.resource_id = scale_set.resource_id + report.resource_name = scale_set.resource_name + report.location = scale_set.location + if scale_set.load_balancer_backend_pools: + report.status = "PASS" + backend_pool_names = [ + pool.split("/")[-1] + for pool in scale_set.load_balancer_backend_pools + ] + report.status_extended = f"Scale set '{scale_set.resource_name}' in subscription '{subscription}' is associated with load balancer backend pool(s): {', '.join(backend_pool_names)}." + else: + report.status = "FAIL" + report.status_extended = f"Scale set '{scale_set.resource_name}' in subscription '{subscription}' is not associated with any load balancer backend pool." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/vm/vm_service.py b/prowler/providers/azure/services/vm/vm_service.py index a30379d9bc..ea7d252acc 100644 --- a/prowler/providers/azure/services/vm/vm_service.py +++ b/prowler/providers/azure/services/vm/vm_service.py @@ -15,6 +15,7 @@ class VirtualMachines(AzureService): super().__init__(ComputeManagementClient, provider) self.virtual_machines = self._get_virtual_machines() self.disks = self._get_disks() + self.vm_scale_sets = self._get_vm_scale_sets() def _get_virtual_machines(self): logger.info("VirtualMachines - Getting virtual machines...") @@ -62,6 +63,18 @@ class VirtualMachines(AzureService): if extension ] + # Collect LinuxConfiguration.disablePasswordAuthentication if available + linux_configuration = None + os_profile = getattr(vm, "os_profile", None) + if os_profile: + linux_conf = getattr(os_profile, "linux_configuration", None) + if linux_conf: + linux_configuration = LinuxConfiguration( + disable_password_authentication=getattr( + linux_conf, "disable_password_authentication", False + ) + ) + virtual_machines[subscription_name].update( { vm.id: VirtualMachine( @@ -92,6 +105,12 @@ class VirtualMachines(AzureService): location=vm.location, security_profile=getattr(vm, "security_profile", None), extensions=extensions, + image_reference=getattr( + getattr(storage_profile, "image_reference", None), + "id", + None, + ), + linux_configuration=linux_configuration, ) } ) @@ -137,6 +156,69 @@ class VirtualMachines(AzureService): return disks + def _get_vm_scale_sets(self) -> dict[str, dict]: + """ + Get all needed information about VM scale sets. + + Returns: + A nested dictionary with the following structure: + { + "subscription_name": { + "vm_scale_set_id": VirtualMachineScaleSet() + } + } + """ + logger.info( + "VirtualMachines - Getting VM scale sets and their load balancer associations..." + ) + vm_scale_sets = {} + for subscription_name, client in self.clients.items(): + try: + scale_sets = client.virtual_machine_scale_sets.list_all() + vm_scale_sets[subscription_name] = {} + for scale_set in scale_sets: + backend_pools = [] + nic_configs = [] + virtual_machine_profile = getattr( + scale_set, "virtual_machine_profile", None + ) + if virtual_machine_profile: + network_profile = getattr( + virtual_machine_profile, "network_profile", None + ) + if network_profile: + nic_configs = ( + getattr( + network_profile, + "network_interface_configurations", + [], + ) + or [] + ) + for nic in nic_configs: + ip_confs = getattr(nic, "ip_configurations", []) + for ipconf in ip_confs: + pools = getattr( + ipconf, "load_balancer_backend_address_pools", [] + ) + if pools: + for pool in pools: + if getattr(pool, "id", None): + backend_pools.append(pool.id) + vm_scale_sets[subscription_name][scale_set.id] = ( + VirtualMachineScaleSet( + resource_id=scale_set.id, + resource_name=scale_set.name, + location=scale_set.location, + load_balancer_backend_pools=backend_pools, + ) + ) + except Exception as error: + logger.error( + f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return vm_scale_sets + @dataclass class UefiSettings: @@ -180,6 +262,10 @@ class VirtualMachineExtension(BaseModel): id: str +class LinuxConfiguration(BaseModel): + disable_password_authentication: bool + + class VirtualMachine(BaseModel): resource_id: str resource_name: str @@ -187,6 +273,8 @@ class VirtualMachine(BaseModel): security_profile: Optional[SecurityProfile] extensions: list[VirtualMachineExtension] storage_profile: Optional[StorageProfile] = None + image_reference: Optional[str] = None + linux_configuration: Optional[LinuxConfiguration] = None class Disk(BaseModel): @@ -195,3 +283,10 @@ class Disk(BaseModel): vms_attached: list[str] encryption_type: str location: str + + +class VirtualMachineScaleSet(BaseModel): + resource_id: str + resource_name: str + location: str + load_balancer_backend_pools: list[str] diff --git a/prowler/providers/github/lib/arguments/arguments.py b/prowler/providers/github/lib/arguments/arguments.py index 6e7598c8d7..530be5b012 100644 --- a/prowler/providers/github/lib/arguments/arguments.py +++ b/prowler/providers/github/lib/arguments/arguments.py @@ -10,6 +10,7 @@ def init_parser(self): nargs="?", help="Personal Access Token to log in against GitHub", default=None, + metavar="GITHUB_PERSONAL_ACCESS_TOKEN", ) github_auth_subparser.add_argument( @@ -17,6 +18,7 @@ def init_parser(self): nargs="?", help="OAuth App Token to log in against GitHub", default=None, + metavar="GITHUB_OAUTH_APP_TOKEN", ) # GitHub App Authentication @@ -25,10 +27,12 @@ def init_parser(self): nargs="?", help="GitHub App ID to log in against GitHub", default=None, + metavar="GITHUB_APP_ID", ) github_auth_subparser.add_argument( "--github-app-key", nargs="?", help="GitHub App Key Path to log in against GitHub", default=None, + metavar="GITHUB_APP_KEY", ) diff --git a/pyproject.toml b/pyproject.toml index 5d9d90c879..0bc29b065c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,8 @@ dependencies = [ "azure-mgmt-monitor==6.0.2", "azure-mgmt-network==28.1.0", "azure-mgmt-rdbms==10.1.0", + "azure-mgmt-recoveryservices==3.1.0", + "azure-mgmt-recoveryservicesbackup==9.2.0", "azure-mgmt-resource==23.3.0", "azure-mgmt-search==9.1.0", "azure-mgmt-security==7.0.0", @@ -38,8 +40,8 @@ dependencies = [ "botocore==1.35.99", "colorama==0.4.6", "cryptography==44.0.1", - "dash==2.18.2", - "dash-bootstrap-components==1.6.0", + "dash==3.1.1", + "dash-bootstrap-components==2.0.3", "detect-secrets==1.5.0", "google-api-python-client==2.163.0", "google-auth-httplib2>=0.1,<0.3", diff --git a/tests/providers/aws/services/ec2/ec2_launch_template_imdsv2_required/ec2_launch_template_imdsv2_required_test.py b/tests/providers/aws/services/ec2/ec2_launch_template_imdsv2_required/ec2_launch_template_imdsv2_required_test.py index 22ff04f349..fd1a5924ec 100644 --- a/tests/providers/aws/services/ec2/ec2_launch_template_imdsv2_required/ec2_launch_template_imdsv2_required_test.py +++ b/tests/providers/aws/services/ec2/ec2_launch_template_imdsv2_required/ec2_launch_template_imdsv2_required_test.py @@ -27,6 +27,24 @@ def mock_make_api_call(self, operation_name, kwarg): return make_api_call(self, operation_name, kwarg) +def mock_make_api_call_empty(self, operation_name, kwarg): + if operation_name == "DescribeLaunchTemplateVersions": + return { + "LaunchTemplateVersions": [ + { + "VersionNumber": 1, + "LaunchTemplateData": { + "MetadataOptions": { + "HttpEndpoint": "", + "HttpTokens": "required", + } + }, + } + ] + } + return make_api_call(self, operation_name, kwarg) + + def mock_make_api_call_not_required(self, operation_name, kwarg): if operation_name == "DescribeLaunchTemplateVersions": return { @@ -134,6 +152,66 @@ class Test_ec2_launch_template_imdsv2_required: ) assert result[0].resource_tags == [] + @mock_aws + def test_launch_template_imdsv2_required_empty(self): + with mock.patch( + "botocore.client.BaseClient._make_api_call", + new=mock_make_api_call_empty, + ): + ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1) + launch_template_name = "test-imdsv2-required-empty" + ec2_client.create_launch_template( + LaunchTemplateName=launch_template_name, + VersionDescription="Launch Template with IMDSv2 required", + LaunchTemplateData={ + "InstanceType": "t1.micro", + "MetadataOptions": { + "HttpEndpoint": "", + "HttpTokens": "required", + }, + }, + ) + + launch_template_id = ec2_client.describe_launch_templates( + LaunchTemplateNames=[launch_template_name] + )["LaunchTemplates"][0]["LaunchTemplateId"] + + from prowler.providers.aws.services.ec2.ec2_service import EC2 + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ec2.ec2_launch_template_imdsv2_required.ec2_launch_template_imdsv2_required.ec2_client", + new=EC2(aws_provider), + ), + ): + # Test Check + from prowler.providers.aws.services.ec2.ec2_launch_template_imdsv2_required.ec2_launch_template_imdsv2_required import ( + ec2_launch_template_imdsv2_required, + ) + + check = ec2_launch_template_imdsv2_required() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"EC2 Launch Template {launch_template_name} has metadata service disabled in the following versions: 1." + ) + assert result[0].resource_id == launch_template_id + assert result[0].region == AWS_REGION_US_EAST_1 + assert ( + result[0].resource_arn + == f"arn:aws:ec2:{AWS_REGION_US_EAST_1}:123456789012:launch-template/{launch_template_id}" + ) + assert result[0].resource_tags == [] + @mock_aws def test_launch_template_imdsv2_not_required(self): with mock.patch( diff --git a/tests/providers/aws/services/firehose/firehose_service_test.py b/tests/providers/aws/services/firehose/firehose_service_test.py index 9d0d72cfbd..340cfd3173 100644 --- a/tests/providers/aws/services/firehose/firehose_service_test.py +++ b/tests/providers/aws/services/firehose/firehose_service_test.py @@ -2,8 +2,13 @@ from boto3 import client from moto import mock_aws from prowler.providers.aws.services.firehose.firehose_service import ( + DatabaseSourceDescription, + DirectPutSourceDescription, EncryptionStatus, Firehose, + KinesisStreamSourceDescription, + MSKSourceDescription, + Source, ) from tests.providers.aws.utils import ( AWS_ACCOUNT_NUMBER, @@ -152,3 +157,102 @@ class Test_Firehose_Service: firehose.delivery_streams[arn].kms_key_arn == f"arn:aws:kms:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:key/test-kms-key-id" ) + + @mock_aws + def test_describe_delivery_stream_source_direct_put(self): + # Generate S3 client + s3_client = client("s3", region_name=AWS_REGION_EU_WEST_1) + s3_client.create_bucket( + Bucket="test-bucket", + CreateBucketConfiguration={"LocationConstraint": AWS_REGION_EU_WEST_1}, + ) + + # Generate Firehose client + firehose_client = client("firehose", region_name=AWS_REGION_EU_WEST_1) + delivery_stream = firehose_client.create_delivery_stream( + DeliveryStreamName="test-delivery-stream", + DeliveryStreamType="DirectPut", + S3DestinationConfiguration={ + "RoleARN": "arn:aws:iam::012345678901:role/firehose-role", + "BucketARN": "arn:aws:s3:::test-bucket", + "Prefix": "", + "BufferingHints": {"IntervalInSeconds": 300, "SizeInMBs": 5}, + "CompressionFormat": "UNCOMPRESSED", + }, + Tags=[{"Key": "key", "Value": "value"}], + ) + arn = delivery_stream["DeliveryStreamARN"] + + # Firehose Client for this test class + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + firehose = Firehose(aws_provider) + + assert len(firehose.delivery_streams) == 1 + assert firehose.delivery_streams[arn].delivery_stream_type == "DirectPut" + + # Test Source structure + assert isinstance(firehose.delivery_streams[arn].source, Source) + assert isinstance( + firehose.delivery_streams[arn].source.direct_put, DirectPutSourceDescription + ) + assert isinstance( + firehose.delivery_streams[arn].source.kinesis_stream, + KinesisStreamSourceDescription, + ) + assert isinstance( + firehose.delivery_streams[arn].source.msk, MSKSourceDescription + ) + assert isinstance( + firehose.delivery_streams[arn].source.database, DatabaseSourceDescription + ) + + @mock_aws + def test_describe_delivery_stream_source_kinesis_stream(self): + # Generate Kinesis client + kinesis_client = client("kinesis", region_name=AWS_REGION_EU_WEST_1) + kinesis_client.create_stream( + StreamName="test-kinesis-stream", + ShardCount=1, + ) + kinesis_stream_arn = f"arn:aws:kinesis:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:stream/test-kinesis-stream" + + # Generate Firehose client + firehose_client = client("firehose", region_name=AWS_REGION_EU_WEST_1) + delivery_stream = firehose_client.create_delivery_stream( + DeliveryStreamName="test-delivery-stream", + DeliveryStreamType="KinesisStreamAsSource", + KinesisStreamSourceConfiguration={ + "KinesisStreamARN": kinesis_stream_arn, + "RoleARN": "arn:aws:iam::012345678901:role/firehose-role", + }, + S3DestinationConfiguration={ + "RoleARN": "arn:aws:iam::012345678901:role/firehose-role", + "BucketARN": "arn:aws:s3:::test-bucket", + "Prefix": "", + "BufferingHints": {"IntervalInSeconds": 300, "SizeInMBs": 5}, + "CompressionFormat": "UNCOMPRESSED", + }, + Tags=[{"Key": "key", "Value": "value"}], + ) + arn = delivery_stream["DeliveryStreamARN"] + + # Firehose Client for this test class + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + firehose = Firehose(aws_provider) + + assert len(firehose.delivery_streams) == 1 + assert ( + firehose.delivery_streams[arn].delivery_stream_type + == "KinesisStreamAsSource" + ) + + # Test Source structure + assert isinstance(firehose.delivery_streams[arn].source, Source) + assert isinstance( + firehose.delivery_streams[arn].source.kinesis_stream, + KinesisStreamSourceDescription, + ) + assert ( + firehose.delivery_streams[arn].source.kinesis_stream.kinesis_stream_arn + == kinesis_stream_arn + ) diff --git a/tests/providers/aws/services/firehose/firehose_stream_encrypted_at_rest/firehose_stream_encrypted_at_rest_test.py b/tests/providers/aws/services/firehose/firehose_stream_encrypted_at_rest/firehose_stream_encrypted_at_rest_test.py index da00aa8900..39ce80d031 100644 --- a/tests/providers/aws/services/firehose/firehose_stream_encrypted_at_rest/firehose_stream_encrypted_at_rest_test.py +++ b/tests/providers/aws/services/firehose/firehose_stream_encrypted_at_rest/firehose_stream_encrypted_at_rest_test.py @@ -198,7 +198,7 @@ class Test_firehose_stream_encrypted_at_rest: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Firehose Stream {stream_name} does not have at rest encryption enabled." + == f"Firehose Stream {stream_name} does not have at rest encryption enabled or the source stream is not encrypted." ) @mock_aws @@ -253,5 +253,74 @@ class Test_firehose_stream_encrypted_at_rest: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Firehose Stream {stream_name} does not have at rest encryption enabled." + == f"Firehose Stream {stream_name} does not have at rest encryption enabled or the source stream is not encrypted." ) + + @mock_aws + def test_stream_kinesis_source_encrypted(self): + # Generate Kinesis client + kinesis_client = client("kinesis", region_name=AWS_REGION_EU_WEST_1) + kinesis_client.create_stream( + StreamName="test-kinesis-stream", + ShardCount=1, + ) + kinesis_stream_arn = f"arn:aws:kinesis:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:stream/test-kinesis-stream" + + # Enable encryption on the Kinesis stream + kinesis_client.start_stream_encryption( + StreamName="test-kinesis-stream", + EncryptionType="KMS", + KeyId=f"arn:aws:kms:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:key/test-kms-key-id", + ) + + # Generate Firehose client + firehose_client = client("firehose", region_name=AWS_REGION_EU_WEST_1) + delivery_stream = firehose_client.create_delivery_stream( + DeliveryStreamName="test-delivery-stream", + DeliveryStreamType="KinesisStreamAsSource", + KinesisStreamSourceConfiguration={ + "KinesisStreamARN": kinesis_stream_arn, + "RoleARN": "arn:aws:iam::012345678901:role/firehose-role", + }, + S3DestinationConfiguration={ + "RoleARN": "arn:aws:iam::012345678901:role/firehose-role", + "BucketARN": "arn:aws:s3:::test-bucket", + "Prefix": "", + "BufferingHints": {"IntervalInSeconds": 300, "SizeInMBs": 5}, + "CompressionFormat": "UNCOMPRESSED", + }, + Tags=[{"Key": "key", "Value": "value"}], + ) + arn = delivery_stream["DeliveryStreamARN"] + stream_name = arn.split("/")[-1] + + from prowler.providers.aws.services.firehose.firehose_service import Firehose + from prowler.providers.aws.services.kinesis.kinesis_service import Kinesis + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.firehose.firehose_stream_encrypted_at_rest.firehose_stream_encrypted_at_rest.firehose_client", + new=Firehose(aws_provider), + ): + with mock.patch( + "prowler.providers.aws.services.firehose.firehose_stream_encrypted_at_rest.firehose_stream_encrypted_at_rest.kinesis_client", + new=Kinesis(aws_provider), + ): + # Test Check + from prowler.providers.aws.services.firehose.firehose_stream_encrypted_at_rest.firehose_stream_encrypted_at_rest import ( + firehose_stream_encrypted_at_rest, + ) + + check = firehose_stream_encrypted_at_rest() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Firehose Stream {stream_name} does not have at rest encryption enabled but the source stream test-kinesis-stream has at rest encryption enabled." + ) diff --git a/tests/providers/azure/services/monitor/monitor_diagnostic_settings_exists/monitor_diagnostic_settings_exists_test.py b/tests/providers/azure/services/monitor/monitor_diagnostic_settings_exists/monitor_diagnostic_settings_exists_test.py index 9aca95c44e..1faff2d267 100644 --- a/tests/providers/azure/services/monitor/monitor_diagnostic_settings_exists/monitor_diagnostic_settings_exists_test.py +++ b/tests/providers/azure/services/monitor/monitor_diagnostic_settings_exists/monitor_diagnostic_settings_exists_test.py @@ -91,6 +91,21 @@ class Test_monitor_diagnostic_settings_exists: ) from prowler.providers.azure.services.storage.storage_service import ( Account, + BlobProperties, + DeleteRetentionPolicy, + NetworkRuleSet, + ) + + # Create a valid BlobProperties instance + valid_blob_properties = BlobProperties( + id="id", + name="name", + type="type", + default_service_version="default_service_version", + container_delete_retention_policy=DeleteRetentionPolicy( + enabled=False, days=0 + ), + versioning_enabled=True, ) monitor_client.diagnostics_settings = { @@ -138,42 +153,34 @@ class Test_monitor_diagnostic_settings_exists: name="storageaccountname1", resouce_group_name="rg", enable_https_traffic_only=True, - infrastructure_encryption="Enabled", + infrastructure_encryption=True, allow_blob_public_access=True, - network_rule_set="AllowAll", + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="Microsoft.CustomerManagedKeyVault", minimum_tls_version="TLS1_2", private_endpoint_connections=[], - key_expiration_period_in_days=365, + key_expiration_period_in_days="365", location="euwest", - blob_properties=mock.MagicMock( - id="id", - name="name", - type="type", - default_service_version="default_service_version", - container_delete_retention_policy="container_delete_retention_policy", - ), + blob_properties=valid_blob_properties, ), Account( id="/subscriptions/1224a5-123a-123a-123a-1234567890ab/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/storageaccountname2", name="storageaccountname2", resouce_group_name="rg", enable_https_traffic_only=False, - infrastructure_encryption="Enabled", + infrastructure_encryption=True, allow_blob_public_access=False, - network_rule_set="AllowAll", + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="Microsoft.Storage", minimum_tls_version="TLS1_2", private_endpoint_connections=[], - key_expiration_period_in_days=365, + key_expiration_period_in_days="365", location="euwest", - blob_properties=mock.MagicMock( - id="id", - name="name", - type="type", - default_service_version="default_service_version", - container_delete_retention_policy="container_delete_retention_policy", - ), + blob_properties=valid_blob_properties, ), ] } diff --git a/tests/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_cmk_encrypted/monitor_storage_account_with_activity_logs_cmk_encrypted_test.py b/tests/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_cmk_encrypted/monitor_storage_account_with_activity_logs_cmk_encrypted_test.py index ea1a90638c..707fd11af2 100644 --- a/tests/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_cmk_encrypted/monitor_storage_account_with_activity_logs_cmk_encrypted_test.py +++ b/tests/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_cmk_encrypted/monitor_storage_account_with_activity_logs_cmk_encrypted_test.py @@ -78,6 +78,9 @@ class Test_monitor_storage_account_with_activity_logs_cmk_encrypted: ) from prowler.providers.azure.services.storage.storage_service import ( Account, + BlobProperties, + DeleteRetentionPolicy, + NetworkRuleSet, ) monitor_client.diagnostics_settings = { @@ -125,20 +128,25 @@ class Test_monitor_storage_account_with_activity_logs_cmk_encrypted: name="storageaccountname1", resouce_group_name="rg", enable_https_traffic_only=True, - infrastructure_encryption="Enabled", + infrastructure_encryption=True, # bool allow_blob_public_access=True, - network_rule_set="AllowAll", + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="Microsoft.CustomerManagedKeyVault", minimum_tls_version="TLS1_2", private_endpoint_connections=[], - key_expiration_period_in_days=365, + key_expiration_period_in_days="365", # str location="euwest", - blob_properties=mock.MagicMock( + blob_properties=BlobProperties( id="id", name="name", type="type", default_service_version="default_service_version", - container_delete_retention_policy="container_delete_retention_policy", + container_delete_retention_policy=DeleteRetentionPolicy( + enabled=True, days=7 + ), + versioning_enabled=True, ), ), Account( @@ -146,20 +154,25 @@ class Test_monitor_storage_account_with_activity_logs_cmk_encrypted: name="storageaccountname2", resouce_group_name="rg", enable_https_traffic_only=False, - infrastructure_encryption="Enabled", + infrastructure_encryption=True, # bool allow_blob_public_access=False, - network_rule_set="AllowAll", + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="Microsoft.Storage", minimum_tls_version="TLS1_2", private_endpoint_connections=[], - key_expiration_period_in_days=365, + key_expiration_period_in_days="365", # str location="euwest", - blob_properties=mock.MagicMock( + blob_properties=BlobProperties( id="id", name="name", type="type", default_service_version="default_service_version", - container_delete_retention_policy="container_delete_retention_policy", + container_delete_retention_policy=DeleteRetentionPolicy( + enabled=True, days=7 + ), + versioning_enabled=False, ), ), ] diff --git a/tests/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_is_private/monitor_storage_account_with_activity_logs_is_private_test.py b/tests/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_is_private/monitor_storage_account_with_activity_logs_is_private_test.py index 2e71cae298..debcab0321 100644 --- a/tests/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_is_private/monitor_storage_account_with_activity_logs_is_private_test.py +++ b/tests/providers/azure/services/monitor/monitor_storage_account_with_activity_logs_is_private/monitor_storage_account_with_activity_logs_is_private_test.py @@ -78,6 +78,9 @@ class Test_monitor_storage_account_with_activity_logs_is_private: ) from prowler.providers.azure.services.storage.storage_service import ( Account, + BlobProperties, + DeleteRetentionPolicy, + NetworkRuleSet, ) monitor_client.diagnostics_settings = { @@ -125,20 +128,25 @@ class Test_monitor_storage_account_with_activity_logs_is_private: name="storageaccountname1", resouce_group_name="rg", enable_https_traffic_only=True, - infrastructure_encryption="Enabled", + infrastructure_encryption=True, allow_blob_public_access=True, - network_rule_set="AllowAll", + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="Microsoft.Storage", minimum_tls_version="TLS1_2", private_endpoint_connections=[], key_expiration_period_in_days=365, location="euwest", - blob_properties=mock.MagicMock( + blob_properties=BlobProperties( id="id", name="name", type="type", default_service_version="default_service_version", - container_delete_retention_policy="container_delete_retention_policy", + container_delete_retention_policy=DeleteRetentionPolicy( + enabled=True, days=7 + ), + versioning_enabled=True, ), ), Account( @@ -146,20 +154,25 @@ class Test_monitor_storage_account_with_activity_logs_is_private: name="storageaccountname2", resouce_group_name="rg", enable_https_traffic_only=False, - infrastructure_encryption="Enabled", + infrastructure_encryption=True, allow_blob_public_access=False, - network_rule_set="AllowAll", + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="Microsoft.Storage", minimum_tls_version="TLS1_2", private_endpoint_connections=[], key_expiration_period_in_days=365, location="euwest", - blob_properties=mock.MagicMock( + blob_properties=BlobProperties( id="id", name="name", type="type", default_service_version="default_service_version", - container_delete_retention_policy="container_delete_retention_policy", + container_delete_retention_policy=DeleteRetentionPolicy( + enabled=True, days=7 + ), + versioning_enabled=False, ), ), ] diff --git a/tests/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled_test.py b/tests/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled_test.py index c235a50d32..6593e90a6c 100644 --- a/tests/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled_test.py +++ b/tests/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled_test.py @@ -1,7 +1,10 @@ from unittest import mock from uuid import uuid4 -from prowler.providers.azure.services.storage.storage_service import Account +from prowler.providers.azure.services.storage.storage_service import ( + Account, + NetworkRuleSet, +) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, @@ -40,16 +43,18 @@ class Test_storage_account_key_access_disabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, - encryption_type=None, - minimum_tls_version=None, + allow_blob_public_access=True, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="None", + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, allow_shared_key_access=True, ) ] @@ -91,16 +96,18 @@ class Test_storage_account_key_access_disabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, - encryption_type=None, - minimum_tls_version=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="None", + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, allow_shared_key_access=False, ) ] diff --git a/tests/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/storage_blob_public_access_level_is_disabled_test.py b/tests/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/storage_blob_public_access_level_is_disabled_test.py index bba18c77dd..8aaa2768d5 100644 --- a/tests/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/storage_blob_public_access_level_is_disabled_test.py +++ b/tests/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/storage_blob_public_access_level_is_disabled_test.py @@ -1,7 +1,10 @@ from unittest import mock from uuid import uuid4 -from prowler.providers.azure.services.storage.storage_service import Account +from prowler.providers.azure.services.storage.storage_service import ( + Account, + NetworkRuleSet, +) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, @@ -40,16 +43,18 @@ class Test_storage_blob_public_access_level_is_disabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="None", + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], allow_blob_public_access=True, - network_rule_set=None, - encryption_type=None, - minimum_tls_version=None, key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, ) ] } @@ -90,16 +95,18 @@ class Test_storage_blob_public_access_level_is_disabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="None", + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], allow_blob_public_access=False, - network_rule_set=None, - encryption_type=None, - minimum_tls_version=None, key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, ) ] } diff --git a/tests/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled_test.py b/tests/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled_test.py index ae0e89716a..357c63a935 100644 --- a/tests/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled_test.py @@ -45,23 +45,28 @@ class Test_storage_blob_versioning_is_enabled: new=storage_client, ), ): - from prowler.providers.azure.services.storage.storage_service import Account + from prowler.providers.azure.services.storage.storage_service import ( + Account, + NetworkRuleSet, + ) storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version=None, + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], blob_properties=storage_account_blob_properties, ) ] @@ -92,12 +97,13 @@ class Test_storage_blob_versioning_is_enabled: Account, BlobProperties, DeleteRetentionPolicy, + NetworkRuleSet, ) storage_account_blob_properties = BlobProperties( - id=None, - name=None, - type=None, + id="id", + name="name", + type="type", default_service_version=None, container_delete_retention_policy=DeleteRetentionPolicy( enabled=False, days=0 @@ -109,16 +115,18 @@ class Test_storage_blob_versioning_is_enabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version=None, + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], blob_properties=storage_account_blob_properties, ) ] @@ -158,12 +166,13 @@ class Test_storage_blob_versioning_is_enabled: Account, BlobProperties, DeleteRetentionPolicy, + NetworkRuleSet, ) storage_account_blob_properties = BlobProperties( - id=None, - name=None, - type=None, + id="id", + name="name", + type="type", default_service_version=None, container_delete_retention_policy=DeleteRetentionPolicy( enabled=False, days=0 @@ -175,16 +184,18 @@ class Test_storage_blob_versioning_is_enabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version=None, + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], blob_properties=storage_account_blob_properties, ) ] diff --git a/tests/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled_test.py b/tests/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled_test.py index 803856572a..e90665d613 100644 --- a/tests/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled_test.py +++ b/tests/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled_test.py @@ -1,7 +1,10 @@ from unittest import mock from uuid import uuid4 -from prowler.providers.azure.services.storage.storage_service import Account +from prowler.providers.azure.services.storage.storage_service import ( + Account, + NetworkRuleSet, +) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, @@ -40,16 +43,18 @@ class Test_storage_cross_tenant_replication_disabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, - encryption_type=None, - minimum_tls_version=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="None", + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, allow_cross_tenant_replication=True, ) ] @@ -91,16 +96,18 @@ class Test_storage_cross_tenant_replication_disabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, - encryption_type=None, - minimum_tls_version=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="None", + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, allow_cross_tenant_replication=False, ) ] diff --git a/tests/providers/azure/services/storage/storage_default_network_access_rule_is_denied/storage_default_network_access_rule_is_denied_test.py b/tests/providers/azure/services/storage/storage_default_network_access_rule_is_denied/storage_default_network_access_rule_is_denied_test.py index 9ed07a4644..9c667b372d 100644 --- a/tests/providers/azure/services/storage/storage_default_network_access_rule_is_denied/storage_default_network_access_rule_is_denied_test.py +++ b/tests/providers/azure/services/storage/storage_default_network_access_rule_is_denied/storage_default_network_access_rule_is_denied_test.py @@ -43,18 +43,18 @@ class Test_storage_default_network_access_rule_is_denied: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, + allow_blob_public_access=False, network_rule_set=NetworkRuleSet( - default_action="Allow", bypass="AzureServices" + bypass="AzureServices", default_action="Allow" ), - encryption_type=None, - minimum_tls_version=None, + encryption_type="None", + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], ) ] } @@ -95,18 +95,18 @@ class Test_storage_default_network_access_rule_is_denied: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, + allow_blob_public_access=False, network_rule_set=NetworkRuleSet( default_action="Deny", bypass="AzureServices" ), - encryption_type=None, - minimum_tls_version=None, + encryption_type="None", + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], ) ] } diff --git a/tests/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled_test.py b/tests/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled_test.py index 33de6038d8..99b7874250 100644 --- a/tests/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled_test.py @@ -1,7 +1,10 @@ from unittest import mock from uuid import uuid4 -from prowler.providers.azure.services.storage.storage_service import Account +from prowler.providers.azure.services.storage.storage_service import ( + Account, + NetworkRuleSet, +) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, @@ -40,16 +43,18 @@ class Test_storage_default_to_entra_authorization_enabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, allow_blob_public_access=False, - network_rule_set=None, - encryption_type=None, - minimum_tls_version=None, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="None", + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, default_to_entra_authorization=True, ) ] @@ -91,16 +96,18 @@ class Test_storage_default_to_entra_authorization_enabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, allow_blob_public_access=False, - network_rule_set=None, - encryption_type=None, - minimum_tls_version=None, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="None", + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, default_to_entra_authorization=False, ) ] diff --git a/tests/providers/azure/services/storage/storage_ensure_azure_services_are_trusted_to_access_is_enabled/storage_ensure_azure_services_are_trusted_to_access_is_enabled_test.py b/tests/providers/azure/services/storage/storage_ensure_azure_services_are_trusted_to_access_is_enabled/storage_ensure_azure_services_are_trusted_to_access_is_enabled_test.py index 7d8eb1b741..d65978c2ff 100644 --- a/tests/providers/azure/services/storage/storage_ensure_azure_services_are_trusted_to_access_is_enabled/storage_ensure_azure_services_are_trusted_to_access_is_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_ensure_azure_services_are_trusted_to_access_is_enabled/storage_ensure_azure_services_are_trusted_to_access_is_enabled_test.py @@ -43,18 +43,18 @@ class Test_storage_ensure_azure_services_are_trusted_to_access_is_enabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, + allow_blob_public_access=False, network_rule_set=NetworkRuleSet( bypass="None", default_action="Deny" ), - encryption_type=None, - minimum_tls_version=None, + encryption_type="None", + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], ) ] } @@ -95,18 +95,18 @@ class Test_storage_ensure_azure_services_are_trusted_to_access_is_enabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, + allow_blob_public_access=False, network_rule_set=NetworkRuleSet( bypass="AzureServices", default_action="Allow" ), - encryption_type=None, - minimum_tls_version=None, + encryption_type="None", + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], ) ] } diff --git a/tests/providers/azure/services/storage/storage_ensure_encryption_with_customer_managed_keys/storage_ensure_encryption_with_customer_managed_keys_test.py b/tests/providers/azure/services/storage/storage_ensure_encryption_with_customer_managed_keys/storage_ensure_encryption_with_customer_managed_keys_test.py index edec8a84a4..7f9803800c 100644 --- a/tests/providers/azure/services/storage/storage_ensure_encryption_with_customer_managed_keys/storage_ensure_encryption_with_customer_managed_keys_test.py +++ b/tests/providers/azure/services/storage/storage_ensure_encryption_with_customer_managed_keys/storage_ensure_encryption_with_customer_managed_keys_test.py @@ -1,7 +1,10 @@ from unittest import mock from uuid import uuid4 -from prowler.providers.azure.services.storage.storage_service import Account +from prowler.providers.azure.services.storage.storage_service import ( + Account, + NetworkRuleSet, +) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, @@ -40,16 +43,18 @@ class Test_storage_ensure_encryption_with_customer_managed_keys: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], encryption_type="None", - minimum_tls_version=None, key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, ) ] } @@ -90,16 +95,18 @@ class Test_storage_ensure_encryption_with_customer_managed_keys: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], encryption_type="Microsoft.Keyvault", - minimum_tls_version=None, key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, ) ] } diff --git a/tests/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled_test.py b/tests/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled_test.py index 29f64bc066..e2c97b7e2e 100644 --- a/tests/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled_test.py @@ -5,6 +5,7 @@ from prowler.providers.azure.services.storage.storage_service import ( Account, DeleteRetentionPolicy, FileServiceProperties, + NetworkRuleSet, SMBProtocolSettings, ) from tests.providers.azure.azure_fixtures import ( @@ -45,16 +46,18 @@ class Test_storage_ensure_file_shares_soft_delete_is_enabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version=None, + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], file_service_properties=None, ) ] @@ -88,23 +91,27 @@ class Test_storage_ensure_file_shares_soft_delete_is_enabled: name="default", type="Microsoft.Storage/storageAccounts/fileServices", share_delete_retention_policy=retention_policy, - smb_protocol_settings=SMBProtocolSettings(channel_encryption=[]), + smb_protocol_settings=SMBProtocolSettings( + channel_encryption=[], supported_versions=[] + ), ) storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version=None, + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], file_service_properties=file_service_properties, ) ] @@ -147,23 +154,27 @@ class Test_storage_ensure_file_shares_soft_delete_is_enabled: name="default", type="Microsoft.Storage/storageAccounts/fileServices", share_delete_retention_policy=retention_policy, - smb_protocol_settings=SMBProtocolSettings(channel_encryption=[]), + smb_protocol_settings=SMBProtocolSettings( + channel_encryption=[], supported_versions=[] + ), ) storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version=None, + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], file_service_properties=file_service_properties, ) ] diff --git a/tests/providers/azure/services/storage/storage_ensure_minimum_tls_version_12/storage_ensure_minimum_tls_version_12_test.py b/tests/providers/azure/services/storage/storage_ensure_minimum_tls_version_12/storage_ensure_minimum_tls_version_12_test.py index 0db80267ba..16ffe488bb 100644 --- a/tests/providers/azure/services/storage/storage_ensure_minimum_tls_version_12/storage_ensure_minimum_tls_version_12_test.py +++ b/tests/providers/azure/services/storage/storage_ensure_minimum_tls_version_12/storage_ensure_minimum_tls_version_12_test.py @@ -1,7 +1,10 @@ from unittest import mock from uuid import uuid4 -from prowler.providers.azure.services.storage.storage_service import Account +from prowler.providers.azure.services.storage.storage_service import ( + Account, + NetworkRuleSet, +) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, @@ -40,16 +43,18 @@ class Test_storage_ensure_minimum_tls_version_12: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", minimum_tls_version="TLS1_1", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], ) ] } @@ -90,16 +95,18 @@ class Test_storage_ensure_minimum_tls_version_12: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], ) ] } diff --git a/tests/providers/azure/services/storage/storage_ensure_private_endpoints_in_storage_accounts/storage_ensure_private_endpoints_in_storage_accounts_test.py b/tests/providers/azure/services/storage/storage_ensure_private_endpoints_in_storage_accounts/storage_ensure_private_endpoints_in_storage_accounts_test.py index 50f7dfc2d2..2652085907 100644 --- a/tests/providers/azure/services/storage/storage_ensure_private_endpoints_in_storage_accounts/storage_ensure_private_endpoints_in_storage_accounts_test.py +++ b/tests/providers/azure/services/storage/storage_ensure_private_endpoints_in_storage_accounts/storage_ensure_private_endpoints_in_storage_accounts_test.py @@ -3,6 +3,7 @@ from uuid import uuid4 from prowler.providers.azure.services.storage.storage_service import ( Account, + NetworkRuleSet, PrivateEndpointConnection, ) from tests.providers.azure.azure_fixtures import ( @@ -45,16 +46,18 @@ class Test_storage_ensure_private_endpoints_in_storage_accounts: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version=None, + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], ) ] } @@ -97,22 +100,24 @@ class Test_storage_ensure_private_endpoints_in_storage_accounts: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version=None, + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=PrivateEndpointConnection( - id=str( - uuid4(), - ), - name="Test Private Endpoint Connection", - type="Test Type", - ), + private_endpoint_connections=[ + PrivateEndpointConnection( + id="f1ef2e48-978a-4b0e-b34f-e6c34a9e0724", + name="Test Private Endpoint Connection", + type="Test Type", + ) + ], ) ] } diff --git a/tests/providers/azure/services/storage/storage_ensure_soft_delete_is_enabled/storage_ensure_soft_delete_is_enabled_test.py b/tests/providers/azure/services/storage/storage_ensure_soft_delete_is_enabled/storage_ensure_soft_delete_is_enabled_test.py index 3bac97b5b5..acb5920815 100644 --- a/tests/providers/azure/services/storage/storage_ensure_soft_delete_is_enabled/storage_ensure_soft_delete_is_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_ensure_soft_delete_is_enabled/storage_ensure_soft_delete_is_enabled_test.py @@ -5,6 +5,7 @@ from prowler.providers.azure.services.storage.storage_service import ( Account, BlobProperties, DeleteRetentionPolicy, + NetworkRuleSet, ) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, @@ -45,16 +46,18 @@ class Test_storage_ensure_soft_delete_is_enabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version=None, + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], blob_properties=storage_account_blob_properties, ) ] @@ -85,29 +88,32 @@ class Test_storage_ensure_soft_delete_is_enabled: storage_account_name = "Test Storage Account" storage_client = mock.MagicMock storage_account_blob_properties = BlobProperties( - id=None, - name=None, - type=None, + id="id", + name="name", + type="type", default_service_version=None, container_delete_retention_policy=DeleteRetentionPolicy( enabled=False, days=7 ), + versioning_enabled=False, ) storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version=None, + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], blob_properties=storage_account_blob_properties, ) ] @@ -147,29 +153,32 @@ class Test_storage_ensure_soft_delete_is_enabled: storage_account_name = "Test Storage Account" storage_client = mock.MagicMock storage_account_blob_properties = BlobProperties( - id=None, - name=None, - type=None, + id="id", + name="name", + type="type", default_service_version=None, container_delete_retention_policy=DeleteRetentionPolicy( enabled=True, days=7 ), + versioning_enabled=True, ) storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version=None, + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], blob_properties=storage_account_blob_properties, ) ] diff --git a/tests/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled_test.py b/tests/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled_test.py index ce17cb80ed..7ae8dc32eb 100644 --- a/tests/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled_test.py @@ -3,6 +3,7 @@ from uuid import uuid4 from prowler.providers.azure.services.storage.storage_service import ( Account, + NetworkRuleSet, ReplicationSettings, ) from tests.providers.azure.azure_fixtures import ( @@ -43,16 +44,18 @@ class Test_storage_geo_redundant_enabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, allow_blob_public_access=False, - network_rule_set=None, - encryption_type=None, - minimum_tls_version=None, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="None", + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, replication_settings=ReplicationSettings.STANDARD_GRS, ) ] @@ -94,16 +97,18 @@ class Test_storage_geo_redundant_enabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, allow_blob_public_access=False, - network_rule_set=None, - encryption_type=None, - minimum_tls_version=None, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="None", + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, replication_settings=ReplicationSettings.STANDARD_LRS, ) ] diff --git a/tests/providers/azure/services/storage/storage_infrastructure_encryption_is_enabled/storage_infrastructure_encryption_is_enabled_test.py b/tests/providers/azure/services/storage/storage_infrastructure_encryption_is_enabled/storage_infrastructure_encryption_is_enabled_test.py index c354a92ded..c66fe2dcfd 100644 --- a/tests/providers/azure/services/storage/storage_infrastructure_encryption_is_enabled/storage_infrastructure_encryption_is_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_infrastructure_encryption_is_enabled/storage_infrastructure_encryption_is_enabled_test.py @@ -1,7 +1,10 @@ from unittest import mock from uuid import uuid4 -from prowler.providers.azure.services.storage.storage_service import Account +from prowler.providers.azure.services.storage.storage_service import ( + Account, + NetworkRuleSet, +) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, @@ -40,16 +43,18 @@ class Test_storage_infrastructure_encryption_is_enabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", minimum_tls_version="TLS1_1", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], ) ] } @@ -90,16 +95,18 @@ class Test_storage_infrastructure_encryption_is_enabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=True, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version="TLS1_1", + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], ) ] } diff --git a/tests/providers/azure/services/storage/storage_key_rotation_90_days/storage_key_rotation_90_days_test.py b/tests/providers/azure/services/storage/storage_key_rotation_90_days/storage_key_rotation_90_days_test.py index c2c9d03e82..480a0737dc 100644 --- a/tests/providers/azure/services/storage/storage_key_rotation_90_days/storage_key_rotation_90_days_test.py +++ b/tests/providers/azure/services/storage/storage_key_rotation_90_days/storage_key_rotation_90_days_test.py @@ -1,7 +1,10 @@ from unittest import mock from uuid import uuid4 -from prowler.providers.azure.services.storage.storage_service import Account +from prowler.providers.azure.services.storage.storage_service import ( + Account, + NetworkRuleSet, +) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, @@ -41,16 +44,18 @@ class Test_storage_key_rotation_90_dayss: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + key_expiration_period_in_days="91", encryption_type="None", - minimum_tls_version="TLS1_1", - key_expiration_period_in_days=expiration_days, + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], location="westeurope", - private_endpoint_connections=None, ) ] } @@ -92,16 +97,18 @@ class Test_storage_key_rotation_90_dayss: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + key_expiration_period_in_days=90, encryption_type="None", minimum_tls_version="TLS1_2", - key_expiration_period_in_days=expiration_days, + private_endpoint_connections=[], location="westeurope", - private_endpoint_connections=None, ) ] } @@ -142,16 +149,18 @@ class Test_storage_key_rotation_90_dayss: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + key_expiration_period_in_days=None, encryption_type="None", minimum_tls_version="TLS1_2", - key_expiration_period_in_days=None, + private_endpoint_connections=[], location="westeurope", - private_endpoint_connections=None, ) ] } diff --git a/tests/providers/azure/services/storage/storage_secure_transfer_required_is_enabled/storage_secure_transfer_required_is_enabled_test.py b/tests/providers/azure/services/storage/storage_secure_transfer_required_is_enabled/storage_secure_transfer_required_is_enabled_test.py index d7a4f469e2..cd3c8ab408 100644 --- a/tests/providers/azure/services/storage/storage_secure_transfer_required_is_enabled/storage_secure_transfer_required_is_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_secure_transfer_required_is_enabled/storage_secure_transfer_required_is_enabled_test.py @@ -1,7 +1,10 @@ from unittest import mock from uuid import uuid4 -from prowler.providers.azure.services.storage.storage_service import Account +from prowler.providers.azure.services.storage.storage_service import ( + Account, + NetworkRuleSet, +) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, @@ -40,16 +43,18 @@ class Test_storage_secure_transfer_required_is_enabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version="TLS1_1", + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], ) ] } @@ -90,16 +95,18 @@ class Test_storage_secure_transfer_required_is_enabled: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=True, infrastructure_encryption=True, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version="TLS1_1", + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], ) ] } diff --git a/tests/providers/azure/services/storage/storage_service_test.py b/tests/providers/azure/services/storage/storage_service_test.py index 7b9b5d99fe..6eb21052a2 100644 --- a/tests/providers/azure/services/storage/storage_service_test.py +++ b/tests/providers/azure/services/storage/storage_service_test.py @@ -5,6 +5,7 @@ from prowler.providers.azure.services.storage.storage_service import ( BlobProperties, DeleteRetentionPolicy, FileServiceProperties, + NetworkRuleSet, ReplicationSettings, SMBProtocolSettings, Storage, @@ -21,7 +22,7 @@ def mock_storage_get_storage_accounts(_): name="name", type="type", default_service_version=None, - container_delete_retention_policy=None, + container_delete_retention_policy=DeleteRetentionPolicy(enabled=True, days=7), ) retention_policy = DeleteRetentionPolicy(enabled=True, days=7) file_service_properties = FileServiceProperties( @@ -29,22 +30,26 @@ def mock_storage_get_storage_accounts(_): name="name", type="type", share_delete_retention_policy=retention_policy, - smb_protocol_settings=SMBProtocolSettings(channel_encryption=[]), + smb_protocol_settings=SMBProtocolSettings( + channel_encryption=[], supported_versions=[] + ), ) return { AZURE_SUBSCRIPTION_ID: [ Account( id="id", name="name", - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version=None, + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, - private_endpoint_connections=None, + private_endpoint_connections=[], location="westeurope", blob_properties=blob_properties, default_to_entra_authorization=True, @@ -79,7 +84,7 @@ class Test_Storage_Service: assert storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0].name == "name" assert ( storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0].resouce_group_name - is None + == "rg" ) assert ( storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0].enable_https_traffic_only @@ -91,10 +96,21 @@ class Test_Storage_Service: ) assert ( storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0].allow_blob_public_access - is None + is False ) assert ( - storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0].network_rule_set is None + storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0].network_rule_set + is not None + ) + assert ( + storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0].network_rule_set.bypass + == "AzureServices" + ) + assert ( + storage.storage_accounts[AZURE_SUBSCRIPTION_ID][ + 0 + ].network_rule_set.default_action + == "Allow" ) assert ( storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0].encryption_type == "None" @@ -104,7 +120,7 @@ class Test_Storage_Service: ) assert ( storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0].minimum_tls_version - is None + == "TLS1_2" ) assert ( storage.storage_accounts[AZURE_SUBSCRIPTION_ID][ @@ -116,7 +132,7 @@ class Test_Storage_Service: storage.storage_accounts[AZURE_SUBSCRIPTION_ID][ 0 ].private_endpoint_connections - is None + == [] ) assert storage.storage_accounts[AZURE_SUBSCRIPTION_ID][ 0 @@ -125,7 +141,9 @@ class Test_Storage_Service: name="name", type="type", default_service_version=None, - container_delete_retention_policy=None, + container_delete_retention_policy=DeleteRetentionPolicy( + enabled=True, days=7 + ), ) assert storage.storage_accounts[AZURE_SUBSCRIPTION_ID][ 0 @@ -175,7 +193,19 @@ class Test_Storage_Service: storage.storage_accounts[AZURE_SUBSCRIPTION_ID][ 0 ].blob_properties.container_delete_retention_policy - is None + is not None + ) + assert ( + storage.storage_accounts[AZURE_SUBSCRIPTION_ID][ + 0 + ].blob_properties.container_delete_retention_policy.enabled + is True + ) + assert ( + storage.storage_accounts[AZURE_SUBSCRIPTION_ID][ + 0 + ].blob_properties.container_delete_retention_policy.days + == 7 ) def test_get_file_service_properties(self): @@ -187,3 +217,11 @@ class Test_Storage_Service: is True ) assert account.file_service_properties.share_delete_retention_policy.days == 7 + assert ( + account.file_service_properties.smb_protocol_settings.channel_encryption + == [] + ) + assert ( + account.file_service_properties.smb_protocol_settings.supported_versions + == [] + ) diff --git a/tests/providers/azure/services/storage/storage_smb_channel_encryption_with_secure_algorithm/storage_smb_channel_encryption_with_secure_algorithm_test.py b/tests/providers/azure/services/storage/storage_smb_channel_encryption_with_secure_algorithm/storage_smb_channel_encryption_with_secure_algorithm_test.py index 9b53a0fd11..db82c09df4 100644 --- a/tests/providers/azure/services/storage/storage_smb_channel_encryption_with_secure_algorithm/storage_smb_channel_encryption_with_secure_algorithm_test.py +++ b/tests/providers/azure/services/storage/storage_smb_channel_encryption_with_secure_algorithm/storage_smb_channel_encryption_with_secure_algorithm_test.py @@ -5,6 +5,7 @@ from prowler.providers.azure.services.storage.storage_service import ( Account, DeleteRetentionPolicy, FileServiceProperties, + NetworkRuleSet, SMBProtocolSettings, ) from tests.providers.azure.azure_fixtures import ( @@ -44,16 +45,18 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version=None, + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], file_service_properties=None, ) ] @@ -84,7 +87,9 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm: name="fs1", type="type1", share_delete_retention_policy=DeleteRetentionPolicy(enabled=True, days=7), - smb_protocol_settings=SMBProtocolSettings(channel_encryption=[]), + smb_protocol_settings=SMBProtocolSettings( + channel_encryption=[], supported_versions=[] + ), ) storage_client = mock.MagicMock() storage_client.storage_accounts = { @@ -92,16 +97,18 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version=None, + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], file_service_properties=file_service_properties, ) ] @@ -137,7 +144,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm: type="type1", share_delete_retention_policy=DeleteRetentionPolicy(enabled=True, days=7), smb_protocol_settings=SMBProtocolSettings( - channel_encryption=["AES-128-GCM"] + channel_encryption=["AES-128-GCM"], supported_versions=[] ), ) storage_client = mock.MagicMock() @@ -146,16 +153,18 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version=None, + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], file_service_properties=file_service_properties, ) ] @@ -191,7 +200,7 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm: type="type1", share_delete_retention_policy=DeleteRetentionPolicy(enabled=True, days=7), smb_protocol_settings=SMBProtocolSettings( - channel_encryption=["AES-256-GCM"] + channel_encryption=["AES-256-GCM"], supported_versions=[] ), ) storage_client = mock.MagicMock() @@ -200,16 +209,18 @@ class Test_storage_smb_channel_encryption_with_secure_algorithm: Account( id=storage_account_id, name=storage_account_name, - resouce_group_name=None, + resouce_group_name="rg", enable_https_traffic_only=False, infrastructure_encryption=False, - allow_blob_public_access=None, - network_rule_set=None, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), encryption_type="None", - minimum_tls_version=None, + minimum_tls_version="TLS1_2", key_expiration_period_in_days=None, location="westeurope", - private_endpoint_connections=None, + private_endpoint_connections=[], file_service_properties=file_service_properties, ) ] diff --git a/tests/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest_test.py b/tests/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest_test.py new file mode 100644 index 0000000000..4194c7ae55 --- /dev/null +++ b/tests/providers/azure/services/storage/storage_smb_protocol_version_is_latest/storage_smb_protocol_version_is_latest_test.py @@ -0,0 +1,303 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.storage.storage_service import ( + Account, + DeleteRetentionPolicy, + FileServiceProperties, + NetworkRuleSet, + SMBProtocolSettings, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_storage_smb_protocol_version_is_latest: + def test_no_storage_accounts(self): + storage_client = mock.MagicMock() + storage_client.storage_accounts = {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_smb_protocol_version_is_latest.storage_smb_protocol_version_is_latest.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_smb_protocol_version_is_latest.storage_smb_protocol_version_is_latest import ( + storage_smb_protocol_version_is_latest, + ) + + check = storage_smb_protocol_version_is_latest() + result = check.execute() + assert len(result) == 0 + + def test_no_file_service_properties(self): + storage_account_id = str(uuid4()) + storage_account_name = "Test Storage Account" + storage_client = mock.MagicMock() + storage_client.storage_accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id=storage_account_id, + name=storage_account_name, + resouce_group_name="rg", + enable_https_traffic_only=True, + infrastructure_encryption=True, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="type", + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], + key_expiration_period_in_days=None, + location="eastus", + file_service_properties=None, + ) + ] + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_smb_protocol_version_is_latest.storage_smb_protocol_version_is_latest.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_smb_protocol_version_is_latest.storage_smb_protocol_version_is_latest import ( + storage_smb_protocol_version_is_latest, + ) + + check = storage_smb_protocol_version_is_latest() + result = check.execute() + assert len(result) == 0 + + def test_only_latest_smb_protocol_version(self): + storage_account_id = str(uuid4()) + storage_account_name = "Test Storage Account" + file_service_properties = FileServiceProperties( + id=storage_account_id, + name="default", + type="type", + share_delete_retention_policy=DeleteRetentionPolicy(enabled=True, days=7), + smb_protocol_settings=SMBProtocolSettings( + channel_encryption=[], supported_versions=["SMB3.1.1"] + ), + ) + storage_client = mock.MagicMock() + storage_client.storage_accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id=storage_account_id, + name=storage_account_name, + resouce_group_name="rg", + enable_https_traffic_only=True, + infrastructure_encryption=True, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="type", + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], + key_expiration_period_in_days=None, + location="eastus", + file_service_properties=file_service_properties, + ) + ] + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_smb_protocol_version_is_latest.storage_smb_protocol_version_is_latest.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_smb_protocol_version_is_latest.storage_smb_protocol_version_is_latest import ( + storage_smb_protocol_version_is_latest, + ) + + check = storage_smb_protocol_version_is_latest() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} allows only the latest SMB protocol version (SMB3.1.1) for file shares." + in result[0].status_extended + ) + + def test_multiple_smb_protocol_versions(self): + storage_account_id = str(uuid4()) + storage_account_name = "Test Storage Account" + file_service_properties = FileServiceProperties( + id=storage_account_id, + name="default", + type="type", + share_delete_retention_policy=DeleteRetentionPolicy(enabled=True, days=7), + smb_protocol_settings=SMBProtocolSettings( + channel_encryption=[], supported_versions=["SMB2.1", "SMB3.1.1"] + ), + ) + storage_client = mock.MagicMock() + storage_client.storage_accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id=storage_account_id, + name=storage_account_name, + resouce_group_name="rg", + enable_https_traffic_only=True, + infrastructure_encryption=True, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="type", + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], + key_expiration_period_in_days=None, + location="eastus", + file_service_properties=file_service_properties, + ) + ] + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_smb_protocol_version_is_latest.storage_smb_protocol_version_is_latest.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_smb_protocol_version_is_latest.storage_smb_protocol_version_is_latest import ( + storage_smb_protocol_version_is_latest, + ) + + check = storage_smb_protocol_version_is_latest() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} allows SMB protocol versions: SMB2.1, SMB3.1.1. Only the latest SMB protocol version (SMB3.1.1) should be allowed." + in result[0].status_extended + ) + + def test_no_smb_protocol_versions(self): + storage_account_id = str(uuid4()) + storage_account_name = "Test Storage Account" + file_service_properties = FileServiceProperties( + id=storage_account_id, + name="default", + type="type", + share_delete_retention_policy=DeleteRetentionPolicy(enabled=True, days=7), + smb_protocol_settings=SMBProtocolSettings( + channel_encryption=[], supported_versions=[] + ), + ) + storage_client = mock.MagicMock() + storage_client.storage_accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id=storage_account_id, + name=storage_account_name, + resouce_group_name="rg", + enable_https_traffic_only=True, + infrastructure_encryption=True, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="type", + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], + key_expiration_period_in_days=None, + location="eastus", + file_service_properties=file_service_properties, + ) + ] + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_smb_protocol_version_is_latest.storage_smb_protocol_version_is_latest.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_smb_protocol_version_is_latest.storage_smb_protocol_version_is_latest import ( + storage_smb_protocol_version_is_latest, + ) + + check = storage_smb_protocol_version_is_latest() + result = check.execute() + assert len(result) == 0 + + def test_multiple_required_versions_custom_config(self): + storage_account_id = str(uuid4()) + storage_account_name = "Test Storage Account" + file_service_properties = FileServiceProperties( + id=storage_account_id, + name="default", + type="type", + share_delete_retention_policy=DeleteRetentionPolicy(enabled=True, days=7), + smb_protocol_settings=SMBProtocolSettings( + channel_encryption=[], supported_versions=["SMB3.1.1", "SMB3.0"] + ), + ) + storage_client = mock.MagicMock() + storage_client.storage_accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id=storage_account_id, + name=storage_account_name, + resouce_group_name="rg", + enable_https_traffic_only=True, + infrastructure_encryption=True, + allow_blob_public_access=False, + network_rule_set=NetworkRuleSet( + bypass="AzureServices", default_action="Allow" + ), + encryption_type="type", + minimum_tls_version="TLS1_2", + private_endpoint_connections=[], + key_expiration_period_in_days=None, + location="eastus", + file_service_properties=file_service_properties, + ) + ] + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_smb_protocol_version_is_latest.storage_smb_protocol_version_is_latest.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_smb_protocol_version_is_latest.storage_smb_protocol_version_is_latest import ( + storage_smb_protocol_version_is_latest, + ) + + check = storage_smb_protocol_version_is_latest() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} allows SMB protocol versions: SMB3.1.1, SMB3.0. Only the latest SMB protocol version (SMB3.1.1) should be allowed." + in result[0].status_extended + ) diff --git a/tests/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled_test.py b/tests/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled_test.py new file mode 100644 index 0000000000..23394addca --- /dev/null +++ b/tests/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled_test.py @@ -0,0 +1,301 @@ +from unittest import mock +from uuid import uuid4 + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_vm_backup_enabled: + def test_vm_backup_enabled_no_subscriptions(self): + vm_client = mock.MagicMock + recovery_client = mock.MagicMock + + vm_client.virtual_machines = {} + recovery_client.vaults = {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.vm_client", + new=vm_client, + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled import ( + vm_backup_enabled, + ) + + check = vm_backup_enabled() + result = check.execute() + assert len(result) == 0 + + def test_no_vms(self): + mock_vm_client = mock.MagicMock() + mock_vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {}} + mock_recovery_client = mock.MagicMock() + mock_recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {}} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.vm_client", + new=mock_vm_client, + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.recovery_client", + new=mock_recovery_client, + ), + ): + from prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled import ( + vm_backup_enabled, + ) + + check = vm_backup_enabled() + result = check.execute() + assert len(result) == 0 + + def test_vm_protected_by_backup(self): + vm_id = str(uuid4()) + vm_name = "VMTest" + vault_id = str(uuid4()) + vault_name = "vault1" + mock_vm_client = mock.MagicMock() + mock_recovery_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.vm_client", + new=mock_vm_client, + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.recovery_client", + new=mock_recovery_client, + ), + ): + from azure.mgmt.recoveryservicesbackup.activestamp.models import ( + DataSourceType, + ) + + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupItem, + BackupVault, + ) + from prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled import ( + vm_backup_enabled, + ) + from prowler.providers.azure.services.vm.vm_service import ( + ManagedDiskParameters, + OSDisk, + StorageProfile, + VirtualMachine, + ) + + vm = VirtualMachine( + resource_id=vm_id, + resource_name=vm_name, + location="eastus", + security_profile=None, + extensions=[], + storage_profile=StorageProfile( + os_disk=OSDisk( + name="os_disk_name", + operating_system_type="Linux", + managed_disk=ManagedDiskParameters(id="managed_disk_id"), + ), + data_disks=[], + ), + ) + backup_item = BackupItem( + id=str(uuid4()), + name=f"someprefix;{vm_name}", + workload_type=DataSourceType.VM, + ) + vault = BackupVault( + id=vault_id, + name=vault_name, + location="eastus", + backup_protected_items={backup_item.id: backup_item}, + ) + mock_vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} + mock_recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}} + check = vm_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == vm_name + assert result[0].resource_id == vm_id + assert ( + result[0].status_extended + == f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_ID} is protected by Azure Backup (vault: {vault_name})." + ) + + def test_vm_not_protected_by_backup(self): + vm_id = str(uuid4()) + vm_name = "VMTest" + vault_id = str(uuid4()) + vault_name = "vault1" + mock_vm_client = mock.MagicMock() + mock_recovery_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.vm_client", + new=mock_vm_client, + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.recovery_client", + new=mock_recovery_client, + ), + ): + from azure.mgmt.recoveryservicesbackup.activestamp.models import ( + DataSourceType, + ) + + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupItem, + BackupVault, + ) + from prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled import ( + vm_backup_enabled, + ) + from prowler.providers.azure.services.vm.vm_service import ( + ManagedDiskParameters, + OSDisk, + StorageProfile, + VirtualMachine, + ) + + vm = VirtualMachine( + resource_id=vm_id, + resource_name=vm_name, + location="eastus", + security_profile=None, + extensions=[], + storage_profile=StorageProfile( + os_disk=OSDisk( + name="os_disk_name", + operating_system_type="Linux", + managed_disk=ManagedDiskParameters(id="managed_disk_id"), + ), + data_disks=[], + ), + ) + backup_item = BackupItem( + id=str(uuid4()), + name="someprefix;OtherVM", + workload_type=DataSourceType.VM, + ) + vault = BackupVault( + id=vault_id, + name=vault_name, + location="eastus", + backup_protected_items={backup_item.id: backup_item}, + ) + mock_vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} + mock_recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}} + check = vm_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == vm_name + assert result[0].resource_id == vm_id + assert ( + result[0].status_extended + == f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_ID} is not protected by Azure Backup." + ) + + def test_vm_protected_by_backup_non_vm_workload(self): + vm_id = str(uuid4()) + vm_name = "VMTest" + vault_id = str(uuid4()) + vault_name = "vault1" + mock_vm_client = mock.MagicMock() + mock_recovery_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.vm_client", + new=mock_vm_client, + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.recovery_client", + new=mock_recovery_client, + ), + ): + from azure.mgmt.recoveryservicesbackup.activestamp.models import ( + DataSourceType, + ) + + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupItem, + BackupVault, + ) + from prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled import ( + vm_backup_enabled, + ) + from prowler.providers.azure.services.vm.vm_service import ( + ManagedDiskParameters, + OSDisk, + StorageProfile, + VirtualMachine, + ) + + vm = VirtualMachine( + resource_id=vm_id, + resource_name=vm_name, + location="eastus", + security_profile=None, + extensions=[], + storage_profile=StorageProfile( + os_disk=OSDisk( + name="os_disk_name", + operating_system_type="Linux", + managed_disk=ManagedDiskParameters(id="managed_disk_id"), + ), + data_disks=[], + ), + ) + backup_item = BackupItem( + id=str(uuid4()), + name=f"someprefix;{vm_name}", + workload_type=DataSourceType.FILE_FOLDER, + ) + vault = BackupVault( + id=vault_id, + name=vault_name, + location="eastus", + backup_protected_items={backup_item.id: backup_item}, + ) + mock_vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} + mock_recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}} + check = vm_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == vm_name + assert result[0].resource_id == vm_id + assert ( + result[0].status_extended + == f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_ID} is not protected by Azure Backup." + ) diff --git a/tests/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images_test.py b/tests/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images_test.py new file mode 100644 index 0000000000..035ec5db3b --- /dev/null +++ b/tests/providers/azure/services/vm/vm_ensure_using_approved_images/vm_ensure_using_approved_images_test.py @@ -0,0 +1,165 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.vm.vm_service import VirtualMachine +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_vm_ensure_using_approved_images: + def test_no_subscriptions(self): + vm_client = mock.MagicMock() + vm_client.virtual_machines = {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_ensure_using_approved_images.vm_ensure_using_approved_images.vm_client", + new=vm_client, + ), + ): + from prowler.providers.azure.services.vm.vm_ensure_using_approved_images.vm_ensure_using_approved_images import ( + vm_ensure_using_approved_images, + ) + + check = vm_ensure_using_approved_images() + result = check.execute() + assert len(result) == 0 + + def test_empty_vms_in_subscription(self): + vm_client = mock.MagicMock() + vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {}} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_ensure_using_approved_images.vm_ensure_using_approved_images.vm_client", + new=vm_client, + ), + ): + from prowler.providers.azure.services.vm.vm_ensure_using_approved_images.vm_ensure_using_approved_images import ( + vm_ensure_using_approved_images, + ) + + check = vm_ensure_using_approved_images() + result = check.execute() + assert len(result) == 0 + + def test_vm_with_approved_image(self): + vm_id = str(uuid4()) + approved_image_id = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg/providers/Microsoft.Compute/images/custom-image" + vm = VirtualMachine( + resource_id=vm_id, + resource_name="VMTestApproved", + location="westeurope", + security_profile=None, + extensions=[], + storage_profile=None, + image_reference=approved_image_id, + ) + vm_client = mock.MagicMock() + vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_ensure_using_approved_images.vm_ensure_using_approved_images.vm_client", + new=vm_client, + ), + ): + from prowler.providers.azure.services.vm.vm_ensure_using_approved_images.vm_ensure_using_approved_images import ( + vm_ensure_using_approved_images, + ) + + check = vm_ensure_using_approved_images() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_name == "VMTestApproved" + assert result[0].resource_id == vm_id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + expected_status_extended = f"VM VMTestApproved in subscription {AZURE_SUBSCRIPTION_ID} is using an approved machine image: custom-image." + assert result[0].status_extended == expected_status_extended + + def test_vm_with_not_approved_image(self): + vm_id = str(uuid4()) + not_approved_image_id = "/subscriptions/other/resourceGroups/rg/providers/Microsoft.Compute/otherResource/other-image" + vm = VirtualMachine( + resource_id=vm_id, + resource_name="VMTestNotApproved", + location="westeurope", + security_profile=None, + extensions=[], + storage_profile=None, + image_reference=not_approved_image_id, + ) + vm_client = mock.MagicMock() + vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_ensure_using_approved_images.vm_ensure_using_approved_images.vm_client", + new=vm_client, + ), + ): + from prowler.providers.azure.services.vm.vm_ensure_using_approved_images.vm_ensure_using_approved_images import ( + vm_ensure_using_approved_images, + ) + + check = vm_ensure_using_approved_images() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "VMTestNotApproved" + assert result[0].resource_id == vm_id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + expected_status_extended = f"VM VMTestNotApproved in subscription {AZURE_SUBSCRIPTION_ID} is not using an approved machine image." + assert result[0].status_extended == expected_status_extended + + def test_vm_with_missing_image_reference(self): + vm_id = str(uuid4()) + vm = VirtualMachine( + resource_id=vm_id, + resource_name="VMTestNoImageRef", + location="westeurope", + security_profile=None, + extensions=[], + storage_profile=None, + image_reference=None, + ) + vm_client = mock.MagicMock() + vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_ensure_using_approved_images.vm_ensure_using_approved_images.vm_client", + new=vm_client, + ), + ): + from prowler.providers.azure.services.vm.vm_ensure_using_approved_images.vm_ensure_using_approved_images import ( + vm_ensure_using_approved_images, + ) + + check = vm_ensure_using_approved_images() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_name == "VMTestNoImageRef" + assert result[0].resource_id == vm_id + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + expected_status_extended = f"VM VMTestNoImageRef in subscription {AZURE_SUBSCRIPTION_ID} is not using an approved machine image." + assert result[0].status_extended == expected_status_extended diff --git a/tests/providers/azure/services/vm/vm_ensure_using_managed_disks/vm_ensure_using_managed_disks_test.py b/tests/providers/azure/services/vm/vm_ensure_using_managed_disks/vm_ensure_using_managed_disks_test.py index b73075337e..3c494d861c 100644 --- a/tests/providers/azure/services/vm/vm_ensure_using_managed_disks/vm_ensure_using_managed_disks_test.py +++ b/tests/providers/azure/services/vm/vm_ensure_using_managed_disks/vm_ensure_using_managed_disks_test.py @@ -86,6 +86,7 @@ class Test_vm_ensure_using_managed_disks: ), data_disks=[], ), + linux_configuration=None, ), } } @@ -142,6 +143,7 @@ class Test_vm_ensure_using_managed_disks: ), data_disks=[], ), + linux_configuration=None, ) } } @@ -200,6 +202,7 @@ class Test_vm_ensure_using_managed_disks: DataDisk(lun=0, name="data_disk_1", managed_disk=None) ], ), + linux_configuration=None, ) } } diff --git a/tests/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication_test.py b/tests/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication_test.py new file mode 100644 index 0000000000..5d400ac7bf --- /dev/null +++ b/tests/providers/azure/services/vm/vm_linux_enforce_ssh_authentication/vm_linux_enforce_ssh_authentication_test.py @@ -0,0 +1,173 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.vm.vm_service import ( + LinuxConfiguration, + VirtualMachine, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_vm_linux_enforce_ssh_authentication: + def test_no_subscriptions(self): + vm_client = mock.MagicMock + vm_client.virtual_machines = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_linux_enforce_ssh_authentication.vm_linux_enforce_ssh_authentication.vm_client", + new=vm_client, + ), + ): + from prowler.providers.azure.services.vm.vm_linux_enforce_ssh_authentication.vm_linux_enforce_ssh_authentication import ( + vm_linux_enforce_ssh_authentication, + ) + + check = vm_linux_enforce_ssh_authentication() + result = check.execute() + assert len(result) == 0 + + def test_empty_subscription(self): + vm_client = mock.MagicMock + vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {}} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_linux_enforce_ssh_authentication.vm_linux_enforce_ssh_authentication.vm_client", + new=vm_client, + ), + ): + from prowler.providers.azure.services.vm.vm_linux_enforce_ssh_authentication.vm_linux_enforce_ssh_authentication import ( + vm_linux_enforce_ssh_authentication, + ) + + check = vm_linux_enforce_ssh_authentication() + result = check.execute() + assert len(result) == 0 + + def test_linux_vm_password_auth_disabled(self): + vm_id = str(uuid4()) + vm_client = mock.MagicMock + vm_client.virtual_machines = { + AZURE_SUBSCRIPTION_ID: { + vm_id: VirtualMachine( + resource_id=vm_id, + resource_name="LinuxVM", + location="westeurope", + security_profile=None, + extensions=[], + storage_profile=None, + linux_configuration=LinuxConfiguration( + disable_password_authentication=True + ), + ) + } + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_linux_enforce_ssh_authentication.vm_linux_enforce_ssh_authentication.vm_client", + new=vm_client, + ), + ): + from prowler.providers.azure.services.vm.vm_linux_enforce_ssh_authentication.vm_linux_enforce_ssh_authentication import ( + vm_linux_enforce_ssh_authentication, + ) + + check = vm_linux_enforce_ssh_authentication() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "LinuxVM" + assert result[0].resource_id == vm_id + assert "password authentication disabled" in result[0].status_extended + + def test_linux_vm_password_auth_enabled(self): + vm_id = str(uuid4()) + vm_client = mock.MagicMock + vm_client.virtual_machines = { + AZURE_SUBSCRIPTION_ID: { + vm_id: VirtualMachine( + resource_id=vm_id, + resource_name="LinuxVM", + location="westeurope", + security_profile=None, + extensions=[], + storage_profile=None, + linux_configuration=LinuxConfiguration( + disable_password_authentication=False + ), + ) + } + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_linux_enforce_ssh_authentication.vm_linux_enforce_ssh_authentication.vm_client", + new=vm_client, + ), + ): + from prowler.providers.azure.services.vm.vm_linux_enforce_ssh_authentication.vm_linux_enforce_ssh_authentication import ( + vm_linux_enforce_ssh_authentication, + ) + + check = vm_linux_enforce_ssh_authentication() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "LinuxVM" + assert result[0].resource_id == vm_id + assert "password authentication enabled" in result[0].status_extended + + def test_non_linux_vm(self): + vm_id = str(uuid4()) + vm_client = mock.MagicMock + vm_client.virtual_machines = { + AZURE_SUBSCRIPTION_ID: { + vm_id: VirtualMachine( + resource_id=vm_id, + resource_name="WindowsVM", + location="westeurope", + security_profile=None, + extensions=[], + storage_profile=None, + linux_configuration=None, # Not a Linux VM + ) + } + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_linux_enforce_ssh_authentication.vm_linux_enforce_ssh_authentication.vm_client", + new=vm_client, + ), + ): + from prowler.providers.azure.services.vm.vm_linux_enforce_ssh_authentication.vm_linux_enforce_ssh_authentication import ( + vm_linux_enforce_ssh_authentication, + ) + + check = vm_linux_enforce_ssh_authentication() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer_test.py b/tests/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer_test.py new file mode 100644 index 0000000000..d9d0717f4e --- /dev/null +++ b/tests/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer_test.py @@ -0,0 +1,216 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.vm.vm_service import VirtualMachineScaleSet +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_vm_scaleset_associated_with_load_balancer: + def test_no_subscriptions(self): + vm_scale_sets = {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_client.vm_client.vm_scale_sets", + new=vm_scale_sets, + ), + ): + from prowler.providers.azure.services.vm.vm_scaleset_associated_with_load_balancer.vm_scaleset_associated_with_load_balancer import ( + vm_scaleset_associated_with_load_balancer, + ) + + check = vm_scaleset_associated_with_load_balancer() + result = check.execute() + assert len(result) == 0 + + def test_empty_scale_sets(self): + vm_scale_sets = {AZURE_SUBSCRIPTION_ID: {}} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_client.vm_client.vm_scale_sets", + new=vm_scale_sets, + ), + ): + from prowler.providers.azure.services.vm.vm_scaleset_associated_with_load_balancer.vm_scaleset_associated_with_load_balancer import ( + vm_scaleset_associated_with_load_balancer, + ) + + check = vm_scaleset_associated_with_load_balancer() + result = check.execute() + assert len(result) == 0 + + def test_compliant_scale_set(self): + vmss_id = str(uuid4()) + backend_pool_id = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg/providers/Microsoft.Network/loadBalancers/lb/backendAddressPools/bepool" + vm_scale_sets = { + AZURE_SUBSCRIPTION_ID: { + vmss_id: VirtualMachineScaleSet( + resource_id=vmss_id, + resource_name="compliant-vmss", + location="eastus", + load_balancer_backend_pools=[backend_pool_id], + ) + } + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_client.vm_client.vm_scale_sets", + new=vm_scale_sets, + ), + ): + from prowler.providers.azure.services.vm.vm_scaleset_associated_with_load_balancer.vm_scaleset_associated_with_load_balancer import ( + vm_scaleset_associated_with_load_balancer, + ) + + check = vm_scaleset_associated_with_load_balancer() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == vmss_id + assert result[0].resource_name == "compliant-vmss" + assert result[0].location == "eastus" + expected_status_extended = ( + f"Scale set 'compliant-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' " + f"is associated with load balancer backend pool(s): bepool." + ) + assert result[0].status_extended == expected_status_extended + + def test_noncompliant_scale_set(self): + vmss_id = str(uuid4()) + vm_scale_sets = { + AZURE_SUBSCRIPTION_ID: { + vmss_id: VirtualMachineScaleSet( + resource_id=vmss_id, + resource_name="noncompliant-vmss", + location="westeurope", + load_balancer_backend_pools=[], + ) + } + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_client.vm_client.vm_scale_sets", + new=vm_scale_sets, + ), + ): + from prowler.providers.azure.services.vm.vm_scaleset_associated_with_load_balancer.vm_scaleset_associated_with_load_balancer import ( + vm_scaleset_associated_with_load_balancer, + ) + + check = vm_scaleset_associated_with_load_balancer() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == vmss_id + assert result[0].resource_name == "noncompliant-vmss" + assert result[0].location == "westeurope" + expected_status_extended = ( + f"Scale set 'noncompliant-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' " + f"is not associated with any load balancer backend pool." + ) + assert result[0].status_extended == expected_status_extended + + def test_multiple_scale_sets(self): + compliant_id = str(uuid4()) + noncompliant_id = str(uuid4()) + backend_pool_id = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg/providers/Microsoft.Network/loadBalancers/lb/backendAddressPools/bepool" + vm_scale_sets = { + AZURE_SUBSCRIPTION_ID: { + compliant_id: VirtualMachineScaleSet( + resource_id=compliant_id, + resource_name="compliant-vmss", + location="eastus", + load_balancer_backend_pools=[backend_pool_id], + ), + noncompliant_id: VirtualMachineScaleSet( + resource_id=noncompliant_id, + resource_name="noncompliant-vmss", + location="westeurope", + load_balancer_backend_pools=[], + ), + } + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_client.vm_client.vm_scale_sets", + new=vm_scale_sets, + ), + ): + from prowler.providers.azure.services.vm.vm_scaleset_associated_with_load_balancer.vm_scaleset_associated_with_load_balancer import ( + vm_scaleset_associated_with_load_balancer, + ) + + check = vm_scaleset_associated_with_load_balancer() + result = check.execute() + assert len(result) == 2 + for r in result: + if r.resource_name == "compliant-vmss": + expected_status_extended = ( + f"Scale set 'compliant-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' " + f"is associated with load balancer backend pool(s): bepool." + ) + assert r.status == "PASS" + assert r.status_extended == expected_status_extended + elif r.resource_name == "noncompliant-vmss": + expected_status_extended = ( + f"Scale set 'noncompliant-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' " + f"is not associated with any load balancer backend pool." + ) + assert r.status == "FAIL" + assert r.status_extended == expected_status_extended + + def test_missing_attributes(self): + # Simulate a scale set with missing optional attributes + vmss_id = str(uuid4()) + vm_scale_sets = { + AZURE_SUBSCRIPTION_ID: { + vmss_id: VirtualMachineScaleSet( + resource_id=vmss_id, + resource_name="", + location="", + load_balancer_backend_pools=[], + ) + } + } + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_client.vm_client.vm_scale_sets", + new=vm_scale_sets, + ), + ): + from prowler.providers.azure.services.vm.vm_scaleset_associated_with_load_balancer.vm_scaleset_associated_with_load_balancer import ( + vm_scaleset_associated_with_load_balancer, + ) + + check = vm_scaleset_associated_with_load_balancer() + result = check.execute() + assert len(result) == 1 + expected_status_extended = f"Scale set '' in subscription '{AZURE_SUBSCRIPTION_ID}' is not associated with any load balancer backend pool." + assert result[0].status == "FAIL" + assert result[0].status_extended == expected_status_extended diff --git a/tests/providers/azure/services/vm/vm_service_test.py b/tests/providers/azure/services/vm/vm_service_test.py index 78aaccda2a..6a3144a5c9 100644 --- a/tests/providers/azure/services/vm/vm_service_test.py +++ b/tests/providers/azure/services/vm/vm_service_test.py @@ -2,6 +2,7 @@ from unittest.mock import patch from prowler.providers.azure.services.vm.vm_service import ( Disk, + LinuxConfiguration, ManagedDiskParameters, OperatingSystemType, OSDisk, @@ -40,6 +41,7 @@ def mock_vm_get_virtual_machines(_): ), data_disks=[], ), + linux_configuration=None, ) } } @@ -55,6 +57,7 @@ def mock_vm_get_virtual_machines_with_none(_): security_profile=None, extensions=[], storage_profile=None, + linux_configuration=None, ), "vm_id-2": VirtualMachine( resource_id="/subscriptions/resource_id2", @@ -66,6 +69,7 @@ def mock_vm_get_virtual_machines_with_none(_): os_disk=None, data_disks=[], ), + linux_configuration=None, ), } } @@ -85,6 +89,24 @@ def mock_vm_get_disks(_): } +def mock_vm_get_virtual_machines_with_linux(_): + return { + AZURE_SUBSCRIPTION_ID: { + "vm_id-linux": VirtualMachine( + resource_id="/subscriptions/resource_id_linux", + resource_name="LinuxVM", + location="location", + security_profile=None, + extensions=[], + storage_profile=None, + linux_configuration=LinuxConfiguration( + disable_password_authentication=True + ), + ) + } + } + + @patch( "prowler.providers.azure.services.vm.vm_service.VirtualMachines._get_virtual_machines", new=mock_vm_get_virtual_machines, @@ -186,3 +208,14 @@ class Test_VirtualMachines_NoneCases: assert vm_2.storage_profile.os_disk is None assert vm_2.storage_profile.data_disks == [] assert vm_2.resource_name == "VMWithPartialNone" + + +@patch( + "prowler.providers.azure.services.vm.vm_service.VirtualMachines._get_virtual_machines", + new=mock_vm_get_virtual_machines_with_linux, +) +def test_virtual_machine_with_linux_configuration(): + virtual_machines = VirtualMachines(set_mocked_azure_provider()) + vm = virtual_machines.virtual_machines[AZURE_SUBSCRIPTION_ID]["vm_id-linux"] + assert vm.linux_configuration is not None + assert vm.linux_configuration.disable_password_authentication is True diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 65d61384c5..e98629166a 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -5,9 +5,34 @@ All notable changes to the **Prowler UI** are documented in this file. ## [v1.9.0] (Prowler v5.9.0) – UNRELEASED ### 🚀 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) + +### 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) + ### 🔄 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) + ### 🐞 Fixed +### Removed + +--- + +## [v1.8.1] (Prowler 5.8.1) + +### 🔄 Changed + +- Latest new failed findings now use `GET /findings/latest` [(#8219)](https://github.com/prowler-cloud/prowler/pull/8219) + +### Removed + +- Validation of the provider's secret type during updates [(#8197)](https://github.com/prowler-cloud/prowler/pull/8197) + --- ## [v1.8.0] (Prowler v5.8.0) @@ -29,7 +54,6 @@ All notable changes to the **Prowler UI** are documented in this file. - Improve `Scan ID` filter by adding more context and enhancing the UI/UX [(#8046)](https://github.com/prowler-cloud/prowler/pull/8046) - Lighthouse chat interface [(#7878)](https://github.com/prowler-cloud/prowler/pull/7878) - Google Tag Manager integration [(#8058)](https://github.com/prowler-cloud/prowler/pull/8058) - ### 🔄 Changed diff --git a/ui/actions/integrations/saml.ts b/ui/actions/integrations/saml.ts index daa27f596f..c5bc6d18bb 100644 --- a/ui/actions/integrations/saml.ts +++ b/ui/actions/integrations/saml.ts @@ -210,8 +210,7 @@ export const initiateSamlAuth = async (email: string) => { if (response.status === 403) { return { success: false, - error: - "Domain is not authorized for SAML authentication or SAML certificates are missing.", + error: "Domain is not authorized for SAML authentication.", }; } diff --git a/ui/actions/overview/overview.ts b/ui/actions/overview/overview.ts index 3a95a2a3f3..363a2b9df3 100644 --- a/ui/actions/overview/overview.ts +++ b/ui/actions/overview/overview.ts @@ -59,9 +59,9 @@ export const getFindingsByStatus = async ({ if (query) url.searchParams.append("filter[search]", query); if (sort) url.searchParams.append("sort", sort); - // Handle multiple filters + // Handle multiple filters, but exclude muted filter as overviews endpoint doesn't support it Object.entries(filters).forEach(([key, value]) => { - if (key !== "filter[search]") { + if (key !== "filter[search]" && key !== "filter[muted]") { url.searchParams.append(key, String(value)); } }); @@ -102,9 +102,9 @@ export const getFindingsBySeverity = async ({ if (query) url.searchParams.append("filter[search]", query); if (sort) url.searchParams.append("sort", sort); - // Handle multiple filters + // Handle multiple filters, but exclude muted filter as overviews endpoint doesn't support it Object.entries(filters).forEach(([key, value]) => { - if (key !== "filter[search]") { + if (key !== "filter[search]" && key !== "filter[muted]") { url.searchParams.append(key, String(value)); } }); diff --git a/ui/actions/processors/index.ts b/ui/actions/processors/index.ts new file mode 100644 index 0000000000..3bd4e78191 --- /dev/null +++ b/ui/actions/processors/index.ts @@ -0,0 +1 @@ +export * from "./processors"; diff --git a/ui/actions/processors/processors.ts b/ui/actions/processors/processors.ts new file mode 100644 index 0000000000..f177dc9ac4 --- /dev/null +++ b/ui/actions/processors/processors.ts @@ -0,0 +1,225 @@ +"use server"; + +import { apiBaseUrl, getAuthHeaders } from "@/lib/helper"; +import { mutedFindingsConfigFormSchema } from "@/types/formSchemas"; +import { + DeleteMutedFindingsConfigActionState, + MutedFindingsConfigActionState, + ProcessorData, +} from "@/types/processors"; + +export const createMutedFindingsConfig = async ( + _prevState: MutedFindingsConfigActionState, + formData: FormData, +): Promise => { + const headers = await getAuthHeaders({ contentType: true }); + const formDataObject = Object.fromEntries(formData); + const validatedData = mutedFindingsConfigFormSchema.safeParse(formDataObject); + + if (!validatedData.success) { + const formFieldErrors = validatedData.error.flatten().fieldErrors; + return { + errors: { + configuration: formFieldErrors?.configuration?.[0], + }, + }; + } + + const { configuration } = validatedData.data; + + try { + const url = new URL(`${apiBaseUrl}/processors`); + + const bodyData = { + data: { + type: "processors", + attributes: { + processor_type: "mutelist", + configuration: configuration, + }, + }, + }; + + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(bodyData), + }); + + if (!response.ok) { + try { + const errorData = await response.json(); + throw new Error( + errorData?.errors?.[0]?.detail || + errorData?.message || + `Failed to create Mutelist configuration: ${response.statusText}`, + ); + } catch { + throw new Error( + `Failed to create Mutelist configuration: ${response.statusText}`, + ); + } + } + + await response.json(); + return { success: "Mutelist configuration created successfully!" }; + } catch (error) { + console.error("Error creating Mutelist config:", error); + return { + errors: { + configuration: + error instanceof Error + ? error.message + : "Error creating Mutelist configuration. Please try again.", + }, + }; + } +}; + +export const updateMutedFindingsConfig = async ( + _prevState: MutedFindingsConfigActionState, + formData: FormData, +): Promise => { + const headers = await getAuthHeaders({ contentType: true }); + const formDataObject = Object.fromEntries(formData); + const validatedData = mutedFindingsConfigFormSchema.safeParse(formDataObject); + + if (!validatedData.success) { + const formFieldErrors = validatedData.error.flatten().fieldErrors; + return { + errors: { + configuration: formFieldErrors?.configuration?.[0], + }, + }; + } + + const { configuration, id } = validatedData.data; + + if (!id) { + return { + errors: { + general: "Configuration ID is required for update", + }, + }; + } + + try { + const url = new URL(`${apiBaseUrl}/processors/${id}`); + + const bodyData = { + data: { + type: "processors", + id, + attributes: { + configuration: configuration, + }, + }, + }; + + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(bodyData), + }); + + if (!response.ok) { + try { + const errorData = await response.json(); + throw new Error( + errorData?.errors?.[0]?.detail || + errorData?.message || + `Failed to update Mutelist configuration: ${response.statusText}`, + ); + } catch { + throw new Error( + `Failed to update Mutelist configuration: ${response.statusText}`, + ); + } + } + + await response.json(); + return { success: "Mutelist configuration updated successfully!" }; + } catch (error) { + console.error("Error updating Mutelist config:", error); + return { + errors: { + configuration: + error instanceof Error + ? error.message + : "Error updating Mutelist configuration. Please try again.", + }, + }; + } +}; + +export const getMutedFindingsConfig = async (): Promise< + ProcessorData | undefined +> => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/processors`); + url.searchParams.append("filter[processor_type]", "mutelist"); + + try { + const response = await fetch(url.toString(), { + method: "GET", + headers, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch Mutelist config: ${response.statusText}`, + ); + } + + const data = await response.json(); + return data.data[0]; + } catch (error) { + console.error("Error fetching Mutelist config:", error); + return undefined; + } +}; + +export const deleteMutedFindingsConfig = async ( + _prevState: DeleteMutedFindingsConfigActionState, + formData: FormData, +): Promise => { + const headers = await getAuthHeaders({ contentType: true }); + const formDataObject = Object.fromEntries(formData); + const processorId = formDataObject.id as string; + + if (!processorId) { + return { + errors: { + general: "Configuration ID is required for deletion", + }, + }; + } + + try { + const url = new URL(`${apiBaseUrl}/processors/${processorId}`); + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.errors?.[0]?.detail || + `Failed to delete Mutelist configuration: ${response.statusText}`, + ); + } + + return { success: "Mutelist configuration deleted successfully!" }; + } catch (error) { + console.error("Error deleting Mutelist config:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error deleting Mutelist configuration. Please try again.", + }, + }; + } +}; diff --git a/ui/actions/providers/providers.ts b/ui/actions/providers/providers.ts index 922041d487..7a38b8875f 100644 --- a/ui/actions/providers/providers.ts +++ b/ui/actions/providers/providers.ts @@ -13,7 +13,6 @@ import { } from "@/lib"; import { buildSecretConfig, - buildUpdateSecretConfig, handleApiError, handleApiResponse, } from "@/lib/provider-credentials/build-crendentials"; @@ -194,8 +193,7 @@ export const updateCredentialsProvider = async ( ) as ProviderType; try { - const secret = buildUpdateSecretConfig(formData, providerType); - + const { secretType, secret } = buildSecretConfig(formData, providerType); const response = await fetch(url.toString(), { method: "PATCH", headers, @@ -203,7 +201,7 @@ export const updateCredentialsProvider = async ( data: { type: "provider-secrets", id: credentialsId, - attributes: { secret }, + attributes: { secret_type: secretType, secret }, }, }), }); diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx index 7c6f2ac906..a061e04f96 100644 --- a/ui/app/(prowler)/page.tsx +++ b/ui/app/(prowler)/page.tsx @@ -1,8 +1,7 @@ import { Spacer } from "@nextui-org/react"; -import { format, subDays } from "date-fns"; import { Suspense } from "react"; -import { getFindings } from "@/actions/findings/findings"; +import { getLatestFindings } from "@/actions/findings/findings"; import { getFindingsBySeverity, getFindingsByStatus, @@ -33,7 +32,7 @@ export default function Home({ const searchParamsKey = JSON.stringify(searchParams || {}); return ( - +
@@ -60,7 +59,7 @@ export default function Home({ key={searchParamsKey} fallback={} > - +
@@ -125,23 +124,34 @@ const SSRFindingsBySeverity = async ({ ); }; -const SSRDataNewFindingsTable = async () => { +const SSRDataNewFindingsTable = async ({ + searchParams, +}: { + searchParams: SearchParamsProps | undefined | null; +}) => { const page = 1; const sort = "severity,-inserted_at"; - const twoDaysAgo = format(subDays(new Date(), 2), "yyyy-MM-dd"); - const defaultFilters = { - "filter[status__in]": "FAIL", - "filter[delta__in]": "new", - "filter[inserted_at__gte]": twoDaysAgo, + "filter[status]": "FAIL", + "filter[delta]": "new", }; - const findingsData = await getFindings({ + const filters = searchParams + ? Object.fromEntries( + Object.entries(searchParams).filter(([key]) => + key.startsWith("filter["), + ), + ) + : {}; + + const combinedFilters = { ...defaultFilters, ...filters }; + + const findingsData = await getLatestFindings({ query: undefined, page, sort, - filters: defaultFilters, + filters: combinedFilters, }); // Create dictionaries for resources, scans, and providers @@ -178,8 +188,7 @@ const SSRDataNewFindingsTable = async () => { Latest new failing findings

- Showing the latest 10 new failing findings by severity from the last - 2 days. + Showing the latest 10 new failing findings by severity.

diff --git a/ui/app/(prowler)/profile/page.tsx b/ui/app/(prowler)/profile/page.tsx index bc8b371fe2..74108355f9 100644 --- a/ui/app/(prowler)/profile/page.tsx +++ b/ui/app/(prowler)/profile/page.tsx @@ -1,10 +1,10 @@ import React, { Suspense } from "react"; -// import { getSamlConfig } from "@/actions/integrations/saml"; +import { getSamlConfig } from "@/actions/integrations/saml"; import { getAllTenants } from "@/actions/users/tenants"; import { getUserInfo } from "@/actions/users/users"; import { getUserMemberships } from "@/actions/users/users"; -// import { SamlIntegrationCard } from "@/components/integrations/saml-integration-card"; +import { SamlIntegrationCard } from "@/components/integrations/saml-integration-card"; import { ContentLayout } from "@/components/ui"; import { UserBasicInfoCard } from "@/components/users/profile"; import { MembershipsCard } from "@/components/users/profile/memberships-card"; @@ -24,7 +24,7 @@ export default async function Profile() { } const SSRDataUser = async () => { - // const samlConfig = await getSamlConfig(); + const samlConfig = await getSamlConfig(); const userProfile = await getUserInfo(); if (!userProfile?.data) { return null; @@ -84,10 +84,9 @@ const SSRDataUser = async () => { />
- {/* TODO after v5.8: Add SAML SSO back in */} - {/*
+
-
*/} +
); }; diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx index ff78664ca9..435b8d73b3 100644 --- a/ui/app/(prowler)/providers/page.tsx +++ b/ui/app/(prowler)/providers/page.tsx @@ -4,7 +4,10 @@ import { Suspense } from "react"; import { getProviders } from "@/actions/providers"; import { FilterControls, filterProviders } from "@/components/filters"; import { ManageGroupsButton } from "@/components/manage-groups"; -import { AddProviderButton } from "@/components/providers"; +import { + AddProviderButton, + MutedFindingsConfigButton, +} from "@/components/providers"; import { ColumnProviders, SkeletonTableProviders, @@ -24,24 +27,31 @@ export default async function Providers({ -
- - -
- - -
-
- }> - - -
-
+ +
+ + + +
+ +
+
+ +
+
+ + } + > + +
); } -const SSRDataTable = async ({ +const ProvidersContent = async ({ searchParams, }: { searchParams: SearchParamsProps; @@ -66,6 +76,8 @@ const SSRDataTable = async ({ pageSize, }); + const hasProviders = providersData?.data && providersData.data.length > 0; + const providerGroupDict = providersData?.included ?.filter((item: any) => item.type === "provider-groups") @@ -85,10 +97,23 @@ const SSRDataTable = async ({ }) || []; return ( - + <> +
+ + + +
+ + +
+
+ +
+
+ ); }; diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index e753463a4e..dda91b1747 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -3,6 +3,7 @@ import { Suspense } from "react"; import { getProviders } from "@/actions/providers"; import { getScans, getScansByState } from "@/actions/scans"; +import { MutedFindingsConfigButton } from "@/components/providers"; import { AutoRefresh, NoProvidersAdded, @@ -92,6 +93,10 @@ export default async function Scans({ providerDetails={providerDetails} /> +
+ +
+ }> diff --git a/ui/components/auth/oss/auth-form.tsx b/ui/components/auth/oss/auth-form.tsx index e20023304e..211d238341 100644 --- a/ui/components/auth/oss/auth-form.tsx +++ b/ui/components/auth/oss/auth-form.tsx @@ -9,6 +9,7 @@ import { z } from "zod"; import { authenticate, createNewUser } from "@/actions/auth"; import { initiateSamlAuth } from "@/actions/integrations/saml"; +import { PasswordRequirementsMessage } from "@/components/auth/oss/password-validator"; import { NotificationIcon, ProwlerExtended } from "@/components/icons"; import { ThemeSwitch } from "@/components/ThemeSwitch"; import { useToast } from "@/components/ui"; @@ -219,15 +220,22 @@ export const AuthForm = ({ showFormMessage={type !== "sign-in"} /> {!isSamlMode && ( - + <> + + {type === "sign-up" && ( + + )} + )} {/* {type === "sign-in" && (
@@ -401,8 +409,7 @@ export const AuthForm = ({ )} - {/* TODO after v5.8: Add SAML SSO back in */} - {/* */} +
)} diff --git a/ui/components/auth/oss/password-validator.tsx b/ui/components/auth/oss/password-validator.tsx new file mode 100644 index 0000000000..e3ef2a2b1e --- /dev/null +++ b/ui/components/auth/oss/password-validator.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { AlertCircle, CheckCircle } from "lucide-react"; + +import { + PASSWORD_REQUIREMENTS, + passwordRequirementCheckers, +} from "@/types/authFormSchema"; + +interface PasswordRequirementsMessageProps { + password: string; + className?: string; +} + +const REQUIREMENTS = [ + { + key: "minLength", + checker: passwordRequirementCheckers.minLength, + label: `At least ${PASSWORD_REQUIREMENTS.minLength} characters`, + }, + { + key: "specialChars", + checker: passwordRequirementCheckers.specialChars, + label: "Special characters", + }, + { + key: "uppercase", + checker: passwordRequirementCheckers.uppercase, + label: "Uppercase letters", + }, + { + key: "lowercase", + checker: passwordRequirementCheckers.lowercase, + label: "Lowercase letters", + }, + { + key: "numbers", + checker: passwordRequirementCheckers.numbers, + label: "Numbers", + }, +]; + +export const PasswordRequirementsMessage = ({ + password, + className = "", +}: PasswordRequirementsMessageProps) => { + const hasPasswordInput = password.length > 0; + if (!hasPasswordInput) { + return null; + } + const results = REQUIREMENTS.map((req) => ({ + ...req, + isMet: req.checker(password), + })); + const metCount = results.filter((r) => r.isMet).length; + const allRequirementsMet = metCount === REQUIREMENTS.length; + + return ( +
+
+ {allRequirementsMet ? ( +
+
+ ) : ( +
+
+
+
    + {results.map((req) => ( +
  • +
    + + + {req.isMet ? "Requirement met" : "Requirement not met"} + +
  • + ))} +
+
+ )} +
+
+ ); +}; diff --git a/ui/components/filters/custom-checkbox-muted-findings.tsx b/ui/components/filters/custom-checkbox-muted-findings.tsx index 5edcaa1cad..05cae96522 100644 --- a/ui/components/filters/custom-checkbox-muted-findings.tsx +++ b/ui/components/filters/custom-checkbox-muted-findings.tsx @@ -1,18 +1,38 @@ +"use client"; + import { Checkbox } from "@nextui-org/react"; -import React from "react"; +import { useSearchParams } from "next/navigation"; +import { useState } from "react"; + +import { useUrlFilters } from "@/hooks/use-url-filters"; export const CustomCheckboxMutedFindings = () => { + const { updateFilter } = useUrlFilters(); + const searchParams = useSearchParams(); + const [excludeMuted, setExcludeMuted] = useState( + searchParams.get("filter[muted]") === "false", + ); + + const handleMutedChange = (value: boolean) => { + setExcludeMuted(value); + updateFilter("muted", value ? "false" : "true"); + }; + return ( - - Include Muted Findings - +
+ + Exclude muted findings + +
); }; diff --git a/ui/components/filters/filter-controls.tsx b/ui/components/filters/filter-controls.tsx index b460999807..36e55f003b 100644 --- a/ui/components/filters/filter-controls.tsx +++ b/ui/components/filters/filter-controls.tsx @@ -4,7 +4,7 @@ import { Spacer } from "@nextui-org/react"; import { useSearchParams } from "next/navigation"; import React, { useEffect, useState } from "react"; -import { FilterControlsProps } from "@/types"; +import { FilterOption } from "@/types"; import { DataTableFilterCustom } from "../ui/table"; import { ClearFiltersButton } from "./clear-filters-button"; @@ -15,6 +15,17 @@ import { CustomRegionSelection } from "./custom-region-selection"; import { CustomSearchInput } from "./custom-search-input"; import { CustomSelectProvider } from "./custom-select-provider"; +export interface FilterControlsProps { + search?: boolean; + providers?: boolean; + date?: boolean; + regions?: boolean; + accounts?: boolean; + mutedFindings?: boolean; + customFilters?: FilterOption[]; + showClearButton?: boolean; +} + export const FilterControls: React.FC = ({ search = false, providers = false, @@ -22,16 +33,17 @@ export const FilterControls: React.FC = ({ regions = false, accounts = false, mutedFindings = false, + showClearButton = true, customFilters, }) => { const searchParams = useSearchParams(); - const [showClearButton, setShowClearButton] = useState(false); + const [hasFilters, setHasFilters] = useState(false); useEffect(() => { const hasFilters = Array.from(searchParams.keys()).some( (key) => key.startsWith("filter[") || key === "sort", ); - setShowClearButton(hasFilters); + setHasFilters(hasFilters); }, [searchParams]); return ( @@ -43,7 +55,9 @@ export const FilterControls: React.FC = ({ {regions && } {accounts && } {mutedFindings && } - {!customFilters && showClearButton && } + {!customFilters && hasFilters && showClearButton && ( + + )} {customFilters && ( diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx index 0dc025084b..d94d0c668d 100644 --- a/ui/components/findings/findings-filters.tsx +++ b/ui/components/findings/findings-filters.tsx @@ -38,6 +38,7 @@ export const FindingsFilters = ({ { +export const Muted = ({ + isMuted, + mutedReason = "This finding is muted", +}: MutedProps) => { if (isMuted === false) return null; return ( - +
diff --git a/ui/components/findings/table/column-findings.tsx b/ui/components/findings/table/column-findings.tsx index a8636ab750..51e3d4625d 100644 --- a/ui/components/findings/table/column-findings.tsx +++ b/ui/components/findings/table/column-findings.tsx @@ -103,7 +103,7 @@ export const ColumnFindings: ColumnDef[] = [ cell: ({ row }) => { const { checktitle } = getFindingsMetadata(row); const { - attributes: { muted }, + attributes: { muted, muted_reason }, } = getFindingsData(row); const { delta } = row.original.attributes; @@ -120,7 +120,7 @@ export const ColumnFindings: ColumnDef[] = [

- + ); diff --git a/ui/components/findings/table/finding-detail.tsx b/ui/components/findings/table/finding-detail.tsx index 92c7bcb9e1..a32eeaa207 100644 --- a/ui/components/findings/table/finding-detail.tsx +++ b/ui/components/findings/table/finding-detail.tsx @@ -67,7 +67,10 @@ export const FindingDetail = ({
- +
= ({ findingsByStatus, }) => { + const searchParams = useSearchParams(); + const shouldShowMuted = searchParams.get("filter[muted]") !== "false"; + const { fail = 0, pass = 0, + muted = 0, pass_new = 0, fail_new = 0, + muted_new = 0, } = findingsByStatus?.data?.attributes || {}; - const chartData = useMemo( - () => [ + + const chartData = useMemo(() => { + const data = [ { findings: "Success", number: pass, @@ -75,9 +88,18 @@ export const FindingsByStatusChart: React.FC = ({ number: fail, fill: "var(--color-fail)", }, - ], - [pass, fail], - ); + ]; + + if (shouldShowMuted) { + data.push({ + findings: "Muted", + number: muted, + fill: "var(--color-muted)", + }); + } + + return data; + }, [pass, fail, muted, shouldShowMuted]); const updatedChartData = calculatePercent(chartData); @@ -86,6 +108,8 @@ export const FindingsByStatusChart: React.FC = ({ [chartData], ); + const hasDataToShow = totalFindings > 0; + const emptyChartData = [ { findings: "Empty", @@ -105,7 +129,7 @@ export const FindingsByStatusChart: React.FC = ({ } /> 0 ? chartData : emptyChartData} + data={hasDataToShow ? chartData : emptyChartData} dataKey="number" nameKey="findings" innerRadius={65} @@ -126,7 +150,7 @@ export const FindingsByStatusChart: React.FC = ({ y={viewBox.cy} className="fill-foreground text-xl font-bold" > - {totalFindings > 0 + {hasDataToShow ? totalFindings.toLocaleString() : "0"} @@ -135,7 +159,7 @@ export const FindingsByStatusChart: React.FC = ({ y={(viewBox.cy || 0) + 20} className="fill-foreground text-xs" > - Findings + {"Findings"} ); @@ -204,6 +228,46 @@ export const FindingsByStatusChart: React.FC = ({
+ + {shouldShowMuted && ( +
+
+ + } + 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" + )} +
+
+ )} diff --git a/ui/components/overview/new-findings-table/table/column-new-findings-to-date.tsx b/ui/components/overview/new-findings-table/table/column-new-findings-to-date.tsx index 488f8b190b..348c1be8df 100644 --- a/ui/components/overview/new-findings-table/table/column-new-findings-to-date.tsx +++ b/ui/components/overview/new-findings-table/table/column-new-findings-to-date.tsx @@ -79,7 +79,7 @@ export const ColumnNewFindingsToDate: ColumnDef[] = [ cell: ({ row }) => { const { checktitle } = getFindingsMetadata(row); const { - attributes: { muted }, + attributes: { muted, muted_reason }, } = getFindingsData(row); const { delta } = row.original.attributes; return ( @@ -95,7 +95,7 @@ export const ColumnNewFindingsToDate: ColumnDef[] = [

- + ); diff --git a/ui/components/providers/credentials-update-info.tsx b/ui/components/providers/credentials-update-info.tsx index 1b32e2391f..2715a1ea6b 100644 --- a/ui/components/providers/credentials-update-info.tsx +++ b/ui/components/providers/credentials-update-info.tsx @@ -1,6 +1,5 @@ "use client"; -import { InfoIcon } from "@/components/icons"; import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws"; import { SelectViaGCP } from "@/components/providers/workflow/forms/select-credentials-type/gcp"; import { ProviderType } from "@/types/providers"; @@ -24,28 +23,5 @@ export const CredentialsUpdateInfo = ({ return null; }; - return ( -
-

- To update provider credentials,{" "} - - the same type that was originally configured must be used. - -

-
- -

- If the provider was configured with static credentials, updates must - also use static credentials. If it was configured with a role in AWS - (or service account in GCP),{" "} - updates must use the same type. -

-
-

- To switch from one type to another, the provider must be deleted and set - up again. -

- {renderSelectComponent()} -
- ); + return
{renderSelectComponent()}
; }; diff --git a/ui/components/providers/forms/index.ts b/ui/components/providers/forms/index.ts index a081952ade..e6001f358d 100644 --- a/ui/components/providers/forms/index.ts +++ b/ui/components/providers/forms/index.ts @@ -1,2 +1,3 @@ export * from "./delete-form"; export * from "./edit-form"; +export { MutedFindingsConfigForm } from "./muted-findings-config-form"; diff --git a/ui/components/providers/forms/muted-findings-config-form.tsx b/ui/components/providers/forms/muted-findings-config-form.tsx new file mode 100644 index 0000000000..52c5e2c7df --- /dev/null +++ b/ui/components/providers/forms/muted-findings-config-form.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { Textarea } from "@nextui-org/react"; +import Link from "next/link"; +import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { useFormState } from "react-dom"; + +import { + createMutedFindingsConfig, + deleteMutedFindingsConfig, + getMutedFindingsConfig, + updateMutedFindingsConfig, +} from "@/actions/processors"; +import { DeleteIcon } from "@/components/icons"; +import { useToast } from "@/components/ui"; +import { CustomButton } from "@/components/ui/custom"; +import { FormButtons } from "@/components/ui/form"; +import { fontMono } from "@/config/fonts"; +import { convertToYaml, parseYamlValidation } from "@/lib/yaml"; +import { + MutedFindingsConfigActionState, + ProcessorData, +} from "@/types/processors"; + +interface MutedFindingsConfigFormProps { + setIsOpen: Dispatch>; +} + +export const MutedFindingsConfigForm = ({ + setIsOpen, +}: MutedFindingsConfigFormProps) => { + const [config, setConfig] = useState(null); + const [configText, setConfigText] = useState(""); + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [yamlValidation, setYamlValidation] = useState<{ + isValid: boolean; + error?: string; + }>({ isValid: true }); + const [hasUserStartedTyping, setHasUserStartedTyping] = useState(false); + + const [state, formAction, isPending] = useFormState< + MutedFindingsConfigActionState, + FormData + >(config ? updateMutedFindingsConfig : createMutedFindingsConfig, null); + + const { toast } = useToast(); + + useEffect(() => { + getMutedFindingsConfig().then((result) => { + setConfig(result || null); + const yamlConfig = convertToYaml(result?.attributes.configuration || ""); + setConfigText(yamlConfig); + setHasUserStartedTyping(false); // Reset when loading initial config + if (yamlConfig) { + setYamlValidation(parseYamlValidation(yamlConfig)); + } + }); + }, []); + + useEffect(() => { + if (state?.success) { + toast({ + title: "Configuration saved successfully", + description: state.success, + }); + setIsOpen(false); + } else if (state?.errors?.general) { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: state.errors.general, + }); + } else if (state?.errors?.configuration) { + // Reset typing state when there are new server errors + setHasUserStartedTyping(false); + } + }, [state, toast, setIsOpen]); + + const handleConfigChange = (value: string) => { + setConfigText(value); + // Clear server errors when user starts typing + setHasUserStartedTyping(true); + // Validate YAML in real-time + const validation = parseYamlValidation(value); + setYamlValidation(validation); + }; + + const handleDelete = async () => { + if (!config) return; + + setIsDeleting(true); + const formData = new FormData(); + formData.append("id", config.id); + + try { + const result = await deleteMutedFindingsConfig(null, formData); + if (result?.success) { + toast({ + title: "Configuration deleted successfully", + description: result.success, + }); + setIsOpen(false); + } else if (result?.errors?.general) { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: result.errors.general, + }); + } + } catch (error) { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: "Error deleting configuration. Please try again.", + }); + } finally { + setIsDeleting(false); + setShowDeleteConfirmation(false); + } + }; + + if (showDeleteConfirmation) { + return ( +
+

+ Delete Mutelist Configuration +

+

+ Are you sure you want to delete this configuration? This action cannot + be undone. +

+
+ setShowDeleteConfirmation(false)} + isDisabled={isDeleting} + > + Cancel + + } + onPress={handleDelete} + > + {isDeleting ? "Deleting" : "Delete"} + +
+
+ ); + } + + return ( +
+ {config && } + +
+
+
    +
  • + + This Mutelist configuration will take effect on the next scan. + +
  • +
  • + Mutelist configuration can be modified at anytime on the Providers + and Scans pages. +
  • +
  • + Learn more about configuring the Mutelist{" "} + + here + + . +
  • +
  • + A default Mutelist is used, to exclude certain predefined + resources, if no Mutelist is provided. +
  • +
+
+ +
+ +
+