diff --git a/.env b/.env index 1fa497f3e6..87ecd8ecd2 100644 --- a/.env +++ b/.env @@ -24,6 +24,10 @@ POSTGRES_USER=prowler POSTGRES_PASSWORD=postgres POSTGRES_DB=prowler_db +# Celery-Prowler task settings +TASK_RETRY_DELAY_SECONDS=0.1 +TASK_RETRY_ATTEMPTS=5 + # Valkey settings # If running Valkey and celery on host, use localhost, else use 'valkey' VALKEY_HOST=valkey @@ -133,3 +137,7 @@ SOCIAL_GOOGLE_OAUTH_CLIENT_SECRET="" SOCIAL_GITHUB_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/github" SOCIAL_GITHUB_OAUTH_CLIENT_ID="" SOCIAL_GITHUB_OAUTH_CLIENT_SECRET="" + +# Single Sign-On (SSO) +SAML_PUBLIC_CERT="" +SAML_PRIVATE_KEY="" diff --git a/.github/workflows/api-build-lint-push-containers.yml b/.github/workflows/api-build-lint-push-containers.yml index 953bd032f1..2165eb7263 100644 --- a/.github/workflows/api-build-lint-push-containers.yml +++ b/.github/workflows/api-build-lint-push-containers.yml @@ -81,7 +81,7 @@ jobs: - name: Build and push container image (latest) # Comment the following line for testing if: github.event_name == 'push' - uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: ${{ env.WORKING_DIRECTORY }} # Set push: false for testing @@ -94,7 +94,7 @@ jobs: - name: Build and push container image (release) if: github.event_name == 'release' - uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: ${{ env.WORKING_DIRECTORY }} push: true diff --git a/.github/workflows/api-codeql.yml b/.github/workflows/api-codeql.yml index 95b044007b..620d9cbaed 100644 --- a/.github/workflows/api-codeql.yml +++ b/.github/workflows/api-codeql.yml @@ -48,12 +48,12 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16 + uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/api-codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16 + uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/api-pull-request.yml b/.github/workflows/api-pull-request.yml index 604c878bf6..046a6048ef 100644 --- a/.github/workflows/api-pull-request.yml +++ b/.github/workflows/api-pull-request.yml @@ -113,6 +113,12 @@ jobs: python-version: ${{ matrix.python-version }} cache: "poetry" + - name: Install system dependencies for xmlsec + if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true' + run: | + sudo apt-get update + sudo apt-get install -y libxml2-dev libxmlsec1-dev libxmlsec1-openssl pkg-config + - name: Install dependencies working-directory: ./api if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true' @@ -131,6 +137,12 @@ 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' @@ -181,7 +193,7 @@ jobs: - name: Upload coverage reports to Codecov if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true' - uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2 + uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: @@ -193,7 +205,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 - name: Build Container - uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: ${{ env.API_WORKING_DIR }} push: false diff --git a/.github/workflows/find-secrets.yml b/.github/workflows/find-secrets.yml index a6240297b6..9892364e37 100644 --- a/.github/workflows/find-secrets.yml +++ b/.github/workflows/find-secrets.yml @@ -11,7 +11,7 @@ jobs: with: fetch-depth: 0 - name: TruffleHog OSS - uses: trufflesecurity/trufflehog@b06f6d72a3791308bb7ba59c2b8cb7a083bd17e4 # v3.88.26 + uses: trufflesecurity/trufflehog@90694bf9af66e7536abc5824e7a87246dbf933cb # v3.88.35 with: path: ./ base: ${{ github.event.repository.default_branch }} diff --git a/.github/workflows/pull-request-merged.yml b/.github/workflows/pull-request-merged.yml index 090950f79b..7bc05dc3c4 100644 --- a/.github/workflows/pull-request-merged.yml +++ b/.github/workflows/pull-request-merged.yml @@ -18,7 +18,7 @@ jobs: - name: Set short git commit SHA id: vars run: | - shortSha=$(git rev-parse --short ${{ github.sha }}) + shortSha=$(git rev-parse --short ${{ github.event.pull_request.merge_commit_sha }}) echo "SHORT_SHA=${shortSha}" >> $GITHUB_ENV - name: Trigger pull request @@ -28,7 +28,7 @@ jobs: repository: ${{ secrets.CLOUD_DISPATCH }} event-type: prowler-pull-request-merged client-payload: '{ - "PROWLER_COMMIT_SHA": "${{ github.sha }}", + "PROWLER_COMMIT_SHA": "${{ github.event.pull_request.merge_commit_sha }}", "PROWLER_COMMIT_SHORT_SHA": "${{ env.SHORT_SHA }}", "PROWLER_PR_TITLE": "${{ github.event.pull_request.title }}", "PROWLER_PR_LABELS": ${{ toJson(github.event.pull_request.labels.*.name) }}, diff --git a/.github/workflows/sdk-build-lint-push-containers.yml b/.github/workflows/sdk-build-lint-push-containers.yml index f4314055ae..8182ef4c04 100644 --- a/.github/workflows/sdk-build-lint-push-containers.yml +++ b/.github/workflows/sdk-build-lint-push-containers.yml @@ -127,7 +127,7 @@ jobs: - name: Build and push container image (latest) if: github.event_name == 'push' - uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: push: true tags: | @@ -140,7 +140,7 @@ jobs: - name: Build and push container image (release) if: github.event_name == 'release' - uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: # Use local context to get changes # https://github.com/docker/build-push-action#path-context diff --git a/.github/workflows/sdk-codeql.yml b/.github/workflows/sdk-codeql.yml index 5634ead1e6..4420f5823d 100644 --- a/.github/workflows/sdk-codeql.yml +++ b/.github/workflows/sdk-codeql.yml @@ -56,12 +56,12 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16 + uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/sdk-codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16 + uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/sdk-pull-request.yml b/.github/workflows/sdk-pull-request.yml index 1fa3e9c592..61768af5ef 100644 --- a/.github/workflows/sdk-pull-request.yml +++ b/.github/workflows/sdk-pull-request.yml @@ -226,7 +226,7 @@ jobs: # Codecov - name: Upload coverage reports to Codecov if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true' - uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2 + uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: diff --git a/.github/workflows/sdk-refresh-aws-services-regions.yml b/.github/workflows/sdk-refresh-aws-services-regions.yml index 8ebc1b5db0..036b75bb84 100644 --- a/.github/workflows/sdk-refresh-aws-services-regions.yml +++ b/.github/workflows/sdk-refresh-aws-services-regions.yml @@ -38,7 +38,7 @@ jobs: pip install boto3 - name: Configure AWS Credentials -- DEV - uses: aws-actions/configure-aws-credentials@ececac1a45f3b08a01d2dd070d28d111c5fe6722 # v4.1.0 + uses: aws-actions/configure-aws-credentials@b47578312673ae6fa5b5096b330d9fbac3d116df # v4.2.1 with: aws-region: ${{ env.AWS_REGION_DEV }} role-to-assume: ${{ secrets.DEV_IAM_ROLE_ARN }} diff --git a/.github/workflows/ui-build-lint-push-containers.yml b/.github/workflows/ui-build-lint-push-containers.yml index 6de0e6abf3..74cf23499f 100644 --- a/.github/workflows/ui-build-lint-push-containers.yml +++ b/.github/workflows/ui-build-lint-push-containers.yml @@ -81,7 +81,7 @@ jobs: - name: Build and push container image (latest) # Comment the following line for testing if: github.event_name == 'push' - uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: ${{ env.WORKING_DIRECTORY }} build-args: | @@ -96,7 +96,7 @@ jobs: - name: Build and push container image (release) if: github.event_name == 'release' - uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: ${{ env.WORKING_DIRECTORY }} build-args: | diff --git a/.github/workflows/ui-codeql.yml b/.github/workflows/ui-codeql.yml index a43f59c245..ef95253c5f 100644 --- a/.github/workflows/ui-codeql.yml +++ b/.github/workflows/ui-codeql.yml @@ -48,12 +48,12 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16 + uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 with: languages: ${{ matrix.language }} config-file: ./.github/codeql/ui-codeql-config.yml - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@28deaeda66b76a05916b6923827895f2b14ab387 # v3.28.16 + uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/ui-pull-request.yml b/.github/workflows/ui-pull-request.yml index 942a4efade..f217f31c3b 100644 --- a/.github/workflows/ui-pull-request.yml +++ b/.github/workflows/ui-pull-request.yml @@ -50,7 +50,7 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 - name: Build Container - uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0 + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: ${{ env.UI_WORKING_DIR }} # Always build using `prod` target diff --git a/README.md b/README.md index 9064d36891..5323e98135 100644 --- a/README.md +++ b/README.md @@ -86,21 +86,27 @@ prowler dashboard | Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) | |---|---|---|---|---| -| AWS | 564 | 82 | 34 | 10 | -| GCP | 79 | 13 | 7 | 3 | -| Azure | 140 | 18 | 8 | 3 | +| AWS | 567 | 82 | 36 | 10 | +| GCP | 79 | 13 | 10 | 3 | +| Azure | 142 | 18 | 10 | 3 | | Kubernetes | 83 | 7 | 5 | 7 | -| GitHub | 3 | 2 | 1 | 0 | -| M365 | 44 | 2 | 2 | 0 | +| GitHub | 16 | 2 | 1 | 0 | +| M365 | 69 | 7 | 2 | 2 | | NHN (Unofficial) | 6 | 2 | 1 | 0 | +> [!Note] +> The numbers in the table are updated periodically. + +> [!Tip] +> For the most accurate and up-to-date information about checks, services, frameworks, and categories, visit [**Prowler Hub**](https://hub.prowler.com). + +> [!Note] > Use the following commands to list Prowler's available checks, services, compliance frameworks, and categories: `prowler --list-checks`, `prowler --list-services`, `prowler --list-compliance` and `prowler --list-categories`. # 💻 Installation ## Prowler App -Installing Prowler App Prowler App offers flexible installation methods tailored to various environments: > For detailed instructions on using Prowler App, refer to the [Prowler App Usage Guide](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app/). diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 2939de72d7..494d712c1a 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -5,14 +5,52 @@ All notable changes to the **Prowler API** are documented in this file. ## [v1.9.0] (Prowler UNRELEASED) ### Added +- Added SSO with SAML support [(#7822)](https://github.com/prowler-cloud/prowler/pull/7822). +- Support GCP Service Account key. [(#7824)](https://github.com/prowler-cloud/prowler/pull/7824) +- Added new `GET /compliance-overviews` endpoints to retrieve compliance metadata and specific requirements statuses [(#7877)](https://github.com/prowler-cloud/prowler/pull/7877). - Support for read only replicas in the database [(#7210)](https://github.com/prowler-cloud/prowler/pull/7210). +### Changed +- Reworked `GET /compliance-overviews` to return proper requirement metrics [(#7877)](https://github.com/prowler-cloud/prowler/pull/7877). + +--- + +## [v1.8.4] (Prowler v5.7.4) + +### Removed +- Reverted RLS transaction handling and DB custom backend [(#7994)](https://github.com/prowler-cloud/prowler/pull/7994). + +--- + +## [v1.8.3] (Prowler v5.7.3) + +### Added +- Database backend to handle already closed connections [(#7935)](https://github.com/prowler-cloud/prowler/pull/7935). + +### Changed +- Renamed field encrypted_password to password for M365 provider [(#7784)](https://github.com/prowler-cloud/prowler/pull/7784) + +### Fixed +- Fixed transaction persistence with RLS operations [(#7916)](https://github.com/prowler-cloud/prowler/pull/7916). +- Reverted the change `get_with_retry` to use the original `get` method for retrieving tasks [(#7932)](https://github.com/prowler-cloud/prowler/pull/7932). + +--- + +## [v1.8.2] (Prowler v5.7.2) + +### Fixed +- Fixed task lookup to use task_kwargs instead of task_args for scan report resolution. [(#7830)](https://github.com/prowler-cloud/prowler/pull/7830) +- Fixed Kubernetes UID validation to allow valid context names [(#7871)](https://github.com/prowler-cloud/prowler/pull/7871) +- Fixed the connection status verification before launching a scan [(#7831)](https://github.com/prowler-cloud/prowler/pull/7831) +- Fixed a race condition when creating background tasks [(#7876)](https://github.com/prowler-cloud/prowler/pull/7876). +- Fixed an error when modifying or retrieving tenants due to missing user UUID in transaction context [(#7890)](https://github.com/prowler-cloud/prowler/pull/7890). + --- ## [v1.8.1] (Prowler v5.7.1) ### Fixed -- Added database index to improve performance on finding lookup. [(#7800)](https://github.com/prowler-cloud/prowler/pull/7800) +- Added database index to improve performance on finding lookup [(#7800)](https://github.com/prowler-cloud/prowler/pull/7800). --- diff --git a/api/Dockerfile b/api/Dockerfile index 8291b14295..d2e31faefc 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -6,7 +6,19 @@ ARG POWERSHELL_VERSION=7.5.0 ENV POWERSHELL_VERSION=${POWERSHELL_VERSION} # hadolint ignore=DL3008 -RUN apt-get update && apt-get install -y --no-install-recommends wget libicu72 \ +RUN apt-get update && apt-get install -y --no-install-recommends \ + wget \ + libicu72 \ + gcc \ + g++ \ + make \ + libxml2-dev \ + libxmlsec1-dev \ + libxmlsec1-openssl \ + pkg-config \ + libtool \ + libxslt1-dev \ + python3-dev \ && rm -rf /var/lib/apt/lists/* # Install PowerShell @@ -37,18 +49,21 @@ COPY pyproject.toml ./ RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir poetry -COPY src/backend/ ./backend/ - ENV PATH="/home/prowler/.local/bin:$PATH" # Add `--no-root` to avoid installing the current project as a package RUN poetry install --no-root && \ rm -rf ~/.cache/pip -COPY docker-entrypoint.sh ./docker-entrypoint.sh - 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 + WORKDIR /home/prowler/backend # Development image diff --git a/api/docker-compose.yml b/api/docker-compose.yml deleted file mode 100644 index b344736152..0000000000 --- a/api/docker-compose.yml +++ /dev/null @@ -1,125 +0,0 @@ -services: - api: - build: - dockerfile: Dockerfile - image: prowler-api - env_file: - - path: ./.env - required: false - ports: - - "${DJANGO_PORT:-8000}:${DJANGO_PORT:-8000}" - profiles: - - prod - depends_on: - postgres: - condition: service_healthy - valkey: - condition: service_healthy - entrypoint: - - "../docker-entrypoint.sh" - - "prod" - - api-dev: - build: - dockerfile: Dockerfile - target: dev - image: prowler-api-dev - environment: - - DJANGO_SETTINGS_MODULE=config.django.devel - - DJANGO_LOGGING_FORMATTER=human_readable - env_file: - - path: ./.env - required: false - ports: - - "${DJANGO_PORT:-8080}:${DJANGO_PORT:-8080}" - volumes: - - "./src/backend:/home/prowler/backend" - - "./pyproject.toml:/home/prowler/pyproject.toml" - profiles: - - dev - depends_on: - postgres: - condition: service_healthy - valkey: - condition: service_healthy - entrypoint: - - "../docker-entrypoint.sh" - - "dev" - - postgres: - image: postgres:16.3-alpine - ports: - - "${POSTGRES_PORT:-5432}:${POSTGRES_PORT:-5432}" - hostname: "postgres-db" - volumes: - - ./_data/postgres:/var/lib/postgresql/data - environment: - - POSTGRES_USER=${POSTGRES_ADMIN_USER:-prowler} - - POSTGRES_PASSWORD=${POSTGRES_ADMIN_PASSWORD:-S3cret} - - POSTGRES_DB=${POSTGRES_DB:-prowler_db} - env_file: - - path: ./.env - required: false - healthcheck: - test: ["CMD-SHELL", "sh -c 'pg_isready -U ${POSTGRES_ADMIN_USER:-prowler} -d ${POSTGRES_DB:-prowler_db}'"] - interval: 5s - timeout: 5s - retries: 5 - - valkey: - image: valkey/valkey:7-alpine3.19 - ports: - - "${VALKEY_PORT:-6379}:6379" - hostname: "valkey" - volumes: - - ./_data/valkey:/data - env_file: - - path: ./.env - required: false - healthcheck: - test: ["CMD-SHELL", "sh -c 'valkey-cli ping'"] - interval: 10s - timeout: 5s - retries: 3 - - worker: - build: - dockerfile: Dockerfile - image: prowler-worker - environment: - - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-config.django.production} - env_file: - - path: ./.env - required: false - profiles: - - dev - - prod - depends_on: - valkey: - condition: service_healthy - postgres: - condition: service_healthy - entrypoint: - - "../docker-entrypoint.sh" - - "worker" - - worker-beat: - build: - dockerfile: Dockerfile - image: prowler-worker - environment: - - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-config.django.production} - env_file: - - path: ./.env - required: false - profiles: - - dev - - prod - depends_on: - valkey: - condition: service_healthy - postgres: - condition: service_healthy - entrypoint: - - "../docker-entrypoint.sh" - - "beat" diff --git a/api/docker-entrypoint.sh b/api/docker-entrypoint.sh index 0dff279d08..4ff9cc4cd1 100755 --- a/api/docker-entrypoint.sh +++ b/api/docker-entrypoint.sh @@ -3,6 +3,10 @@ apply_migrations() { echo "Applying database migrations..." + + # Fix Inconsistent migration history after adding sites app + poetry run python manage.py check_and_fix_socialaccount_sites_migration --database admin + poetry run python manage.py migrate --database admin } diff --git a/api/poetry.lock b/api/poetry.lock index a71c16b14b..c961c7f73f 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1469,14 +1469,14 @@ with-social = ["django-allauth[socialaccount] (>=64.0.0)"] [[package]] name = "django" -version = "5.1.8" +version = "5.1.10" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.10" groups = ["main", "dev"] files = [ - {file = "Django-5.1.8-py3-none-any.whl", hash = "sha256:11b28fa4b00e59d0def004e9ee012fefbb1065a5beb39ee838983fd24493ad4f"}, - {file = "Django-5.1.8.tar.gz", hash = "sha256:42e92a1dd2810072bcc40a39a212b693f94406d0ba0749e68eb642f31dc770b4"}, + {file = "django-5.1.10-py3-none-any.whl", hash = "sha256:19c9b771e9cf4de91101861aadd2daaa159bcf10698ca909c5755c88e70ccb84"}, + {file = "django-5.1.10.tar.gz", hash = "sha256:73e5d191421d177803dbd5495d94bc7d06d156df9561f4eea9e11b4994c07137"}, ] [package.dependencies] @@ -1490,19 +1490,20 @@ bcrypt = ["bcrypt"] [[package]] name = "django-allauth" -version = "65.4.1" +version = "65.8.0" description = "Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication." optional = false python-versions = ">=3.8" groups = ["main"] files = [ - {file = "django_allauth-65.4.1.tar.gz", hash = "sha256:60b32aef7dbbcc213319aa4fd8f570e985266ea1162ae6ef7a26a24efca85c8c"}, + {file = "django_allauth-65.8.0.tar.gz", hash = "sha256:9da589d99d412740629333a01865a90c95c97e0fae0cde789aa45a8fda90e83b"}, ] [package.dependencies] asgiref = ">=3.8.1" Django = ">=4.2.16" pyjwt = {version = ">=1.7", extras = ["crypto"], optional = true, markers = "extra == \"socialaccount\""} +python3-saml = {version = ">=1.15.0,<2.0.0", optional = true, markers = "extra == \"saml\""} requests = {version = ">=2.0.0", optional = true, markers = "extra == \"socialaccount\""} requests-oauthlib = {version = ">=0.3.0", optional = true, markers = "extra == \"socialaccount\""} @@ -2582,6 +2583,155 @@ websocket-client = ">=0.32.0,<0.40.0 || >0.40.0,<0.41.dev0 || >=0.43.dev0" [package.extras] adal = ["adal (>=1.0.2)"] +[[package]] +name = "lxml" +version = "5.4.0" +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"}, +] + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html-clean = ["lxml_html_clean"] +html5 = ["html5lib"] +htmlsoup = ["BeautifulSoup4"] +source = ["Cython (>=3.0.11,<3.1.0)"] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -4236,6 +4386,27 @@ files = [ {file = "python_memcached-1.62-py2.py3-none-any.whl", hash = "sha256:1bdd8d2393ff53e80cd5e9442d750e658e0b35c3eebb3211af137303e3b729d1"}, ] +[[package]] +name = "python3-saml" +version = "1.16.0" +description = "Saml Python Toolkit. Add SAML support to your Python software using this library" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "python3-saml-1.16.0.tar.gz", hash = "sha256:97c9669aecabc283c6e5fb4eb264f446b6e006f5267d01c9734f9d8bffdac133"}, + {file = "python3_saml-1.16.0-py2-none-any.whl", hash = "sha256:c49097863c278ff669a337a96c46dc1f25d16307b4bb2679d2d1733cc4f5176a"}, + {file = "python3_saml-1.16.0-py3-none-any.whl", hash = "sha256:20b97d11b04f01ee22e98f4a38242e2fea2e28fbc7fbc9bdd57cab5ac7fc2d0d"}, +] + +[package.dependencies] +isodate = ">=0.6.1" +lxml = ">=4.6.5,<4.7.0 || >4.7.0" +xmlsec = ">=1.3.9" + +[package.extras] +test = ["coverage (>=4.5.2)", "flake8 (>=3.6.0,<=5.0.0)", "freezegun (>=0.3.11,<=1.1.0)", "pytest (>=4.6)"] + [[package]] name = "pytz" version = "2025.1" @@ -5341,6 +5512,42 @@ files = [ {file = "xlsxwriter-3.2.3.tar.gz", hash = "sha256:ad6fd41bdcf1b885876b1f6b7087560aecc9ae5a9cc2ba97dcac7ab2e210d3d5"}, ] +[[package]] +name = "xmlsec" +version = "1.3.15" +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"}, +] + +[package.dependencies] +lxml = ">=3.8" + [[package]] name = "yarl" version = "1.20.0" @@ -5483,4 +5690,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.13" -content-hash = "051924735a7069c8393fefc18fc2c310b196ea24ad41b8c984dc5852683d0407" +content-hash = "e25a4a8a62a8bad15ecb770bd3a6038e23ae545f55944b067816f59b3a96d5ca" diff --git a/api/pyproject.toml b/api/pyproject.toml index 19190f8893..fb58826690 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -7,8 +7,8 @@ authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}] dependencies = [ "celery[pytest] (>=5.4.0,<6.0.0)", "dj-rest-auth[with_social,jwt] (==7.0.1)", - "django==5.1.8", - "django-allauth==65.4.1", + "django==5.1.10", + "django-allauth[saml] (>=65.8.0,<66.0.0)", "django-celery-beat (>=2.7.0,<3.0.0)", "django-celery-results (>=2.5.1,<3.0.0)", "django-cors-headers==4.4.0", @@ -35,7 +35,7 @@ name = "prowler-api" package-mode = false # Needed for the SDK compatibility requires-python = ">=3.11,<3.13" -version = "1.8.1" +version = "1.9.0" [project.scripts] celery = "src.backend.config.settings.celery" diff --git a/api/src/backend/api/adapters.py b/api/src/backend/api/adapters.py index 7ccda0336c..3cee70761f 100644 --- a/api/src/backend/api/adapters.py +++ b/api/src/backend/api/adapters.py @@ -3,7 +3,14 @@ from django.db import transaction from api.db_router import MainRouter from api.db_utils import rls_transaction -from api.models import Membership, Role, Tenant, User, UserRoleRelationship +from api.models import ( + Membership, + Role, + SAMLConfiguration, + Tenant, + User, + UserRoleRelationship, +) class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): @@ -17,6 +24,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 email: existing_user = self.get_user_by_email(email) if existing_user: @@ -29,33 +38,86 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): """ with transaction.atomic(using=MainRouter.admin_db): user = super().save_user(request, sociallogin, form) - user.save(using=MainRouter.admin_db) - social_account_name = sociallogin.account.extra_data.get("name") - if social_account_name: - user.name = social_account_name + provider = sociallogin.account.provider + extra = sociallogin.account.extra_data + + if provider == "saml": + # Handle SAML-specific logic + user.first_name = extra.get("firstName", [""])[0] + user.last_name = extra.get("lastName", [""])[0] + user.company_name = extra.get("organization", [""])[0] + user.name = f"{user.first_name} {user.last_name}".strip() 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 + email_domain = user.email.split("@")[-1] + tenant = ( + SAMLConfiguration.objects.using(MainRouter.admin_db) + .get(email_domain=email_domain) + .tenant ) - 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, + + with rls_transaction(str(tenant.id)): + role_name = extra.get("userType", ["saml_default_role"])[0].strip() + + 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, + ) + + else: + # 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, + ) + return user diff --git a/api/src/backend/api/base_views.py b/api/src/backend/api/base_views.py index 3e965482df..605afe332d 100644 --- a/api/src/backend/api/base_views.py +++ b/api/src/backend/api/base_views.py @@ -109,16 +109,6 @@ class BaseTenantViewset(BaseViewSet): pass # Tenant might not exist, handle gracefully def initial(self, request, *args, **kwargs): - if ( - request.resolver_match.url_name != "tenant-detail" - and request.method != "DELETE" - ): - user_id = str(request.user.id) - - with rls_transaction(value=user_id, parameter=POSTGRES_USER_VAR): - return super().initial(request, *args, **kwargs) - - # TODO: DRY this when we have time if request.auth is None: raise NotAuthenticated @@ -126,8 +116,8 @@ class BaseTenantViewset(BaseViewSet): if tenant_id is None: raise NotAuthenticated("Tenant ID is not present in token") - with rls_transaction(tenant_id): - self.request.tenant_id = tenant_id + user_id = str(request.user.id) + with rls_transaction(value=user_id, parameter=POSTGRES_USER_VAR): return super().initial(request, *args, **kwargs) diff --git a/api/src/backend/api/compliance.py b/api/src/backend/api/compliance.py index 96b5e313f3..46e7211962 100644 --- a/api/src/backend/api/compliance.py +++ b/api/src/backend/api/compliance.py @@ -190,6 +190,8 @@ def generate_compliance_overview_template(prowler_compliance: dict): total_checks = len(requirement.Checks) checks_dict = {check: None for check in requirement.Checks} + req_status_val = "MANUAL" if total_checks == 0 else "PASS" + # Build requirement dictionary requirement_dict = { "name": requirement.Name or requirement.Id, @@ -204,20 +206,18 @@ def generate_compliance_overview_template(prowler_compliance: dict): "manual": 0, "total": total_checks, }, - "status": "PASS", + "status": req_status_val, } - # Update requirements status - if total_checks == 0: + # Update requirements status counts for the framework + if req_status_val == "MANUAL": requirements_status["manual"] += 1 + elif req_status_val == "PASS": + requirements_status["passed"] += 1 # Add requirement to compliance requirements compliance_requirements[requirement.Id] = requirement_dict - # Calculate pending requirements - pending_requirements = total_requirements - requirements_status["manual"] - requirements_status["passed"] = pending_requirements - # Build compliance dictionary compliance_dict = { "framework": compliance_data.Framework, diff --git a/api/src/backend/api/db_utils.py b/api/src/backend/api/db_utils.py index ca98b6b592..d163a02fd3 100644 --- a/api/src/backend/api/db_utils.py +++ b/api/src/backend/api/db_utils.py @@ -1,3 +1,4 @@ +import re import secrets import uuid from contextlib import contextmanager @@ -152,6 +153,28 @@ def delete_related_daily_task(provider_id: str): PeriodicTask.objects.filter(name=task_name).delete() +def create_objects_in_batches( + tenant_id: str, model, objects: list, batch_size: int = 500 +): + """ + Bulk-create model instances in repeated, per-tenant RLS transactions. + + All chunks execute in their own transaction, so no single transaction + grows too large. + + Args: + tenant_id (str): UUID string of the tenant under which to set RLS. + model: Django model class whose `.objects.bulk_create()` will be called. + objects (list): List of model instances (unsaved) to bulk-create. + batch_size (int): Maximum number of objects per bulk_create call. + """ + total = len(objects) + for i in range(0, total, batch_size): + chunk = objects[i : i + batch_size] + with rls_transaction(value=tenant_id, parameter=POSTGRES_TENANT_VAR): + model.objects.bulk_create(chunk, batch_size) + + # Postgres Enums @@ -227,6 +250,72 @@ def register_enum(apps, schema_editor, enum_class): # noqa: F841 register_adapter(enum_class, enum_adapter) +def _should_create_index_on_partition( + partition_name: str, all_partitions: bool = False +) -> bool: + """ + Determine if we should create an index on this partition. + + Args: + partition_name: The name of the partition (e.g., "findings_2025_aug", "findings_default") + all_partitions: If True, create on all partitions. If False, only current/future partitions. + + Returns: + bool: True if index should be created on this partition, False otherwise. + """ + if all_partitions: + return True + + # Extract date from partition name if it follows the pattern + # Partition names look like: findings_2025_aug, findings_2025_jul, etc. + date_pattern = r"(\d{4})_([a-z]{3})$" + match = re.search(date_pattern, partition_name) + + if not match: + # If we can't parse the date, include it to be safe (e.g., default partition) + return True + + try: + year_str, month_abbr = match.groups() + year = int(year_str) + + # Map month abbreviations to numbers + month_map = { + "jan": 1, + "feb": 2, + "mar": 3, + "apr": 4, + "may": 5, + "jun": 6, + "jul": 7, + "aug": 8, + "sep": 9, + "oct": 10, + "nov": 11, + "dec": 12, + } + + month = month_map.get(month_abbr.lower()) + if month is None: + # Unknown month abbreviation, include it to be safe + return True + + partition_date = datetime(year, month, 1, tzinfo=timezone.utc) + + # Get current month start + now = datetime.now(timezone.utc) + current_month_start = now.replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + + # Include current month and future partitions + return partition_date >= current_month_start + + except (ValueError, TypeError): + # If date parsing fails, include it to be safe + return True + + def create_index_on_partitions( apps, # noqa: F841 schema_editor, @@ -235,16 +324,39 @@ def create_index_on_partitions( columns: str, method: str = "BTREE", where: str = "", + all_partitions: bool = True, ): """ - Create an index on every existing partition of `parent_table`. + Create an index on existing partitions of `parent_table`. Args: parent_table: The name of the root table (e.g. "findings"). index_name: A short name for the index (will be prefixed per-partition). columns: The parenthesized column list, e.g. "tenant_id, scan_id, status". - method: The index method—BTREE, GIN, etc. Defaults to BTREE. - where: Optional WHERE clause (without the leading "WHERE"), e.g. "status = 'FAIL'". + method: The index method—BTREE, GIN, etc. Defaults to BTREE. + where: Optional WHERE clause (without the leading "WHERE"), e.g. "status = 'FAIL'". + all_partitions: Whether to create indexes on all partitions or just current/future ones. + Defaults to False (current/future only) to avoid maintenance overhead + on old partitions where the index may not be needed. + + Examples: + # Create index only on current and future partitions (recommended for new indexes) + create_index_on_partitions( + apps, schema_editor, + parent_table="findings", + index_name="new_performance_idx", + columns="tenant_id, status, severity", + all_partitions=False # Default behavior + ) + + # Create index on all partitions (use when migrating existing critical indexes) + create_index_on_partitions( + apps, schema_editor, + parent_table="findings", + index_name="critical_existing_idx", + columns="tenant_id, scan_id", + all_partitions=True + ) """ with connection.cursor() as cursor: cursor.execute( @@ -259,13 +371,14 @@ def create_index_on_partitions( where_sql = f" WHERE {where}" if where else "" for partition in partitions: - idx_name = f"{partition.replace('.', '_')}_{index_name}" - sql = ( - f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {idx_name} " - f"ON {partition} USING {method} ({columns})" - f"{where_sql};" - ) - schema_editor.execute(sql) + if _should_create_index_on_partition(partition, all_partitions): + idx_name = f"{partition.replace('.', '_')}_{index_name}" + sql = ( + f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {idx_name} " + f"ON {partition} USING {method} ({columns})" + f"{where_sql};" + ) + schema_editor.execute(sql) def drop_index_on_partitions( @@ -279,7 +392,7 @@ def drop_index_on_partitions( Args: parent_table: The name of the root table (e.g. "findings"). - index_name: The same short name used when creating them. + index_name: The same short name used when creating them. """ with connection.cursor() as cursor: cursor.execute( diff --git a/api/src/backend/api/exceptions.py b/api/src/backend/api/exceptions.py index 12bc788d68..14f7227d9a 100644 --- a/api/src/backend/api/exceptions.py +++ b/api/src/backend/api/exceptions.py @@ -3,7 +3,7 @@ from rest_framework import status from rest_framework.exceptions import APIException from rest_framework_json_api.exceptions import exception_handler from rest_framework_json_api.serializers import ValidationError -from rest_framework_simplejwt.exceptions import TokenError, InvalidToken +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError class ModelValidationError(ValidationError): @@ -32,6 +32,31 @@ class InvitationTokenExpiredException(APIException): default_code = "token_expired" +# Task Management Exceptions (non-HTTP) +class TaskManagementError(Exception): + """Base exception for task management errors.""" + + def __init__(self, task=None): + self.task = task + super().__init__() + + +class TaskFailedException(TaskManagementError): + """Raised when a task has failed.""" + + +class TaskNotFoundException(TaskManagementError): + """Raised when a task is not found.""" + + +class TaskInProgressException(TaskManagementError): + """Raised when a task is running but there's no related Task object to return.""" + + def __init__(self, task_result=None): + self.task_result = task_result + super().__init__() + + def custom_exception_handler(exc, context): if isinstance(exc, django_validation_error): if hasattr(exc, "error_dict"): @@ -39,7 +64,12 @@ def custom_exception_handler(exc, context): else: exc = ValidationError(detail=exc.messages[0], code=exc.code) elif isinstance(exc, (TokenError, InvalidToken)): - exc.detail["messages"] = [ - message_item["message"] for message_item in exc.detail["messages"] - ] + if ( + hasattr(exc, "detail") + and isinstance(exc.detail, dict) + and "messages" in exc.detail + ): + exc.detail["messages"] = [ + message_item["message"] for message_item in exc.detail["messages"] + ] return exception_handler(exc, context) diff --git a/api/src/backend/api/filters.py b/api/src/backend/api/filters.py index 9e95016e1d..35ebb6a611 100644 --- a/api/src/backend/api/filters.py +++ b/api/src/backend/api/filters.py @@ -22,7 +22,7 @@ from api.db_utils import ( StatusEnumField, ) from api.models import ( - ComplianceOverview, + ComplianceRequirementOverview, Finding, Integration, Invitation, @@ -637,12 +637,11 @@ class RoleFilter(FilterSet): class ComplianceOverviewFilter(FilterSet): inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date") - provider_type = ChoiceFilter(choices=Provider.ProviderChoices.choices) - provider_type__in = ChoiceInFilter(choices=Provider.ProviderChoices.choices) - scan_id = UUIDFilter(field_name="scan__id") + scan_id = UUIDFilter(field_name="scan_id") + region = CharFilter(field_name="region") class Meta: - model = ComplianceOverview + model = ComplianceRequirementOverview fields = { "inserted_at": ["date", "gte", "lte"], "compliance_id": ["exact", "icontains"], diff --git a/api/src/backend/api/management/commands/check_and_fix_socialaccount_sites_migration.py b/api/src/backend/api/management/commands/check_and_fix_socialaccount_sites_migration.py new file mode 100644 index 0000000000..361780683b --- /dev/null +++ b/api/src/backend/api/management/commands/check_and_fix_socialaccount_sites_migration.py @@ -0,0 +1,80 @@ +from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand +from django.db import DEFAULT_DB_ALIAS, connection, connections, transaction +from django.db.migrations.recorder import MigrationRecorder + + +def table_exists(table_name): + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = %s + ) + """, + [table_name], + ) + return cursor.fetchone()[0] + + +class Command(BaseCommand): + help = "Fix migration inconsistency between socialaccount and sites" + + def add_arguments(self, parser): + parser.add_argument( + "--database", + default=DEFAULT_DB_ALIAS, + help="Specifies the database to operate on.", + ) + + def handle(self, *args, **options): + db = options["database"] + connection = connections[db] + recorder = MigrationRecorder(connection) + + applied = set(recorder.applied_migrations()) + + has_social = ("socialaccount", "0001_initial") in applied + + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'django_site' + ); + """ + ) + site_table_exists = cursor.fetchone()[0] + + if has_social and not site_table_exists: + self.stdout.write( + f"Detected inconsistency in '{db}'. Creating 'django_site' table manually..." + ) + + with transaction.atomic(using=db): + with connection.schema_editor() as schema_editor: + schema_editor.create_model(Site) + + recorder.record_applied("sites", "0001_initial") + recorder.record_applied("sites", "0002_alter_domain_unique") + + self.stdout.write( + "Fixed: 'django_site' table created and migrations registered." + ) + + # Ensure the relationship table also exists + if not table_exists("socialaccount_socialapp_sites"): + self.stdout.write( + "Detected missing 'socialaccount_socialapp_sites' table. Creating manually..." + ) + with connection.schema_editor() as schema_editor: + from allauth.socialaccount.models import SocialApp + + schema_editor.create_model( + SocialApp._meta.get_field("sites").remote_field.through + ) + self.stdout.write( + "Fixed: 'socialaccount_socialapp_sites' table created." + ) diff --git a/api/src/backend/api/migrations/0026_provider_secret_gcp_service_account.py b/api/src/backend/api/migrations/0026_provider_secret_gcp_service_account.py new file mode 100644 index 0000000000..3857dbbd75 --- /dev/null +++ b/api/src/backend/api/migrations/0026_provider_secret_gcp_service_account.py @@ -0,0 +1,14 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0025_findings_uid_index_parent"), + ] + + operations = [ + migrations.RunSQL( + "ALTER TYPE provider_secret_type ADD VALUE IF NOT EXISTS 'service_account';", + reverse_sql=migrations.RunSQL.noop, + ), + ] diff --git a/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py b/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py new file mode 100644 index 0000000000..82bbb136a5 --- /dev/null +++ b/api/src/backend/api/migrations/0027_compliance_requirement_overviews.py @@ -0,0 +1,124 @@ +# Generated by Django 5.1.8 on 2025-05-21 11:37 + +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", "0026_provider_secret_gcp_service_account"), + ] + + operations = [ + migrations.CreateModel( + name="ComplianceRequirementOverview", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ("compliance_id", models.TextField(blank=False)), + ("framework", models.TextField(blank=False)), + ("version", models.TextField(blank=True)), + ("description", models.TextField(blank=True)), + ("region", models.TextField(blank=False)), + ("requirement_id", models.TextField(blank=False)), + ( + "requirement_status", + api.db_utils.StatusEnumField( + choices=[ + ("FAIL", "Fail"), + ("PASS", "Pass"), + ("MANUAL", "Manual"), + ] + ), + ), + ("passed_checks", models.IntegerField(default=0)), + ("failed_checks", models.IntegerField(default=0)), + ("total_checks", models.IntegerField(default=0)), + ( + "scan", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="compliance_requirements_overviews", + related_query_name="compliance_requirements_overview", + to="api.scan", + ), + ), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "compliance_requirements_overviews", + "abstract": False, + "indexes": [ + models.Index( + fields=["tenant_id", "scan_id"], name="cro_tenant_scan_idx" + ), + models.Index( + fields=["tenant_id", "scan_id", "compliance_id"], + name="cro_scan_comp_idx", + ), + models.Index( + fields=["tenant_id", "scan_id", "compliance_id", "region"], + name="cro_scan_comp_reg_idx", + ), + models.Index( + fields=[ + "tenant_id", + "scan_id", + "compliance_id", + "requirement_id", + ], + name="cro_scan_comp_req_idx", + ), + models.Index( + fields=[ + "tenant_id", + "scan_id", + "compliance_id", + "requirement_id", + "region", + ], + name="cro_scan_comp_req_reg_idx", + ), + ], + "constraints": [ + models.UniqueConstraint( + fields=( + "tenant_id", + "scan_id", + "compliance_id", + "requirement_id", + "region", + ), + name="unique_tenant_compliance_requirement_overview", + ) + ], + }, + ), + migrations.AddConstraint( + model_name="ComplianceRequirementOverview", + constraint=RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_compliancerequirementoverview", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/migrations/0028_findings_check_index_partitions.py b/api/src/backend/api/migrations/0028_findings_check_index_partitions.py new file mode 100644 index 0000000000..ad61f3004f --- /dev/null +++ b/api/src/backend/api/migrations/0028_findings_check_index_partitions.py @@ -0,0 +1,29 @@ +from functools import partial + +from django.db import migrations + +from api.db_utils import create_index_on_partitions, drop_index_on_partitions + + +class Migration(migrations.Migration): + atomic = False + + dependencies = [ + ("api", "0027_compliance_requirement_overviews"), + ] + + operations = [ + migrations.RunPython( + partial( + create_index_on_partitions, + parent_table="findings", + index_name="find_tenant_scan_check_idx", + columns="tenant_id, scan_id, check_id", + ), + reverse_code=partial( + drop_index_on_partitions, + parent_table="findings", + index_name="find_tenant_scan_check_idx", + ), + ) + ] diff --git a/api/src/backend/api/migrations/0029_findings_check_index_parent.py b/api/src/backend/api/migrations/0029_findings_check_index_parent.py new file mode 100644 index 0000000000..8b975782f1 --- /dev/null +++ b/api/src/backend/api/migrations/0029_findings_check_index_parent.py @@ -0,0 +1,17 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0028_findings_check_index_partitions"), + ] + + operations = [ + migrations.AddIndex( + model_name="finding", + index=models.Index( + fields=["tenant_id", "scan_id", "check_id"], + name="find_tenant_scan_check_idx", + ), + ), + ] diff --git a/api/src/backend/api/migrations/0030_samlconfigurations.py b/api/src/backend/api/migrations/0030_samlconfigurations.py new file mode 100644 index 0000000000..b5ec1fea1a --- /dev/null +++ b/api/src/backend/api/migrations/0030_samlconfigurations.py @@ -0,0 +1,120 @@ +# Generated by Django 5.1.8 on 2025-05-15 09:54 + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0029_findings_check_index_parent"), + ] + + operations = [ + 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"], + ), + ), + 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.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"), + ] + ), + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index a427bcf9c9..2d006ff223 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -1,13 +1,18 @@ import json import re +import xml.etree.ElementTree as ET from uuid import UUID, uuid4 +from allauth.socialaccount.models import SocialApp +from config.settings.social_login import SOCIALACCOUNT_PROVIDERS from cryptography.fernet import Fernet 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 @@ -19,6 +24,7 @@ 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, @@ -242,7 +248,7 @@ class Provider(RowLevelSecurityProtectedModel): @staticmethod def validate_kubernetes_uid(value): if not re.match( - r"^[a-z0-9][A-Za-z0-9_.:\/-]{1,250}$", + r"^[a-zA-Z0-9][a-zA-Z0-9._@:\/-]{1,250}$", value, ): raise ModelValidationError( @@ -762,6 +768,10 @@ class Finding(PostgresPartitionedModel, RowLevelSecurityProtectedModel): GinIndex(fields=["resource_services"], name="gin_find_service_idx"), GinIndex(fields=["resource_regions"], name="gin_find_region_idx"), GinIndex(fields=["resource_types"], name="gin_find_rtype_idx"), + models.Index( + fields=["tenant_id", "scan_id", "check_id"], + name="find_tenant_scan_check_idx", + ), ] class JSONAPIMeta: @@ -850,6 +860,7 @@ class ProviderSecret(RowLevelSecurityProtectedModel): class TypeChoices(models.TextChoices): STATIC = "static", _("Key-value pairs") ROLE = "role", _("Role assumption") + SERVICE_ACCOUNT = "service_account", _("GCP Service Account Key") id = models.UUIDField(primary_key=True, default=uuid4, editable=False) inserted_at = models.DateTimeField(auto_now_add=True, editable=False) @@ -1142,6 +1153,78 @@ class ComplianceOverview(RowLevelSecurityProtectedModel): resource_name = "compliance-overviews" +class ComplianceRequirementOverview(RowLevelSecurityProtectedModel): + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + compliance_id = models.TextField(blank=False) + framework = models.TextField(blank=False) + version = models.TextField(blank=True) + description = models.TextField(blank=True) + region = models.TextField(blank=False) + + requirement_id = models.TextField(blank=False) + requirement_status = StatusEnumField(choices=StatusChoices) + passed_checks = models.IntegerField(default=0) + failed_checks = models.IntegerField(default=0) + total_checks = models.IntegerField(default=0) + + scan = models.ForeignKey( + Scan, + on_delete=models.CASCADE, + related_name="compliance_requirements_overviews", + related_query_name="compliance_requirements_overview", + ) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "compliance_requirements_overviews" + + constraints = [ + models.UniqueConstraint( + fields=( + "tenant_id", + "scan_id", + "compliance_id", + "requirement_id", + "region", + ), + name="unique_tenant_compliance_requirement_overview", + ), + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "DELETE"], + ), + ] + indexes = [ + models.Index(fields=["tenant_id", "scan_id"], name="cro_tenant_scan_idx"), + models.Index( + fields=["tenant_id", "scan_id", "compliance_id"], + name="cro_scan_comp_idx", + ), + models.Index( + fields=["tenant_id", "scan_id", "compliance_id", "region"], + name="cro_scan_comp_reg_idx", + ), + models.Index( + fields=["tenant_id", "scan_id", "compliance_id", "requirement_id"], + name="cro_scan_comp_req_idx", + ), + models.Index( + fields=[ + "tenant_id", + "scan_id", + "compliance_id", + "requirement_id", + "region", + ], + name="cro_scan_comp_req_reg_idx", + ), + ] + + class JSONAPIMeta: + resource_name = "compliance-requirements-overviews" + + class ScanSummary(RowLevelSecurityProtectedModel): objects = ActiveProviderManager() all_objects = models.Manager() @@ -1210,7 +1293,6 @@ class ScanSummary(RowLevelSecurityProtectedModel): class Integration(RowLevelSecurityProtectedModel): class IntegrationChoices(models.TextChoices): S3 = "amazon_s3", _("Amazon S3") - SAML = "saml", _("SAML") AWS_SECURITY_HUB = "aws_security_hub", _("AWS Security Hub") JIRA = "jira", _("JIRA") SLACK = "slack", _("Slack") @@ -1284,6 +1366,221 @@ class IntegrationProviderRelationship(RowLevelSecurityProtectedModel): ] +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) + + 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"], + ), + ] + + +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. + + 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. + + 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) + + class JSONAPIMeta: + resource_name = "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", + ), + ] + + def clean(self, old_email_domain=None): + # 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."} + ) + + # 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() + + 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 + + self.clean(old_email_domain) + super().save(*args, **kwargs) + + if is_create or ( + old_email_domain != self.email_domain + or old_metadata_xml != self.metadata_xml + ): + self._sync_social_app(old_email_domain) + + # 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() + + # Create/update the new domain index + SAMLDomainIndex.objects.update_or_create( + email_domain=self.email_domain, defaults={"tenant": self.tenant} + ) + + 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}"}) + + # Entity ID + entity_id = root.attrib.get("entityID") + + # 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"] + + # SLO endpoint (optional) + slo = root.find(".//md:IDPSSODescriptor/md:SingleLogoutService", ns) + slo_url = slo.attrib.get("Location") if slo is not None else None + + # 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. + """ + idp_settings = self._parse_metadata() + settings_dict = SOCIALACCOUNT_PROVIDERS["saml"].copy() + settings_dict["idp"] = idp_settings + + 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 + ) + + if social_app_qs.exists(): + social_app = social_app_qs.first() + social_app.client_id = self.email_domain + social_app.name = f"{self.tenant.name} SAML ({self.email_domain})" + social_app.settings = settings_dict + social_app.save() + social_app.sites.set([current_site]) + else: + social_app = SocialApp.objects.create( + provider="saml", + client_id=self.email_domain, + name=f"{self.tenant.name} SAML ({self.email_domain})", + settings=settings_dict, + ) + social_app.sites.set([current_site]) + + class ResourceScanSummary(RowLevelSecurityProtectedModel): scan_id = models.UUIDField(default=uuid7, db_index=True) resource_id = models.UUIDField(default=uuid4, db_index=True) diff --git a/api/src/backend/api/pagination.py b/api/src/backend/api/pagination.py index 8f37c9ba78..b9742416bb 100644 --- a/api/src/backend/api/pagination.py +++ b/api/src/backend/api/pagination.py @@ -1,4 +1,4 @@ -from rest_framework_json_api.pagination import JsonApiPageNumberPagination +from drf_spectacular_jsonapi.schemas.pagination import JsonApiPageNumberPagination class ComplianceOverviewPagination(JsonApiPageNumberPagination): diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 3f90c28cb1..5e8b3f3462 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Prowler API - version: 1.8.1 + version: 1.9.0 description: |- Prowler API specification. @@ -10,9 +10,7 @@ paths: /api/v1/compliance-overviews: get: operationId: compliance_overviews_list - description: Retrieve an overview of all the compliance in a given scan. If - no region filters are provided, the region with the most fails will be returned - by default. + description: Retrieve an overview of all the compliance in a given scan. summary: List compliance overviews for a scan parameters: - in: query @@ -22,15 +20,13 @@ paths: items: type: string enum: - - inserted_at - - compliance_id + - id - framework - version - - requirements_status - - region - - provider_type - - scan - - url + - requirements_passed + - requirements_failed + - requirements_manual + - total_requirements description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false @@ -74,44 +70,6 @@ paths: schema: type: string format: date-time - - in: query - name: filter[provider_type] - schema: - type: string - enum: - - aws - - azure - - gcp - - kubernetes - - m365 - description: |- - * `aws` - AWS - * `azure` - Azure - * `gcp` - GCP - * `kubernetes` - Kubernetes - * `m365` - M365 - - in: query - name: filter[provider_type__in] - schema: - type: array - items: - type: string - enum: - - aws - - azure - - gcp - - kubernetes - - m365 - description: |- - Multiple values may be separated by commas. - - * `aws` - AWS - * `azure` - Azure - * `gcp` - GCP - * `kubernetes` - Kubernetes - * `m365` - M365 - explode: false - style: form - in: query name: filter[region] schema: @@ -171,14 +129,8 @@ paths: items: type: string enum: - - inserted_at - - -inserted_at - compliance_id - -compliance_id - - framework - - -framework - - region - - -region explode: false tags: - Compliance Overview @@ -190,41 +142,43 @@ paths: application/vnd.api+json: schema: $ref: '#/components/schemas/PaginatedComplianceOverviewList' - description: '' - /api/v1/compliance-overviews/{id}: + description: Compliance overviews obtained successfully + '202': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedTaskList' + description: The task is in progress + '500': + description: Compliance overviews generation task failed + /api/v1/compliance-overviews/attributes: get: - operationId: compliance_overviews_retrieve - description: Fetch detailed information about a specific compliance overview - by its ID, including detailed requirement information and check's status. - summary: Retrieve data from a specific compliance overview + operationId: compliance_overviews_attributes_retrieve + description: Retrieve detailed attribute information for all requirements in + a specific compliance framework along with the associated check IDs for each + requirement. + summary: Get compliance requirement attributes parameters: - in: query - name: fields[compliance-overviews] + name: fields[compliance-requirements-attributes] schema: type: array items: type: string enum: - - inserted_at - - compliance_id + - id - framework - version - - requirements_status - - region - - provider_type - - scan - - url - description - - requirements + - attributes description: endpoint return only specific fields in the response on a per-type basis by including a fields[TYPE] query parameter. explode: false - - in: path - name: id + - in: query + name: filter[compliance_id] schema: type: string - format: uuid - description: A UUID string identifying this compliance overview. + description: Compliance framework ID to get attributes for. required: true tags: - Compliance Overview @@ -235,8 +189,8 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/ComplianceOverviewFullResponse' - description: '' + $ref: '#/components/schemas/PaginatedComplianceOverviewAttributesList' + description: Compliance attributes obtained successfully /api/v1/compliance-overviews/metadata: get: operationId: compliance_overviews_metadata_retrieve @@ -271,8 +225,142 @@ paths: content: application/vnd.api+json: schema: - $ref: '#/components/schemas/ComplianceOverviewMetadataResponse' - description: '' + $ref: '#/components/schemas/OpenApiResponseResponse' + description: Compliance overviews metadata obtained successfully + '202': + description: The task is in progress + '500': + description: Compliance overviews generation task failed + /api/v1/compliance-overviews/requirements: + get: + operationId: compliance_overviews_requirements_retrieve + description: Retrieve a detailed overview of compliance requirements in a given + scan, grouped by compliance framework. This endpoint provides requirement-level + details and aggregates status across regions. + summary: List compliance requirements overview for a scan + parameters: + - in: query + name: fields[compliance-requirements-details] + schema: + type: array + items: + type: string + enum: + - id + - framework + - version + - description + - status + 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[compliance_id] + schema: + type: string + description: Compliance ID. + required: true + - in: query + name: filter[compliance_id__icontains] + schema: + type: string + - in: query + name: filter[framework] + schema: + type: string + - in: query + name: filter[framework__icontains] + schema: + type: string + - in: query + name: filter[framework__iexact] + schema: + type: string + - in: query + name: filter[inserted_at] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__date] + schema: + type: string + format: date + - in: query + name: filter[inserted_at__gte] + schema: + type: string + format: date-time + - in: query + name: filter[inserted_at__lte] + schema: + type: string + format: date-time + - in: query + name: filter[region] + schema: + type: string + - in: query + name: filter[region__icontains] + schema: + type: string + - in: query + name: filter[region__in] + schema: + type: array + items: + type: string + description: Multiple values may be separated by commas. + explode: false + style: form + - in: query + name: filter[scan_id] + schema: + type: string + format: uuid + description: Related scan ID. + required: true + - name: filter[search] + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: filter[version] + schema: + type: string + - in: query + name: filter[version__icontains] + schema: + type: string + - name: sort + required: false + in: query + description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)' + schema: + type: array + items: + type: string + enum: + - compliance_id + - -compliance_id + explode: false + tags: + - Compliance Overview + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedComplianceOverviewDetailList' + description: Compliance requirement details obtained successfully + '202': + description: The task is in progress + '500': + description: Compliance overviews generation task failed /api/v1/findings: get: operationId: findings_list @@ -2524,11 +2612,9 @@ paths: - amazon_s3 - aws_security_hub - jira - - saml - slack description: |- * `amazon_s3` - Amazon S3 - * `saml` - SAML * `aws_security_hub` - AWS Security Hub * `jira` - JIRA * `slack` - Slack @@ -2542,13 +2628,11 @@ paths: - amazon_s3 - aws_security_hub - jira - - saml - slack description: |- Multiple values may be separated by commas. * `amazon_s3` - Amazon S3 - * `saml` - SAML * `aws_security_hub` - AWS Security Hub * `jira` - JIRA * `slack` - Slack @@ -4892,6 +4976,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 configurations. + 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 configurations. + 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 configurations. + required: true + tags: + - SAML + security: + - jwtAuth: [] + responses: + '204': + description: No response body /api/v1/scans: get: operationId: scans_list @@ -5376,7 +5653,8 @@ paths: '403': description: There is a problem with credentials '404': - description: The scan has no reports + description: The scan has no reports, or the report generation task has + not started yet /api/v1/schedules/daily: post: operationId: schedules_daily_create @@ -6838,80 +7116,37 @@ components: properties: type: allOf: - - $ref: '#/components/schemas/Type7f7Enum' + - $ref: '#/components/schemas/ComplianceOverviewTypeEnum' 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 + id: {} attributes: type: object properties: - inserted_at: + id: type: string - format: date-time - readOnly: true - compliance_id: - type: string - maxLength: 100 framework: type: string - maxLength: 100 version: type: string - maxLength: 50 - requirements_status: - type: object - properties: - passed: - type: integer - failed: - type: integer - manual: - type: integer - total: - type: integer - readOnly: true - region: - type: string - maxLength: 50 - provider_type: - type: string - nullable: true - readOnly: true + requirements_passed: + type: integer + requirements_failed: + type: integer + requirements_manual: + type: integer + total_requirements: + type: integer required: - - compliance_id + - id - framework - relationships: - type: object - properties: - scan: - type: object - properties: - data: - type: object - properties: - id: - type: string - format: uuid - type: - type: string - enum: - - scans - 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 - ComplianceOverviewFull: + - version + - requirements_passed + - requirements_failed + - requirements_manual + - total_requirements + ComplianceOverviewAttributes: type: object required: - type @@ -6920,134 +7155,78 @@ components: properties: type: allOf: - - $ref: '#/components/schemas/Type7f7Enum' + - $ref: '#/components/schemas/ComplianceOverviewAttributesTypeEnum' 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 + id: {} attributes: type: object properties: - inserted_at: + id: type: string - format: date-time - readOnly: true - compliance_id: - type: string - maxLength: 100 framework: type: string - maxLength: 100 version: type: string - maxLength: 50 - requirements_status: - type: object - properties: - passed: - type: integer - failed: - type: integer - manual: - type: integer - total: - type: integer - readOnly: true - region: - type: string - maxLength: 50 - provider_type: - type: string - nullable: true - readOnly: true description: type: string - requirements: - type: object - properties: - requirement_id: - type: object - properties: - name: - type: string - checks: - type: object - properties: - check_name: - type: object - properties: - status: - type: string - enum: - - PASS - - FAIL - - null - description: Each key in the 'checks' object is a check name, - with values as 'PASS', 'FAIL', or null. - status: - type: string - enum: - - PASS - - FAIL - - MANUAL - attributes: - type: array - items: - type: object - description: - type: string - checks_status: - type: object - properties: - total: - type: integer - pass: - type: integer - fail: - type: integer - manual: - type: integer - readOnly: true + attributes: {} required: - - compliance_id + - id - framework - relationships: + - version + - description + - attributes + ComplianceOverviewAttributesTypeEnum: + type: string + enum: + - compliance-requirements-attributes + ComplianceOverviewDetail: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/ComplianceOverviewDetailTypeEnum' + 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: - scan: - type: object - properties: - data: - type: object - properties: - id: - type: string - format: uuid - type: - type: string - enum: - - scans - 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 - ComplianceOverviewFullResponse: - type: object - properties: - data: - $ref: '#/components/schemas/ComplianceOverviewFull' - required: - - data + id: + type: string + framework: + type: string + version: + type: string + description: + type: string + status: + enum: + - FAIL + - PASS + - MANUAL + type: string + description: |- + * `FAIL` - Fail + * `PASS` - Pass + * `MANUAL` - Manual + required: + - id + - framework + - version + - description + - status + ComplianceOverviewDetailTypeEnum: + type: string + enum: + - compliance-requirements-details ComplianceOverviewMetadata: type: object required: @@ -7071,17 +7250,14 @@ components: type: string required: - regions - ComplianceOverviewMetadataResponse: - type: object - properties: - data: - $ref: '#/components/schemas/ComplianceOverviewMetadata' - required: - - data ComplianceOverviewMetadataTypeEnum: type: string enum: - compliance-overviews-metadata + ComplianceOverviewTypeEnum: + type: string + enum: + - compliance-overviews Finding: type: object required: @@ -7357,14 +7533,12 @@ components: integration_type: enum: - amazon_s3 - - saml - aws_security_hub - jira - slack type: string description: |- * `amazon_s3` - Amazon S3 - * `saml` - SAML * `aws_security_hub` - AWS Security Hub * `jira` - JIRA * `slack` - Slack @@ -7442,14 +7616,12 @@ components: integration_type: enum: - amazon_s3 - - saml - aws_security_hub - jira - slack type: string description: |- * `amazon_s3` - Amazon S3 - * `saml` - SAML * `aws_security_hub` - AWS Security Hub * `jira` - JIRA * `slack` - Slack @@ -7585,14 +7757,12 @@ components: integration_type: enum: - amazon_s3 - - saml - aws_security_hub - jira - slack type: string description: |- * `amazon_s3` - Amazon S3 - * `saml` - SAML * `aws_security_hub` - AWS Security Hub * `jira` - JIRA * `slack` - Slack @@ -7743,14 +7913,12 @@ components: integration_type: enum: - amazon_s3 - - saml - aws_security_hub - jira - slack type: string description: |- * `amazon_s3` - Amazon S3 - * `saml` - SAML * `aws_security_hub` - AWS Security Hub * `jira` - JIRA * `slack` - Slack @@ -8385,7 +8553,7 @@ components: type: object properties: data: - $ref: '#/components/schemas/Membership' + $ref: '#/components/schemas/ComplianceOverviewMetadata' required: - data OverviewFinding: @@ -8600,29 +8768,33 @@ components: type: string enum: - findings-severity-overview + PaginatedComplianceOverviewAttributesList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ComplianceOverviewAttributes' + required: + - data + PaginatedComplianceOverviewDetailList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/ComplianceOverviewDetail' + required: + - data PaginatedComplianceOverviewList: type: object - required: - - count - - results properties: - count: - type: integer - example: 123 - next: - type: string - nullable: true - format: uri - example: http://api.example.org/accounts/?page[number]=4 - previous: - type: string - nullable: true - format: uri - example: http://api.example.org/accounts/?page[number]=2 - results: + data: type: array items: $ref: '#/components/schemas/ComplianceOverview' + required: + - data PaginatedFindingList: type: object properties: @@ -8704,6 +8876,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: @@ -8783,14 +8964,12 @@ components: integration_type: enum: - amazon_s3 - - saml - aws_security_hub - jira - slack type: string description: |- * `amazon_s3` - Amazon S3 - * `saml` - SAML * `aws_security_hub` - AWS Security Hub * `jira` - JIRA * `slack` - Slack @@ -9136,10 +9315,12 @@ components: enum: - static - role + - service_account type: string description: |- * `static` - Key-value pairs * `role` - Role assumption + * `service_account` - GCP Service Account Key readOnly: true secret: oneOf: @@ -9240,15 +9421,15 @@ components: user: type: email description: User microsoft email address. - encrypted_password: + password: type: string - description: User encrypted password. + description: User password. required: - client_id - client_secret - tenant_id - user - - encrypted_password + - password - type: object title: GCP Static Credentials properties: @@ -9268,6 +9449,14 @@ components: - client_id - client_secret - refresh_token + - type: object + title: GCP Service Account Key + properties: + service_account_key: + type: object + description: The service account key for GCP. + required: + - service_account_key - type: object title: Kubernetes Static Credentials properties: @@ -9513,6 +9702,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: @@ -10316,10 +10551,12 @@ components: enum: - static - role + - service_account type: string description: |- * `static` - Key-value pairs * `role` - Role assumption + * `service_account` - GCP Service Account Key required: - secret_type relationships: @@ -10383,10 +10620,12 @@ components: enum: - static - role + - service_account type: string description: |- * `static` - Key-value pairs * `role` - Role assumption + * `service_account` - GCP Service Account Key secret: oneOf: - type: object @@ -10485,15 +10724,15 @@ components: user: type: email description: User microsoft email address. - encrypted_password: + password: type: string - description: User encrypted password. + description: User password. required: - client_id - client_secret - tenant_id - user - - encrypted_password + - password - type: object title: GCP Static Credentials properties: @@ -10513,6 +10752,14 @@ components: - client_id - client_secret - refresh_token + - type: object + title: GCP Service Account Key + properties: + service_account_key: + type: object + description: The service account key for GCP. + required: + - service_account_key - type: object title: Kubernetes Static Credentials properties: @@ -10591,10 +10838,12 @@ components: enum: - static - role + - service_account type: string description: |- * `static` - Key-value pairs * `role` - Role assumption + * `service_account` - GCP Service Account Key secret: oneOf: - type: object @@ -10694,15 +10943,15 @@ components: user: type: email description: User microsoft email address. - encrypted_password: + password: type: string - description: User encrypted password. + description: User password. required: - client_id - client_secret - tenant_id - user - - encrypted_password + - password - type: object title: GCP Static Credentials properties: @@ -10722,6 +10971,14 @@ components: - client_id - client_secret - refresh_token + - type: object + title: GCP Service Account Key + properties: + service_account_key: + type: object + description: The service account key for GCP. + required: + - service_account_key - type: object title: Kubernetes Static Credentials properties: @@ -10816,10 +11073,12 @@ components: enum: - static - role + - service_account type: string description: |- * `static` - Key-value pairs * `role` - Role assumption + * `service_account` - GCP Service Account Key readOnly: true secret: oneOf: @@ -10919,15 +11178,15 @@ components: user: type: email description: User microsoft email address. - encrypted_password: + password: type: string - description: User encrypted password. + description: User password. required: - client_id - client_secret - tenant_id - user - - encrypted_password + - password - type: object title: GCP Static Credentials properties: @@ -10947,6 +11206,14 @@ components: - client_id - client_secret - refresh_token + - type: object + title: GCP Service Account Key + properties: + service_account_key: + type: object + description: The service account key for GCP. + required: + - service_account_key - type: object title: Kubernetes Static Credentials properties: @@ -11594,6 +11861,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: @@ -11861,6 +12219,7 @@ components: type: object required: - type + - id additionalProperties: false properties: type: @@ -11869,6 +12228,9 @@ components: 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: @@ -12283,10 +12645,6 @@ components: type: string enum: - roles - Type7f7Enum: - type: string - enum: - - compliance-overviews Type8cdEnum: type: string enum: diff --git a/api/src/backend/api/tests/test_adapters.py b/api/src/backend/api/tests/test_adapters.py new file mode 100644 index 0000000000..b5cf1564d7 --- /dev/null +++ b/api/src/backend/api/tests/test_adapters.py @@ -0,0 +1,82 @@ +from unittest.mock import MagicMock + +import pytest +from allauth.socialaccount.models import SocialLogin +from django.contrib.auth import get_user_model + +from api.adapters import ProwlerSocialAccountAdapter +from api.db_router import MainRouter +from api.models import Membership, SAMLConfiguration, Tenant + +User = get_user_model() + + +@pytest.mark.django_db +class TestProwlerSocialAccountAdapter: + def test_get_user_by_email_returns_user(self, create_test_user): + adapter = ProwlerSocialAccountAdapter() + user = adapter.get_user_by_email(create_test_user.email) + assert user == create_test_user + + def test_get_user_by_email_returns_none_for_unknown_email(self): + 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() + + sociallogin = MagicMock(spec=SocialLogin) + sociallogin.account = MagicMock() + sociallogin.account.provider = "saml" + sociallogin.account.extra_data = {} + sociallogin.user = create_test_user + sociallogin.connect = MagicMock() + + adapter.pre_social_login(rf.get("/"), sociallogin) + + call_args = sociallogin.connect.call_args + assert call_args is not None + + called_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.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"] + + 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.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_compliance.py b/api/src/backend/api/tests/test_compliance.py index 6b405536b3..bf3208a4d1 100644 --- a/api/src/backend/api/tests/test_compliance.py +++ b/api/src/backend/api/tests/test_compliance.py @@ -1,12 +1,12 @@ -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch from api.compliance import ( + generate_compliance_overview_template, + generate_scan_compliance, get_prowler_provider_checks, get_prowler_provider_compliance, - load_prowler_compliance, load_prowler_checks, - generate_scan_compliance, - generate_compliance_overview_template, + load_prowler_compliance, ) from api.models import Provider @@ -69,7 +69,7 @@ class TestCompliance: load_prowler_compliance() - from api.compliance import PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, PROWLER_CHECKS + from api.compliance import PROWLER_CHECKS, PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE assert PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE == { "template_key": "template_value" @@ -268,7 +268,7 @@ class TestCompliance: "manual": 0, "total": 0, }, - "status": "PASS", + "status": "MANUAL", }, }, "requirements_status": { diff --git a/api/src/backend/api/tests/test_db_utils.py b/api/src/backend/api/tests/test_db_utils.py index e22b1417bc..3373dafed0 100644 --- a/api/src/backend/api/tests/test_db_utils.py +++ b/api/src/backend/api/tests/test_db_utils.py @@ -3,9 +3,13 @@ from enum import Enum from unittest.mock import patch import pytest +from django.conf import settings +from freezegun import freeze_time from api.db_utils import ( + _should_create_index_on_partition, batch_delete, + create_objects_in_batches, enum_to_choices, generate_random_token, one_week_from_now, @@ -138,3 +142,88 @@ class TestBatchDelete: ) assert Provider.objects.all().count() == 0 assert summary == {"api.Provider": create_test_providers} + + +class TestShouldCreateIndexOnPartition: + @freeze_time("2025-05-15 00:00:00Z") + @pytest.mark.parametrize( + "partition_name, all_partitions, expected", + [ + ("any_name", True, True), + ("findings_default", True, True), + ("findings_2022_jan", True, True), + ("foo_bar", False, True), + ("findings_2025_MAY", False, True), + ("findings_2025_may", False, True), + ("findings_2025_jun", False, True), + ("findings_2025_apr", False, False), + ("findings_2025_xyz", False, True), + ], + ) + def test_partition_inclusion_logic(self, partition_name, all_partitions, expected): + assert ( + _should_create_index_on_partition(partition_name, all_partitions) + is expected + ) + + @freeze_time("2025-05-15 00:00:00Z") + def test_invalid_date_components(self): + # even if regex matches but int conversion fails, we fallback True + # (e.g. year too big, month number parse error) + bad_name = "findings_99999_jan" + assert _should_create_index_on_partition(bad_name, False) is True + + bad_name2 = "findings_2025_abc" + # abc not in month_map → fallback True + assert _should_create_index_on_partition(bad_name2, False) is True + + +@pytest.mark.django_db +class TestCreateObjectsInBatches: + @pytest.fixture + def tenant(self, tenants_fixture): + return tenants_fixture[0] + + def make_provider_instances(self, tenant, count): + """ + Return a list of `count` unsaved Provider instances for the given tenant. + """ + base_uid = 1000 + return [ + Provider( + tenant=tenant, + uid=str(base_uid + i), + provider=Provider.ProviderChoices.AWS, + ) + for i in range(count) + ] + + def test_exact_multiple_of_batch(self, tenant): + total = 6 + batch_size = 3 + objs = self.make_provider_instances(tenant, total) + + create_objects_in_batches(str(tenant.id), Provider, objs, batch_size=batch_size) + + qs = Provider.objects.filter(tenant=tenant) + assert qs.count() == total + + def test_non_multiple_of_batch(self, tenant): + total = 7 + batch_size = 3 + objs = self.make_provider_instances(tenant, total) + + create_objects_in_batches(str(tenant.id), Provider, objs, batch_size=batch_size) + + qs = Provider.objects.filter(tenant=tenant) + assert qs.count() == total + + def test_batch_size_default(self, tenant): + default_size = settings.DJANGO_DELETION_BATCH_SIZE + total = default_size + 2 + objs = self.make_provider_instances(tenant, total) + + create_objects_in_batches(str(tenant.id), Provider, objs) + + qs = Provider.objects.filter(tenant=tenant) + assert qs.count() == total diff --git a/api/src/backend/api/tests/test_mixins.py b/api/src/backend/api/tests/test_mixins.py new file mode 100644 index 0000000000..7daf9d5ff6 --- /dev/null +++ b/api/src/backend/api/tests/test_mixins.py @@ -0,0 +1,379 @@ +import json +from uuid import uuid4 + +import pytest +from django_celery_results.models import TaskResult +from rest_framework import status +from rest_framework.response import Response + +from api.exceptions import ( + TaskFailedException, + TaskInProgressException, + TaskNotFoundException, +) +from api.models import Task, User +from api.rls import Tenant +from api.v1.mixins import PaginateByPkMixin, TaskManagementMixin + + +@pytest.mark.django_db +class TestPaginateByPkMixin: + @pytest.fixture + def tenant(self): + return Tenant.objects.create(name="Test Tenant") + + @pytest.fixture + def users(self, tenant): + # Create 5 users with proper email field + users = [] + for i in range(5): + user = User.objects.create(email=f"user{i}@example.com", name=f"User {i}") + users.append(user) + return users + + class DummyView(PaginateByPkMixin): + def __init__(self, page): + self._page = page + + def paginate_queryset(self, qs): + return self._page + + def get_serializer(self, queryset, many): + class S: + def __init__(self, data): + # serialize to list of ids + self.data = [obj.id for obj in data] if many else queryset.id + + return S(queryset) + + def get_paginated_response(self, data): + return Response({"results": data}, status=status.HTTP_200_OK) + + def test_no_pagination(self, users): + base_qs = User.objects.all().order_by("id") + view = self.DummyView(page=None) + resp = view.paginate_by_pk( + request=None, base_queryset=base_qs, manager=User.objects + ) + # since no pagination, should return all ids in order + expected = [u.id for u in base_qs] + assert isinstance(resp, Response) + assert resp.data == expected + + def test_with_pagination(self, users): + base_qs = User.objects.all().order_by("id") + # simulate paging to first 2 ids + page = [base_qs[1].id, base_qs[3].id] + view = self.DummyView(page=page) + resp = view.paginate_by_pk( + request=None, base_queryset=base_qs, manager=User.objects + ) + # should fetch only those two users, in the same order as page + assert resp.status_code == status.HTTP_200_OK + assert resp.data == {"results": page} + + +@pytest.mark.django_db +class TestTaskManagementMixin: + class DummyView(TaskManagementMixin): + pass + + @pytest.fixture + def tenant(self): + return Tenant.objects.create(name="Test Tenant") + + @pytest.fixture(autouse=True) + def cleanup(self): + Task.objects.all().delete() + TaskResult.objects.all().delete() + + def test_no_task_and_no_taskresult_raises_not_found(self): + view = self.DummyView() + with pytest.raises(TaskNotFoundException): + view.check_task_status("task_xyz", {"foo": "bar"}) + + def test_no_task_and_no_taskresult_returns_none_when_not_raising(self): + view = self.DummyView() + result = view.check_task_status( + "task_xyz", {"foo": "bar"}, raise_on_not_found=False + ) + assert result is None + + def test_taskresult_pending_raises_in_progress(self): + task_kwargs = {"foo": "bar"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="task_xyz", + task_kwargs=json.dumps(task_kwargs), + status="PENDING", + ) + view = self.DummyView() + with pytest.raises(TaskInProgressException) as excinfo: + view.check_task_status("task_xyz", task_kwargs, raise_on_not_found=False) + assert hasattr(excinfo.value, "task_result") + assert excinfo.value.task_result == tr + + def test_taskresult_started_raises_in_progress(self): + task_kwargs = {"foo": "bar"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="task_xyz", + task_kwargs=json.dumps(task_kwargs), + status="STARTED", + ) + view = self.DummyView() + with pytest.raises(TaskInProgressException) as excinfo: + view.check_task_status("task_xyz", task_kwargs, raise_on_not_found=False) + assert hasattr(excinfo.value, "task_result") + assert excinfo.value.task_result == tr + + def test_taskresult_progress_raises_in_progress(self): + task_kwargs = {"foo": "bar"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="task_xyz", + task_kwargs=json.dumps(task_kwargs), + status="PROGRESS", + ) + view = self.DummyView() + with pytest.raises(TaskInProgressException) as excinfo: + view.check_task_status("task_xyz", task_kwargs, raise_on_not_found=False) + assert hasattr(excinfo.value, "task_result") + assert excinfo.value.task_result == tr + + def test_taskresult_failure_raises_failed(self): + task_kwargs = {"a": 1} + TaskResult.objects.create( + task_id=str(uuid4()), + task_name="task_fail", + task_kwargs=json.dumps(task_kwargs), + status="FAILURE", + ) + view = self.DummyView() + with pytest.raises(TaskFailedException): + view.check_task_status("task_fail", task_kwargs, raise_on_not_found=False) + + def test_taskresult_failure_returns_none_when_not_raising(self): + task_kwargs = {"a": 1} + TaskResult.objects.create( + task_id=str(uuid4()), + task_name="task_fail", + task_kwargs=json.dumps(task_kwargs), + status="FAILURE", + ) + view = self.DummyView() + result = view.check_task_status( + "task_fail", task_kwargs, raise_on_failed=False, raise_on_not_found=False + ) + assert result is None + + def test_taskresult_success_returns_none(self): + task_kwargs = {"x": 2} + TaskResult.objects.create( + task_id=str(uuid4()), + task_name="task_ok", + task_kwargs=json.dumps(task_kwargs), + status="SUCCESS", + ) + view = self.DummyView() + # should not raise, and returns None + assert ( + view.check_task_status("task_ok", task_kwargs, raise_on_not_found=False) + is None + ) + + def test_taskresult_revoked_returns_none(self): + task_kwargs = {"x": 2} + TaskResult.objects.create( + task_id=str(uuid4()), + task_name="task_revoked", + task_kwargs=json.dumps(task_kwargs), + status="REVOKED", + ) + view = self.DummyView() + # should not raise, and returns None + assert ( + view.check_task_status( + "task_revoked", task_kwargs, raise_on_not_found=False + ) + is None + ) + + def test_task_with_failed_status_raises_failed(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="FAILURE", + ) + task = Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + with pytest.raises(TaskFailedException) as excinfo: + view.check_task_status("scan_task", task_kwargs) + # Check that the exception contains the expected task + assert hasattr(excinfo.value, "task") + assert excinfo.value.task == task + + def test_task_with_cancelled_status_raises_failed(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="REVOKED", + ) + task = Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + with pytest.raises(TaskFailedException) as excinfo: + view.check_task_status("scan_task", task_kwargs) + # Check that the exception contains the expected task + assert hasattr(excinfo.value, "task") + assert excinfo.value.task == task + + def test_task_with_failed_status_returns_task_when_not_raising(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="FAILURE", + ) + task = Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + result = view.check_task_status("scan_task", task_kwargs, raise_on_failed=False) + assert result == task + + def test_task_with_completed_status_returns_none(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="SUCCESS", + ) + Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + result = view.check_task_status("scan_task", task_kwargs) + assert result is None + + def test_task_with_executing_status_returns_task(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="STARTED", + ) + task = Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + result = view.check_task_status("scan_task", task_kwargs) + assert result is not None + assert result.pk == task.pk + + def test_task_with_pending_status_returns_task(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="PENDING", + ) + task = Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + result = view.check_task_status("scan_task", task_kwargs) + assert result is not None + assert result.pk == task.pk + + def test_get_task_response_if_running_returns_none_for_completed_task(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="SUCCESS", + ) + Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + result = view.get_task_response_if_running("scan_task", task_kwargs) + assert result is None + + def test_get_task_response_if_running_returns_none_for_no_task(self): + view = self.DummyView() + result = view.get_task_response_if_running( + "nonexistent", {"foo": "bar"}, raise_on_not_found=False + ) + assert result is None + + def test_get_task_response_if_running_returns_202_for_executing_task(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="STARTED", + ) + task = Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + result = view.get_task_response_if_running("scan_task", task_kwargs) + + assert isinstance(result, Response) + assert result.status_code == status.HTTP_202_ACCEPTED + assert "Content-Location" in result.headers + # The response should contain the serialized task data + assert result.data is not None + assert "id" in result.data + assert str(result.data["id"]) == str(task.id) + + def test_get_task_response_if_running_returns_none_for_available_task(self, tenant): + task_kwargs = {"provider_id": "test"} + tr = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs), + status="PENDING", + ) + Task.objects.create(tenant=tenant, task_runner_task=tr) + view = self.DummyView() + result = view.get_task_response_if_running("scan_task", task_kwargs) + # PENDING maps to AVAILABLE, which is not EXECUTING, so should return None + assert result is None + + def test_kwargs_filtering_works_correctly(self, tenant): + # Create tasks with different kwargs + task_kwargs_1 = {"provider_id": "test1", "scan_type": "full"} + task_kwargs_2 = {"provider_id": "test2", "scan_type": "quick"} + + tr1 = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs_1), + status="STARTED", + ) + tr2 = TaskResult.objects.create( + task_id=str(uuid4()), + task_name="scan_task", + task_kwargs=json.dumps(task_kwargs_2), + status="STARTED", + ) + + task1 = Task.objects.create(tenant=tenant, task_runner_task=tr1) + task2 = Task.objects.create(tenant=tenant, task_runner_task=tr2) + + view = self.DummyView() + + # Should find task1 when searching for its kwargs + result1 = view.check_task_status("scan_task", {"provider_id": "test1"}) + assert result1 is not None + assert result1.pk == task1.pk + + # Should find task2 when searching for its kwargs + result2 = view.check_task_status("scan_task", {"provider_id": "test2"}) + assert result2 is not None + assert result2.pk == task2.pk + + # Should not find anything when searching for non-existent kwargs + result3 = view.check_task_status( + "scan_task", {"provider_id": "test3"}, raise_on_not_found=False + ) + assert result3 is None diff --git a/api/src/backend/api/tests/test_models.py b/api/src/backend/api/tests/test_models.py index c2beeb3583..d834143ed1 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, Tenant @pytest.mark.django_db @@ -120,3 +123,149 @@ class TestResourceModel: # compliance={}, # ) # assert Finding.objects.filter(uid=long_uid).exists() + + +@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, + ) + + 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, + ) + + 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] + + 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") + + 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, + ) + + 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] + + def test_duplicate_tenant_config_fails(self): + tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant D") + + 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, + ) + + 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] + ) + + 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, + ) + + 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] + + 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, + ) + + 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] + + 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, + ) + + 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] diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 80ab44b946..fed1f9d2da 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -9,15 +9,20 @@ from unittest.mock import ANY, MagicMock, Mock, patch 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 ( - ComplianceOverview, Integration, Invitation, Membership, @@ -27,6 +32,7 @@ from api.models import ( ProviderSecret, Role, RoleProviderGroupRelationship, + SAMLConfiguration, Scan, StateChoices, Task, @@ -34,6 +40,7 @@ from api.models import ( UserRoleRelationship, ) from api.rls import Tenant +from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView TODAY = str(datetime.today().date()) @@ -913,6 +920,16 @@ class TestProviderViewSet: "uid": "gke_aaaa-dev_europe-test1_dev-aaaa-test-cluster-long-name-123456789", "alias": "GKE", }, + { + "provider": "kubernetes", + "uid": "gke_project/cluster-name", + "alias": "GKE", + }, + { + "provider": "kubernetes", + "uid": "admin@k8s-demo", + "alias": "test", + }, { "provider": "azure", "uid": "8851db6b-42e5-4533-aa9e-30a32d67e875", @@ -920,7 +937,7 @@ class TestProviderViewSet: }, { "provider": "m365", - "uid": "TestingPro.onMirosoft.com", + "uid": "TestingPro.onmicrosoft.com", "alias": "test", }, { @@ -1678,6 +1695,26 @@ class TestProviderSecretViewSet: "refresh_token": "refresh-token", }, ), + # GCP with Service Account Key secret + ( + Provider.ProviderChoices.GCP.value, + ProviderSecret.TypeChoices.SERVICE_ACCOUNT, + { + "service_account_key": { + "type": "service_account", + "project_id": "project-id", + "private_key_id": "private-key-id", + "private_key": "private-key", + "client_email": "client-email", + "client_id": "client-id", + "auth_uri": "auth-uri", + "token_uri": "token-uri", + "auth_provider_x509_cert_url": "auth-provider-x509-cert-url", + "client_x509_cert_url": "client-x509-cert-url", + "universe_domain": "universe-domain", + }, + }, + ), # Kubernetes with STATIC secret ( Provider.ProviderChoices.KUBERNETES.value, @@ -2303,7 +2340,10 @@ class TestScanViewSet: url = reverse("scan-report", kwargs={"pk": scan.id}) response = authenticated_client.get(url) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json()["errors"]["detail"] == "The scan has no reports." + assert ( + response.json()["errors"]["detail"] + == "The scan has no reports, or the report generation task has not started yet." + ) def test_report_s3_no_credentials( self, authenticated_client, scans_fixture, monkeypatch @@ -2371,7 +2411,7 @@ class TestScanViewSet: ): """ When output_location is a local path and glob.glob returns an empty list, - the view should return HTTP 404 with detail "The scan has no reports." + the view should return HTTP 404 with detail "The scan has no reports, or the report generation task has not started yet." """ scan = scans_fixture[0] scan.output_location = "/tmp/nonexistent_report_pattern.zip" @@ -2383,7 +2423,10 @@ class TestScanViewSet: response = authenticated_client.get(url) assert response.status_code == 404 - assert response.json()["errors"]["detail"] == "The scan has no reports." + assert ( + response.json()["errors"]["detail"] + == "The scan has no reports, or the report generation task has not started yet." + ) def test_report_local_file(self, authenticated_client, scans_fixture, monkeypatch): scan = scans_fixture[0] @@ -2458,7 +2501,10 @@ class TestScanViewSet: url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": framework}) resp = authenticated_client.get(url) assert resp.status_code == status.HTTP_404_NOT_FOUND - assert resp.json()["errors"]["detail"] == "The scan has no reports." + assert ( + resp.json()["errors"]["detail"] + == "The scan has no reports, or the report generation task has not started yet." + ) def test_compliance_s3_no_credentials( self, authenticated_client, scans_fixture, monkeypatch @@ -2600,6 +2646,36 @@ class TestScanViewSet: assert response.status_code == status.HTTP_404_NOT_FOUND + @patch("api.v1.views.TaskSerializer") + def test__get_task_status_finds_task_using_kwargs( + self, mock_task_serializer, authenticated_client, scans_fixture + ): + scan = scans_fixture[0] + scan.state = StateChoices.COMPLETED + scan.output_location = "dummy" + scan.save() + + task_result = TaskResult.objects.create( + task_name="scan-report", + task_kwargs={"scan_id": str(scan.id)}, + ) + + task = Task.objects.create( + tenant_id=scan.tenant_id, + task_runner_task=task_result, + ) + + mock_task_serializer.return_value.data = { + "id": str(task.id), + "state": StateChoices.EXECUTING, + } + + url = reverse("scan-report", kwargs={"pk": scan.id}) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_202_ACCEPTED + assert response.data["id"] == str(task.id) + @patch("api.v1.views.get_s3_client") @patch("api.v1.views.sentry_sdk.capture_exception") def test_compliance_list_objects_client_error( @@ -2650,7 +2726,10 @@ class TestScanViewSet: response = authenticated_client.get(url) assert response.status_code == status.HTTP_404_NOT_FOUND - assert response.json()["errors"]["detail"] == "The scan has no reports." + assert ( + response.json()["errors"]["detail"] + == "The scan has no reports, or the report generation task has not started yet." + ) @patch("api.v1.views.get_s3_client") def test_report_s3_client_error_other( @@ -4688,210 +4767,266 @@ class TestComplianceOverviewViewSet: assert len(response.json()["data"]) == 0 def test_compliance_overview_list( - self, authenticated_client, compliance_overviews_fixture + self, authenticated_client, compliance_requirements_overviews_fixture ): # List compliance overviews with existing data - compliance_overview1, compliance_overview2 = compliance_overviews_fixture - scan_id = str(compliance_overview1.scan.id) + requirement_overview1 = compliance_requirements_overviews_fixture[0] + scan_id = str(requirement_overview1.scan.id) response = authenticated_client.get( reverse("complianceoverview-list"), {"filter[scan_id]": scan_id}, ) assert response.status_code == status.HTTP_200_OK - assert ( - len(response.json()["data"]) == 1 - ) # Due to the custom get_queryset method, only one compliance_id + data = response.json()["data"] + assert len(data) == 2 # Two compliance frameworks - def test_compliance_overview_list_missing_scan_id(self, authenticated_client): - # Attempt to list compliance overviews without providing filter[scan_id] - response = authenticated_client.get(reverse("complianceoverview-list")) + # Check that we get aggregated data for each compliance framework + framework_ids = [item["id"] for item in data] + assert "aws_account_security_onboarding_aws" in framework_ids + assert "cis_1.4_aws" in framework_ids + + # Check structure of response + for item in data: + assert "id" in item + assert "attributes" in item + attributes = item["attributes"] + assert "framework" in attributes + assert "version" in attributes + assert "requirements_passed" in attributes + assert "requirements_failed" in attributes + assert "requirements_manual" in attributes + assert "total_requirements" in attributes + + def test_compliance_overview_metadata( + self, authenticated_client, compliance_requirements_overviews_fixture + ): + requirement_overview1 = compliance_requirements_overviews_fixture[0] + scan_id = str(requirement_overview1.scan.id) + + response = authenticated_client.get( + reverse("complianceoverview-metadata"), + {"filter[scan_id]": scan_id}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert "attributes" in data + assert "regions" in data["attributes"] + assert isinstance(data["attributes"]["regions"], list) + + def test_compliance_overview_requirements( + self, authenticated_client, compliance_requirements_overviews_fixture + ): + requirement_overview1 = compliance_requirements_overviews_fixture[0] + scan_id = str(requirement_overview1.scan.id) + compliance_id = requirement_overview1.compliance_id + + response = authenticated_client.get( + reverse("complianceoverview-requirements"), + { + "filter[scan_id]": scan_id, + "filter[compliance_id]": compliance_id, + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) > 0 + + # Check structure of requirements response + for item in data: + assert "id" in item + assert "attributes" in item + attributes = item["attributes"] + assert "framework" in attributes + assert "version" in attributes + assert "description" in attributes + assert "status" in attributes + + def test_compliance_overview_requirements_manual( + self, authenticated_client, compliance_requirements_overviews_fixture + ): + scan_id = str(compliance_requirements_overviews_fixture[0].scan.id) + # Compliance with a manual requirement + compliance_id = "aws_account_security_onboarding_aws" + + response = authenticated_client.get( + reverse("complianceoverview-requirements"), + { + "filter[scan_id]": scan_id, + "filter[compliance_id]": compliance_id, + }, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data[-1]["attributes"]["status"] == "MANUAL" + + def test_compliance_overview_requirements_missing_scan_id( + self, authenticated_client + ): + response = authenticated_client.get( + reverse("complianceoverview-requirements"), + {"filter[compliance_id]": "aws_account_security_onboarding_aws"}, + ) assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json()["errors"][0]["source"]["pointer"] == "filter[scan_id]" - assert response.json()["errors"][0]["code"] == "required" + + def test_compliance_overview_requirements_missing_compliance_id( + self, authenticated_client, compliance_requirements_overviews_fixture + ): + requirement_overview1 = compliance_requirements_overviews_fixture[0] + scan_id = str(requirement_overview1.scan.id) + + response = authenticated_client.get( + reverse("complianceoverview-requirements"), + {"filter[scan_id]": scan_id}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_compliance_overview_attributes(self, authenticated_client): + response = authenticated_client.get( + reverse("complianceoverview-attributes"), + {"filter[compliance_id]": "aws_account_security_onboarding_aws"}, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) > 0 + + # Check structure of attributes response + for item in data: + assert "id" in item + assert "attributes" in item + attributes = item["attributes"] + assert "framework" in attributes + assert "version" in attributes + assert "description" in attributes + assert "attributes" in attributes + assert "metadata" in attributes["attributes"] + assert "check_ids" in attributes["attributes"] + + def test_compliance_overview_attributes_missing_compliance_id( + self, authenticated_client + ): + response = authenticated_client.get( + reverse("complianceoverview-attributes"), + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_compliance_overview_task_management_integration( + self, authenticated_client, compliance_requirements_overviews_fixture + ): + """Test that task management mixin is properly integrated""" + from unittest.mock import patch + + requirement_overview1 = compliance_requirements_overviews_fixture[0] + scan_id = str(requirement_overview1.scan.id) + + # Mock a running task + with patch.object( + ComplianceOverviewViewSet, "get_task_response_if_running" + ) as mock_task_response: + mock_response = Response( + {"detail": "Task is running"}, status=status.HTTP_202_ACCEPTED + ) + mock_task_response.return_value = mock_response + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[scan_id]": scan_id}, + ) + assert response.status_code == status.HTTP_202_ACCEPTED + mock_task_response.assert_called_once() + + def test_compliance_overview_task_failed_exception( + self, authenticated_client, compliance_requirements_overviews_fixture + ): + """Test handling of TaskFailedException""" + from unittest.mock import patch + + from api.exceptions import TaskFailedException + + requirement_overview1 = compliance_requirements_overviews_fixture[0] + scan_id = str(requirement_overview1.scan.id) + + # Mock a failed task + with patch.object( + ComplianceOverviewViewSet, "get_task_response_if_running" + ) as mock_task_response: + mock_task_response.side_effect = TaskFailedException("Task failed") + + response = authenticated_client.get( + reverse("complianceoverview-list"), + {"filter[scan_id]": scan_id}, + ) + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert "Task failed to generate compliance overview data" in str( + response.data + ) @pytest.mark.parametrize( - "filter_name, filter_value, expected_count", + "filter_name, filter_value_attr, expected_count_min", [ - ("compliance_id", "aws_account_security_onboarding_aws", 1), - ("compliance_id.icontains", "security_onboarding", 1), - ("framework", "AWS-Account-Security-Onboarding", 1), - ("framework.icontains", "security-onboarding", 1), - ("version", "1.0", 1), - ("version", "2.0", 0), - ("version.icontains", "0", 1), - ("region", "eu-west-1", 1), - ("region.icontains", "west-1", 1), - ("region.in", "eu-west-1,eu-west-2", 1), - ("inserted_at.date", "2024-01-01", 0), - ("inserted_at.date", TODAY, 1), - ("inserted_at.gte", "2024-01-01", 1), + ("scan_id", "scan.id", 1), + ("compliance_id", "compliance_id", 1), + ("framework", "framework", 1), + ("version", "version", 1), + ("region", "region", 1), ], ) def test_compliance_overview_filters( self, authenticated_client, - compliance_overviews_fixture, + compliance_requirements_overviews_fixture, filter_name, - filter_value, - expected_count, + filter_value_attr, + expected_count_min, ): - # Test filtering compliance overviews - compliance_overview1 = compliance_overviews_fixture[0] - scan_id = str(compliance_overview1.scan.id) + requirement_overview = compliance_requirements_overviews_fixture[0] + scan_id = str(requirement_overview.scan.id) + + filter_value = requirement_overview + for attr in filter_value_attr.split("."): + filter_value = getattr(filter_value, attr) + + filter_value = str(filter_value) + + query_params = { + "filter[scan_id]": scan_id, + f"filter[{filter_name}]": filter_value, + } + + if filter_name == "scan_id": + query_params = {"filter[scan_id]": filter_value} response = authenticated_client.get( reverse("complianceoverview-list"), - { - "filter[scan_id]": scan_id, - f"filter[{filter_name}]": filter_value, - }, - ) - assert response.status_code == status.HTTP_200_OK - assert len(response.json()["data"]) == expected_count - - @pytest.mark.parametrize( - "filter_name", - ["invalid_filter", "unknown_field"], - ) - def test_compliance_overview_filters_invalid( - self, authenticated_client, compliance_overviews_fixture, filter_name - ): - # Test handling of invalid filters - compliance_overview1 = compliance_overviews_fixture[0] - scan_id = str(compliance_overview1.scan.id) - - response = authenticated_client.get( - reverse("complianceoverview-list"), - { - "filter[scan_id]": scan_id, - f"filter[{filter_name}]": "some_value", - }, - ) - assert response.status_code == status.HTTP_400_BAD_REQUEST - - @pytest.mark.parametrize( - "sort_field", - ["inserted_at", "-inserted_at", "compliance_id", "-compliance_id"], - ) - def test_compliance_overview_sort( - self, authenticated_client, compliance_overviews_fixture, sort_field - ): - # Test sorting compliance overviews - compliance_overview1 = compliance_overviews_fixture[0] - scan_id = str(compliance_overview1.scan.id) - - response = authenticated_client.get( - reverse("complianceoverview-list"), - { - "filter[scan_id]": scan_id, - "sort": sort_field, - }, - ) - assert response.status_code == status.HTTP_200_OK - - def test_compliance_overview_sort_invalid( - self, authenticated_client, compliance_overviews_fixture - ): - # Test handling of invalid sort parameters - compliance_overview1 = compliance_overviews_fixture[0] - scan_id = str(compliance_overview1.scan.id) - - response = authenticated_client.get( - reverse("complianceoverview-list"), - { - "filter[scan_id]": scan_id, - "sort": "invalid_field", - }, - ) - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json()["errors"][0]["code"] == "invalid" - assert "invalid sort parameter" in response.json()["errors"][0]["detail"] - - def test_compliance_overview_retrieve( - self, authenticated_client, compliance_overviews_fixture - ): - # Retrieve a specific compliance overview - compliance_overview1 = compliance_overviews_fixture[0] - - response = authenticated_client.get( - reverse( - "complianceoverview-detail", - kwargs={"pk": compliance_overview1.id}, - ), - ) - assert response.status_code == status.HTTP_200_OK - data = response.json()["data"] - assert data["id"] == str(compliance_overview1.id) - attributes = data["attributes"] - assert attributes["compliance_id"] == compliance_overview1.compliance_id - assert attributes["framework"] == compliance_overview1.framework - assert attributes["version"] == compliance_overview1.version - assert attributes["region"] == compliance_overview1.region - assert attributes["description"] == compliance_overview1.description - assert "requirements" in attributes - - def test_compliance_overview_invalid_retrieve(self, authenticated_client): - # Attempt to retrieve a compliance overview with an invalid ID - response = authenticated_client.get( - reverse( - "complianceoverview-detail", - kwargs={"pk": "invalid-id"}, - ), - ) - assert response.status_code == status.HTTP_404_NOT_FOUND - - def test_compliance_overview_list_queryset( - self, authenticated_client, compliance_overviews_fixture - ): - compliance_overview1, compliance_overview2 = compliance_overviews_fixture - scan_id = str(compliance_overview1.scan.id) - - response = authenticated_client.get( - reverse("complianceoverview-list"), - {"filter[scan_id]": scan_id}, - ) - # No filters, most fails should be returned - assert len(response.json()["data"]) == 1 - assert response.json()["data"][0]["id"] == str(compliance_overview2.id) - - compliance_overview1.requirements_failed = 5 - compliance_overview1.save() - - response = authenticated_client.get( - reverse("complianceoverview-list"), - {"filter[scan_id]": scan_id}, - ) - # No filters, now compliance_overview1 has more fails - assert len(response.json()["data"]) == 1 - assert response.json()["data"][0]["id"] == str(compliance_overview1.id) - - def test_compliance_overview_metadata( - self, authenticated_client, compliance_overviews_fixture - ): - response = authenticated_client.get( - reverse("complianceoverview-metadata"), - {"filter[scan_id]": str(compliance_overviews_fixture[0].scan_id)}, - ) - data = response.json() - - expected_regions = set( - ComplianceOverview.objects.all() - .values_list("region", flat=True) - .distinct("region") + query_params, ) assert response.status_code == status.HTTP_200_OK - assert data["data"]["type"] == "compliance-overviews-metadata" - assert data["data"]["id"] is None - assert set(data["data"]["attributes"]["regions"]) == expected_regions + response_data = response.json() - def test_compliance_overview_metadata_missing_scan_id(self, authenticated_client): - # Attempt to list compliance overviews without providing filter[scan_id] - response = authenticated_client.get(reverse("complianceoverview-metadata")) - assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json()["errors"][0]["source"]["pointer"] == "filter[scan_id]" - assert response.json()["errors"][0]["code"] == "required" + assert len(response_data["data"]) >= expected_count_min + + if response_data["data"]: + first_item = response_data["data"][0] + assert "id" in first_item + assert "type" in first_item + assert first_item["type"] == "compliance-overviews" + assert "attributes" in first_item + + attributes = first_item["attributes"] + assert "framework" in attributes + assert "version" in attributes + assert "requirements_passed" in attributes + assert "requirements_failed" in attributes + assert "requirements_manual" in attributes + assert "total_requirements" in attributes + + if filter_name == "compliance_id": + assert first_item["id"] == filter_value + elif filter_name == "framework": + assert attributes["framework"] == filter_value + elif filter_name == "version": + assert attributes["version"] == filter_value @pytest.mark.django_db @@ -5395,3 +5530,264 @@ class TestIntegrationViewSet: {f"filter[{filter_name}]": "whatever"}, ) assert response.status_code == status.HTTP_400_BAD_REQUEST + + +@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"]} + + response = authenticated_client.post(url, data=payload, format="json") + + assert response.status_code == status.HTTP_302_FOUND + assert f"email={saml_setup['email']}" in response.url + assert ( + reverse("saml_login", kwargs={"organization_slug": saml_setup["domain"]}) + in response.url + ) + + 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") + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json()["errors"]["detail"] == "Unauthorized domain." + + def test_missing_certificates(self, authenticated_client, saml_setup, monkeypatch): + monkeypatch.setenv("SAML_PUBLIC_CERT", "") + monkeypatch.setenv("SAML_PRIVATE_KEY", "") + + url = reverse("api_saml_initiate") + payload = {"email_domain": saml_setup["email"]} + + response = authenticated_client.post(url, data=payload, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert ( + response.json()["errors"]["detail"] + == "SAML configuration is invalid: missing certificates." + ) + + +@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_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_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})() + + 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") + + 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] + + 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") + + assert isinstance(response, JsonResponse) or response.status_code in [200, 302] + + def test_dispatch_sets_user_profile_and_assigns_role( + self, create_test_user, tenants_fixture, saml_setup + ): + user = create_test_user + original_email = user.email + original_name = user.name + original_company = user.company_name + user.email = f"doe@{saml_setup['email']}" + + social_account = SocialAccount( + user=user, + provider="saml", + extra_data={ + "firstName": ["John"], + "lastName": ["Doe"], + "organization": ["TestOrg"], + "userType": ["saml_default_role"], + }, + ) + + 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_socialaccount_get, + patch("api.v1.serializers.TokenSocialLoginSerializer") as mock_serializer, + ): + mock_get_app_or_404.return_value = MagicMock( + provider="saml", client_id="testtenant", name="Test App", settings={} + ) + + mock_socialaccount_get.return_value = social_account + + mock_instance = mock_serializer.return_value + mock_instance.is_valid.return_value = True + mock_instance.validated_data = { + "token": "mocktoken", + "refresh_token": "mockrefresh", + } + + view = TenantFinishACSView.as_view() + response = view(request, organization_slug="testtenant") + + assert response.status_code == 200 + user.refresh_from_db() + assert user.name == "John Doe" + assert user.company_name == "TestOrg" + + role = Role.objects.using(MainRouter.admin_db).get(name="saml_default_role") + assert role.tenant == tenants_fixture[0] + + assert ( + UserRoleRelationship.objects.using(MainRouter.admin_db) + .filter(user=user, tenant_id=tenants_fixture[0].id) + .exists() + ) + user.email = original_email + user.name = original_name + user.company_name = original_company + user.save() diff --git a/api/src/backend/api/v1/mixins.py b/api/src/backend/api/v1/mixins.py index 85250c0eef..fde14a23c5 100644 --- a/api/src/backend/api/v1/mixins.py +++ b/api/src/backend/api/v1/mixins.py @@ -1,5 +1,16 @@ +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.exceptions import ( + TaskFailedException, + TaskInProgressException, + TaskNotFoundException, +) +from api.models import StateChoices, Task +from api.v1.serializers import TaskSerializer + class PaginateByPkMixin: """ @@ -31,3 +42,181 @@ class PaginateByPkMixin: serialized = self.get_serializer(queryset, many=True).data return self.get_paginated_response(serialized) + + +class TaskManagementMixin: + """ + Mixin to manage task status checking. + + This mixin provides functionality to check if a task with specific parameters + is running, completed, failed, or doesn't exist. It returns the task when running + and raises specific exceptions for failed/not found scenarios that can be handled + at the view level. + """ + + def check_task_status( + self, + task_name: str, + task_kwargs: dict, + raise_on_failed: bool = True, + raise_on_not_found: bool = True, + ) -> Task | None: + """ + Check the status of a task with given name and kwargs. + + This method first checks for a related Task object, and if not found, + checks TaskResult directly. If a TaskResult is found and running but + there's no related Task, it raises TaskInProgressException. + + Args: + task_name (str): The name of the task to check + task_kwargs (dict): The kwargs to match against the task + raise_on_failed (bool): Whether to raise exception if task failed + raise_on_not_found (bool): Whether to raise exception if task not found + + Returns: + Task | None: The task instance if found (regardless of state), None if not found and raise_on_not_found=False + + Raises: + TaskFailedException: If task failed and raise_on_failed=True + TaskNotFoundException: If task not found and raise_on_not_found=True + TaskInProgressException: If task is running but no related Task object exists + """ + # First, try to find a Task object with related TaskResult + try: + # Build the filter for task kwargs + task_filter = { + "task_runner_task__task_name": task_name, + } + + # Add kwargs filters - we need to check if the task kwargs contain our parameters + for key, value in task_kwargs.items(): + task_filter["task_runner_task__task_kwargs__contains"] = str(value) + + task = ( + Task.objects.filter(**task_filter) + .select_related("task_runner_task") + .order_by("-inserted_at") + .first() + ) + + if task: + # Get task state using the same logic as TaskSerializer + task_state_mapping = { + "PENDING": StateChoices.AVAILABLE, + "STARTED": StateChoices.EXECUTING, + "PROGRESS": StateChoices.EXECUTING, + "SUCCESS": StateChoices.COMPLETED, + "FAILURE": StateChoices.FAILED, + "REVOKED": StateChoices.CANCELLED, + } + + celery_status = ( + task.task_runner_task.status if task.task_runner_task else None + ) + task_state = task_state_mapping.get( + celery_status or "", StateChoices.AVAILABLE + ) + + # Check task state and raise exceptions accordingly + if task_state in (StateChoices.FAILED, StateChoices.CANCELLED): + if raise_on_failed: + raise TaskFailedException(task=task) + return task + elif task_state == StateChoices.COMPLETED: + return None + + return task + + except Task.DoesNotExist: + pass + + # If no Task found, check TaskResult directly + try: + # Build the filter for TaskResult + task_result_filter = { + "task_name": task_name, + } + + # Add kwargs filters - check if the task kwargs contain our parameters + for key, value in task_kwargs.items(): + task_result_filter["task_kwargs__contains"] = str(value) + + task_result = ( + TaskResult.objects.filter(**task_result_filter) + .order_by("-date_created") + .first() + ) + + if task_result: + # Check if the TaskResult indicates a running task + if task_result.status in ["PENDING", "STARTED", "PROGRESS"]: + # Task is running but no related Task object exists + raise TaskInProgressException(task_result=task_result) + elif task_result.status == "FAILURE": + if raise_on_failed: + raise TaskFailedException(task=None) + # For other statuses (SUCCESS, REVOKED), we don't have a Task to return, + # so we treat it as not found + + except TaskResult.DoesNotExist: + pass + + # No task found at all + if raise_on_not_found: + raise TaskNotFoundException() + return None + + def get_task_response_if_running( + self, + task_name: str, + task_kwargs: dict, + raise_on_failed: bool = True, + raise_on_not_found: bool = True, + ) -> Response | None: + """ + Get a 202 response with task details if the task is currently running. + + This method is useful for endpoints that should return task status when + a background task is in progress, similar to the compliance overview endpoints. + + Args: + task_name (str): The name of the task to check + task_kwargs (dict): The kwargs to match against the task + + Returns: + Response | None: 202 response with task details if running, None otherwise + """ + task = self.check_task_status( + task_name=task_name, + task_kwargs=task_kwargs, + raise_on_failed=raise_on_failed, + raise_on_not_found=raise_on_not_found, + ) + + if not task: + return None + + # Get task state + task_state_mapping = { + "PENDING": StateChoices.AVAILABLE, + "STARTED": StateChoices.EXECUTING, + "PROGRESS": StateChoices.EXECUTING, + "SUCCESS": StateChoices.COMPLETED, + "FAILURE": StateChoices.FAILED, + "REVOKED": StateChoices.CANCELLED, + } + + celery_status = task.task_runner_task.status if task.task_runner_task else None + task_state = task_state_mapping.get(celery_status or "", StateChoices.AVAILABLE) + + if task_state == StateChoices.EXECUTING: + self.response_serializer_class = TaskSerializer + serializer = TaskSerializer(task) + return Response( + data=serializer.data, + status=status.HTTP_202_ACCEPTED, + headers={ + "Content-Location": reverse("task-detail", kwargs={"pk": task.id}) + }, + ) diff --git a/api/src/backend/api/v1/serializer_utils/providers.py b/api/src/backend/api/v1/serializer_utils/providers.py index 231e5dc1bb..0a1e999fe2 100644 --- a/api/src/backend/api/v1/serializer_utils/providers.py +++ b/api/src/backend/api/v1/serializer_utils/providers.py @@ -119,9 +119,9 @@ from rest_framework_json_api import serializers "type": "email", "description": "User microsoft email address.", }, - "encrypted_password": { + "password": { "type": "string", - "description": "User encrypted password.", + "description": "User password.", }, }, "required": [ @@ -129,7 +129,7 @@ from rest_framework_json_api import serializers "client_secret", "tenant_id", "user", - "encrypted_password", + "password", ], }, { @@ -154,6 +154,17 @@ from rest_framework_json_api import serializers }, "required": ["client_id", "client_secret", "refresh_token"], }, + { + "type": "object", + "title": "GCP Service Account Key", + "properties": { + "service_account_key": { + "type": "object", + "description": "The service account key for GCP.", + } + }, + "required": ["service_account_key"], + }, { "type": "object", "title": "Kubernetes Static Credentials", diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index c0440f3ef0..f61ab9ee19 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -14,7 +14,6 @@ from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from rest_framework_simplejwt.tokens import RefreshToken from api.models import ( - ComplianceOverview, Finding, Integration, IntegrationProviderRelationship, @@ -29,8 +28,10 @@ from api.models import ( ResourceTag, Role, RoleProviderGroupRelationship, + SAMLConfiguration, Scan, StateChoices, + StatusChoices, Task, User, UserRoleRelationship, @@ -1159,6 +1160,8 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer): ) elif secret_type == ProviderSecret.TypeChoices.ROLE: serializer = AWSRoleAssumptionProviderSecret(data=secret) + elif secret_type == ProviderSecret.TypeChoices.SERVICE_ACCOUNT: + serializer = GCPServiceAccountProviderSecret(data=secret) else: raise serializers.ValidationError( {"secret_type": f"Secret type not supported: {secret_type}"} @@ -1197,7 +1200,7 @@ class M365ProviderSecret(serializers.Serializer): client_secret = serializers.CharField() tenant_id = serializers.CharField() user = serializers.EmailField() - encrypted_password = serializers.CharField() + password = serializers.CharField() class Meta: resource_name = "provider-secrets" @@ -1212,6 +1215,13 @@ class GCPProviderSecret(serializers.Serializer): resource_name = "provider-secrets" +class GCPServiceAccountProviderSecret(serializers.Serializer): + service_account_key = serializers.JSONField() + + class Meta: + resource_name = "provider-secrets" + + class KubernetesProviderSecret(serializers.Serializer): kubeconfig_content = serializers.CharField() @@ -1670,130 +1680,61 @@ class RoleProviderGroupRelationshipSerializer(RLSSerializer, BaseWriteSerializer # Compliance overview -class ComplianceOverviewSerializer(RLSSerializer): +class ComplianceOverviewSerializer(serializers.Serializer): """ - Serializer for the ComplianceOverview model. + Serializer for compliance requirement status aggregated by compliance framework. + + This serializer is used to format aggregated compliance framework data, + providing counts of passed, failed, and manual requirements along with + an overall global status for each framework. """ - requirements_status = serializers.SerializerMethodField( - read_only=True, method_name="get_requirements_status" - ) - provider_type = serializers.SerializerMethodField(read_only=True) + # Add ID field which will be used for resource identification + id = serializers.CharField() + framework = serializers.CharField() + version = serializers.CharField() + requirements_passed = serializers.IntegerField() + requirements_failed = serializers.IntegerField() + requirements_manual = serializers.IntegerField() + total_requirements = serializers.IntegerField() - class Meta: - model = ComplianceOverview - fields = [ - "id", - "inserted_at", - "compliance_id", - "framework", - "version", - "requirements_status", - "region", - "provider_type", - "scan", - "url", - ] - - @extend_schema_field( - { - "type": "object", - "properties": { - "passed": {"type": "integer"}, - "failed": {"type": "integer"}, - "manual": {"type": "integer"}, - "total": {"type": "integer"}, - }, - } - ) - def get_requirements_status(self, obj): - return { - "passed": obj.requirements_passed, - "failed": obj.requirements_failed, - "manual": obj.requirements_manual, - "total": obj.total_requirements, - } - - @extend_schema_field(serializers.CharField(allow_null=True)) - def get_provider_type(self, obj): - """ - Retrieves the provider_type from scan.provider.provider_type. - """ - try: - return obj.scan.provider.provider - except AttributeError: - return None + class JSONAPIMeta: + resource_name = "compliance-overviews" -class ComplianceOverviewFullSerializer(ComplianceOverviewSerializer): - requirements = serializers.SerializerMethodField(read_only=True) +class ComplianceOverviewDetailSerializer(serializers.Serializer): + """ + Serializer for detailed compliance requirement information. - class Meta(ComplianceOverviewSerializer.Meta): - fields = ComplianceOverviewSerializer.Meta.fields + [ - "description", - "requirements", - ] + This serializer formats the aggregated requirement data, showing detailed status + and counts for each requirement across all regions. + """ - @extend_schema_field( - { - "type": "object", - "properties": { - "requirement_id": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "checks": { - "type": "object", - "properties": { - "check_name": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": ["PASS", "FAIL", None], - }, - }, - } - }, - "description": "Each key in the 'checks' object is a check name, with values as " - "'PASS', 'FAIL', or null.", - }, - "status": { - "type": "string", - "enum": ["PASS", "FAIL", "MANUAL"], - }, - "attributes": { - "type": "array", - "items": { - "type": "object", - }, - }, - "description": {"type": "string"}, - "checks_status": { - "type": "object", - "properties": { - "total": {"type": "integer"}, - "pass": {"type": "integer"}, - "fail": {"type": "integer"}, - "manual": {"type": "integer"}, - }, - }, - }, - } - }, - } - ) - def get_requirements(self, obj): - """ - Returns the detailed structure of requirements. - """ - return obj.requirements + id = serializers.CharField() + framework = serializers.CharField() + version = serializers.CharField() + description = serializers.CharField() + status = serializers.ChoiceField(choices=StatusChoices.choices) + + class JSONAPIMeta: + resource_name = "compliance-requirements-details" + + +class ComplianceOverviewAttributesSerializer(serializers.Serializer): + id = serializers.CharField() + framework = serializers.CharField() + version = serializers.CharField() + description = serializers.CharField() + attributes = serializers.JSONField() + + class JSONAPIMeta: + resource_name = "compliance-requirements-attributes" class ComplianceOverviewMetadataSerializer(serializers.Serializer): regions = serializers.ListField(child=serializers.CharField(), allow_empty=True) - class Meta: + class JSONAPIMeta: resource_name = "compliance-overviews-metadata" @@ -2119,3 +2060,25 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer): IntegrationProviderRelationship.objects.bulk_create(new_relationships) return super().update(instance, validated_data) + + +# SSO + + +class SamlInitiateSerializer(serializers.Serializer): + email_domain = serializers.CharField() + + class JSONAPIMeta: + resource_name = "saml-initiate" + + +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"] diff --git a/api/src/backend/api/v1/urls.py b/api/src/backend/api/v1/urls.py index c324314f86..967b40ff87 100644 --- a/api/src/backend/api/v1/urls.py +++ b/api/src/backend/api/v1/urls.py @@ -22,10 +22,13 @@ from api.v1.views import ( ResourceViewSet, RoleProviderGroupRelationshipView, RoleViewSet, + SAMLConfigurationViewSet, + SAMLInitiateAPIView, ScanViewSet, ScheduleViewSet, SchemaView, TaskViewSet, + TenantFinishACSView, TenantMembersViewSet, TenantViewSet, UserRoleRelationshipView, @@ -49,6 +52,7 @@ 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") tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant") tenants_router.register( @@ -112,6 +116,17 @@ urlpatterns = [ ), name="provider_group-providers-relationship", ), + # API endpoint to start SAML SSO flow + path( + "auth/saml/initiate/", SAMLInitiateAPIView.as_view(), name="api_saml_initiate" + ), + # Allauth SAML endpoints for tenants + path("accounts/", include("allauth.urls")), + path( + "api/v1/accounts/saml//acs/finish/", + TenantFinishACSView.as_view(), + name="saml_finish_acs", + ), 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 1e3a241a28..895812b5c9 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -3,8 +3,10 @@ import os from datetime import datetime, timedelta, timezone 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 from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError from celery.result import AsyncResult from config.env import env @@ -17,19 +19,20 @@ from django.conf import settings as django_settings from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.search import SearchQuery from django.db import transaction -from django.db.models import Count, Exists, F, OuterRef, Prefetch, Q, Subquery, Sum +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.http import HttpResponse, JsonResponse +from django.shortcuts import redirect from django.urls import reverse from django.utils.dateparse import parse_date from django.utils.decorators import method_decorator from django.views.decorators.cache import cache_control from django_celery_beat.models import PeriodicTask from drf_spectacular.settings import spectacular_settings +from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import ( OpenApiParameter, OpenApiResponse, - OpenApiTypes, extend_schema, extend_schema_view, ) @@ -58,8 +61,13 @@ from tasks.tasks import ( ) from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset -from api.compliance import get_compliance_frameworks +from api.compliance import ( + PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, + 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, FindingFilter, @@ -81,6 +89,7 @@ from api.filters import ( ) from api.models import ( ComplianceOverview, + ComplianceRequirementOverview, Finding, Integration, Invitation, @@ -94,6 +103,8 @@ from api.models import ( ResourceScanSummary, Role, RoleProviderGroupRelationship, + SAMLConfiguration, + SAMLDomainIndex, Scan, ScanSummary, SeverityChoices, @@ -111,9 +122,10 @@ from api.utils import ( validate_invitation, ) from api.uuid_utils import datetime_to_uuid7, uuid7_start -from api.v1.mixins import PaginateByPkMixin +from api.v1.mixins import PaginateByPkMixin, TaskManagementMixin from api.v1.serializers import ( - ComplianceOverviewFullSerializer, + ComplianceOverviewAttributesSerializer, + ComplianceOverviewDetailSerializer, ComplianceOverviewMetadataSerializer, ComplianceOverviewSerializer, FindingDynamicFilterSerializer, @@ -146,6 +158,8 @@ from api.v1.serializers import ( RoleProviderGroupRelationshipSerializer, RoleSerializer, RoleUpdateSerializer, + SAMLConfigurationSerializer, + SamlInitiateSerializer, ScanComplianceReportSerializer, ScanCreateSerializer, ScanReportSerializer, @@ -260,7 +274,7 @@ class SchemaView(SpectacularAPIView): def get(self, request, *args, **kwargs): spectacular_settings.TITLE = "Prowler API" - spectacular_settings.VERSION = "1.8.1" + spectacular_settings.VERSION = "1.9.0" spectacular_settings.DESCRIPTION = ( "Prowler API specification.\n\nThis file is auto-generated." ) @@ -377,6 +391,163 @@ class GithubSocialLoginView(SocialLoginView): return original_response +@extend_schema(exclude=True) +class SAMLInitiateAPIView(GenericAPIView): + serializer_class = SamlInitiateSerializer + permission_classes = [] + + def post(self, request, *args, **kwargs): + 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() + + 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 + 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, + ) + + saml_login_url = reverse( + "saml_login", kwargs={"organization_slug": config.email_domain} + ) + return redirect(f"{saml_login_url}?email={email}") + + +@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). + + 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() + + 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 + + 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 + + extra = social_account.extra_data + user.first_name = extra.get("firstName", [""])[0] + user.last_name = extra.get("lastName", [""])[0] + user.company_name = extra.get("organization", [""])[0] + user.name = f"{user.first_name} {user.last_name}".strip() + 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() + 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, + ) + + serializer = TokenSocialLoginSerializer(data={"email": user.email}) + serializer.is_valid(raise_exception=True) + return JsonResponse( + { + "type": "saml-social-tokens", + "attributes": serializer.validated_data, + } + ) + + @extend_schema_view( list=extend_schema( tags=["User"], @@ -1160,7 +1331,9 @@ class ProviderViewSet(BaseRLSViewSet): 200: OpenApiResponse(description="Report obtained successfully"), 202: OpenApiResponse(description="The task is in progress"), 403: OpenApiResponse(description="There is a problem with credentials"), - 404: OpenApiResponse(description="The scan has no reports"), + 404: OpenApiResponse( + description="The scan has no reports, or the report generation task has not started yet" + ), }, ), compliance=extend_schema( @@ -1281,7 +1454,7 @@ class ScanViewSet(BaseRLSViewSet): try: task = Task.objects.get( task_runner_task__task_name="scan-report", - task_runner_task__task_args__contains=str(scan_instance.id), + task_runner_task__task_kwargs__contains=str(scan_instance.id), ) except Task.DoesNotExist: return None @@ -1363,7 +1536,9 @@ class ScanViewSet(BaseRLSViewSet): code = e.response.get("Error", {}).get("Code") if code == "NoSuchKey": return Response( - {"detail": "The scan has no reports."}, + { + "detail": "The scan has no reports, or the report generation task has not started yet." + }, status=status.HTTP_404_NOT_FOUND, ) return Response( @@ -1376,7 +1551,9 @@ class ScanViewSet(BaseRLSViewSet): files = glob.glob(path_pattern) if not files: return Response( - {"detail": "The scan has no reports."}, + { + "detail": "The scan has no reports, or the report generation task has not started yet." + }, status=status.HTTP_404_NOT_FOUND, ) filepath = files[0] @@ -1402,7 +1579,10 @@ class ScanViewSet(BaseRLSViewSet): if not scan.output_location: return Response( - {"detail": "The scan has no reports."}, status=status.HTTP_404_NOT_FOUND + { + "detail": "The scan has no reports, or the report generation task has not started yet." + }, + status=status.HTTP_404_NOT_FOUND, ) if scan.output_location.startswith("s3://"): @@ -1440,7 +1620,10 @@ class ScanViewSet(BaseRLSViewSet): if not scan.output_location: return Response( - {"detail": "The scan has no reports."}, status=status.HTTP_404_NOT_FOUND + { + "detail": "The scan has no reports, or the report generation task has not started yet." + }, + status=status.HTTP_404_NOT_FOUND, ) if scan.output_location.startswith("s3://"): @@ -1477,10 +1660,10 @@ class ScanViewSet(BaseRLSViewSet): }, ) + prowler_task = Task.objects.get(id=task.id) scan.task_id = task.id scan.save(update_fields=["task_id"]) - prowler_task = Task.objects.get(id=task.id) self.response_serializer_class = TaskSerializer output_serializer = self.get_serializer(prowler_task) @@ -2379,8 +2562,7 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): list=extend_schema( tags=["Compliance Overview"], summary="List compliance overviews for a scan", - description="Retrieve an overview of all the compliance in a given scan. If no region filters are provided, the" - " region with the most fails will be returned by default.", + description="Retrieve an overview of all the compliance in a given scan.", parameters=[ OpenApiParameter( name="filter[scan_id]", @@ -2390,12 +2572,18 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): description="Related scan ID.", ), ], - ), - retrieve=extend_schema( - tags=["Compliance Overview"], - summary="Retrieve data from a specific compliance overview", - description="Fetch detailed information about a specific compliance overview by its ID, including detailed " - "requirement information and check's status.", + responses={ + 200: OpenApiResponse( + description="Compliance overviews obtained successfully", + response=ComplianceOverviewSerializer(many=True), + ), + 202: OpenApiResponse( + description="The task is in progress", response=TaskSerializer + ), + 500: OpenApiResponse( + description="Compliance overviews generation task failed" + ), + }, ), metadata=extend_schema( tags=["Compliance Overview"], @@ -2411,19 +2599,84 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet): description="Related scan ID.", ), ], + responses={ + 200: OpenApiResponse( + description="Compliance overviews metadata obtained successfully", + response=ComplianceOverviewMetadataSerializer, + ), + 202: OpenApiResponse(description="The task is in progress"), + 500: OpenApiResponse( + description="Compliance overviews generation task failed" + ), + }, + ), + requirements=extend_schema( + tags=["Compliance Overview"], + summary="List compliance requirements overview for a scan", + description="Retrieve a detailed overview of compliance requirements in a given scan, grouped by compliance " + "framework. This endpoint provides requirement-level details and aggregates status across regions.", + parameters=[ + OpenApiParameter( + name="filter[scan_id]", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.QUERY, + description="Related scan ID.", + ), + OpenApiParameter( + name="filter[compliance_id]", + required=True, + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Compliance ID.", + ), + ], + responses={ + 200: OpenApiResponse( + description="Compliance requirement details obtained successfully", + response=ComplianceOverviewDetailSerializer(many=True), + ), + 202: OpenApiResponse(description="The task is in progress"), + 500: OpenApiResponse( + description="Compliance overviews generation task failed" + ), + }, + filters=True, + ), + attributes=extend_schema( + tags=["Compliance Overview"], + summary="Get compliance requirement attributes", + description="Retrieve detailed attribute information for all requirements in a specific compliance framework " + "along with the associated check IDs for each requirement.", + parameters=[ + OpenApiParameter( + name="filter[compliance_id]", + required=True, + type=str, + location=OpenApiParameter.QUERY, + description="Compliance framework ID to get attributes for.", + ), + ], + responses={ + 200: OpenApiResponse( + description="Compliance attributes obtained successfully", + response=ComplianceOverviewAttributesSerializer(many=True), + ), + }, ), ) @method_decorator(CACHE_DECORATOR, name="list") -@method_decorator(CACHE_DECORATOR, name="retrieve") -class ComplianceOverviewViewSet(BaseRLSViewSet): +@method_decorator(CACHE_DECORATOR, name="requirements") +@method_decorator(CACHE_DECORATOR, name="attributes") +class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): pagination_class = ComplianceOverviewPagination - queryset = ComplianceOverview.objects.all() + queryset = ComplianceRequirementOverview.objects.all() serializer_class = ComplianceOverviewSerializer filterset_class = ComplianceOverviewFilter http_method_names = ["get"] search_fields = ["compliance_id"] ordering = ["compliance_id"] - ordering_fields = ["inserted_at", "compliance_id", "framework", "region"] + ordering_fields = ["compliance_id"] # RBAC required permissions (implicit -> MANAGE_PROVIDERS enable unlimited visibility or check the visibility of # the provider through the provider group) required_permissions = [] @@ -2434,51 +2687,44 @@ class ComplianceOverviewViewSet(BaseRLSViewSet): role, Permissions.UNLIMITED_VISIBILITY.value, False ) - if self.action == "retrieve": - if unlimited_visibility: - # User has unlimited visibility, return all compliance - return ComplianceOverview.objects.filter( - tenant_id=self.request.tenant_id - ) - - providers = get_providers(role) - return ComplianceOverview.objects.filter( - tenant_id=self.request.tenant_id, scan__provider__in=providers - ) - if unlimited_visibility: base_queryset = self.filter_queryset( - ComplianceOverview.objects.filter(tenant_id=self.request.tenant_id) + ComplianceRequirementOverview.objects.filter( + tenant_id=self.request.tenant_id + ) ) else: providers = Provider.objects.filter( provider_groups__in=role.provider_groups.all() ).distinct() base_queryset = self.filter_queryset( - ComplianceOverview.objects.filter( + ComplianceRequirementOverview.objects.filter( tenant_id=self.request.tenant_id, scan__provider__in=providers ) ) - max_failed_ids = ( - base_queryset.filter(compliance_id=OuterRef("compliance_id")) - .order_by("-requirements_failed") - .values("id")[:1] - ) - - return base_queryset.filter(id__in=Subquery(max_failed_ids)).order_by( - "compliance_id" - ) + return base_queryset def get_serializer_class(self): - if self.action == "retrieve": - return ComplianceOverviewFullSerializer + if hasattr(self, "response_serializer_class"): + return self.response_serializer_class + elif self.action == "list": + return ComplianceOverviewSerializer elif self.action == "metadata": return ComplianceOverviewMetadataSerializer + elif self.action == "attributes": + return ComplianceOverviewAttributesSerializer + elif self.action == "requirements": + return ComplianceOverviewDetailSerializer return super().get_serializer_class() + @extend_schema(exclude=True) + def retrieve(self, request, *args, **kwargs): + raise MethodNotAllowed(method="GET") + def list(self, request, *args, **kwargs): - if not request.query_params.get("filter[scan_id]"): + scan_id = request.query_params.get("filter[scan_id]") + if not scan_id: raise ValidationError( [ { @@ -2489,7 +2735,82 @@ class ComplianceOverviewViewSet(BaseRLSViewSet): } ] ) - return super().list(request, *args, **kwargs) + try: + if task := self.get_task_response_if_running( + task_name="scan-compliance-overviews", + task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id}, + raise_on_not_found=False, + ): + return task + except TaskFailedException: + return Response( + {"detail": "Task failed to generate compliance overview data."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + queryset = self.filter_queryset(self.filter_queryset(self.get_queryset())) + + requirement_status_subquery = queryset.values( + "compliance_id", "requirement_id" + ).annotate( + fail_count=Count("id", filter=Q(requirement_status="FAIL")), + pass_count=Count("id", filter=Q(requirement_status="PASS")), + total_count=Count("id"), + ) + + compliance_data = {} + framework_info = {} + + for item in queryset.values("compliance_id", "framework", "version").distinct(): + framework_info[item["compliance_id"]] = { + "framework": item["framework"], + "version": item["version"], + } + + for item in requirement_status_subquery: + compliance_id = item["compliance_id"] + + if item["fail_count"] > 0: + req_status = "FAIL" + elif item["pass_count"] == item["total_count"]: + req_status = "PASS" + else: + req_status = "MANUAL" + + if compliance_id not in compliance_data: + compliance_data[compliance_id] = { + "total_requirements": 0, + "requirements_passed": 0, + "requirements_failed": 0, + "requirements_manual": 0, + } + + compliance_data[compliance_id]["total_requirements"] += 1 + if req_status == "PASS": + compliance_data[compliance_id]["requirements_passed"] += 1 + elif req_status == "FAIL": + compliance_data[compliance_id]["requirements_failed"] += 1 + else: + compliance_data[compliance_id]["requirements_manual"] += 1 + + response_data = [] + for compliance_id, data in compliance_data.items(): + framework = framework_info.get(compliance_id, {}) + + response_data.append( + { + "id": compliance_id, + "compliance_id": compliance_id, + "framework": framework.get("framework", ""), + "version": framework.get("version", ""), + "requirements_passed": data["requirements_passed"], + "requirements_failed": data["requirements_failed"], + "requirements_manual": data["requirements_manual"], + "total_requirements": data["total_requirements"], + } + ) + + serializer = self.get_serializer(response_data, many=True) + return Response(serializer.data) @action(detail=False, methods=["get"], url_name="metadata") def metadata(self, request): @@ -2505,11 +2826,21 @@ class ComplianceOverviewViewSet(BaseRLSViewSet): } ] ) - - tenant_id = self.request.tenant_id - + try: + if task := self.get_task_response_if_running( + task_name="scan-compliance-overviews", + task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id}, + raise_on_not_found=False, + ): + return task + except TaskFailedException: + return Response( + {"detail": "Task failed to generate compliance overview data."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) regions = list( - ComplianceOverview.objects.filter(tenant_id=tenant_id, scan_id=scan_id) + self.get_queryset() + .filter(scan_id=scan_id) .values_list("region", flat=True) .order_by("region") .distinct() @@ -2520,6 +2851,160 @@ class ComplianceOverviewViewSet(BaseRLSViewSet): serializer.is_valid(raise_exception=True) return Response(serializer.data, status=status.HTTP_200_OK) + @action(detail=False, methods=["get"], url_name="requirements") + def requirements(self, request): + scan_id = request.query_params.get("filter[scan_id]") + compliance_id = request.query_params.get("filter[compliance_id]") + + if not scan_id: + raise ValidationError( + [ + { + "detail": "This query parameter is required.", + "status": 400, + "source": {"pointer": "filter[scan_id]"}, + "code": "required", + } + ] + ) + + if not compliance_id: + raise ValidationError( + [ + { + "detail": "This query parameter is required.", + "status": 400, + "source": {"pointer": "filter[compliance_id]"}, + "code": "required", + } + ] + ) + try: + if task := self.get_task_response_if_running( + task_name="scan-compliance-overviews", + task_kwargs={"tenant_id": self.request.tenant_id, "scan_id": scan_id}, + raise_on_not_found=False, + ): + return task + except TaskFailedException: + return Response( + {"detail": "Task failed to generate compliance overview data."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + filtered_queryset = self.filter_queryset(self.get_queryset()) + + all_requirements = ( + filtered_queryset.values( + "requirement_id", "framework", "version", "description" + ) + .distinct() + .annotate( + total_instances=Count("id"), + manual_count=Count("id", filter=Q(requirement_status="MANUAL")), + ) + ) + + passed_instances = ( + filtered_queryset.filter(requirement_status="PASS") + .values("requirement_id") + .annotate(pass_count=Count("id")) + ) + + passed_counts = { + item["requirement_id"]: item["pass_count"] for item in passed_instances + } + + requirements_summary = [] + for requirement in all_requirements: + requirement_id = requirement["requirement_id"] + total_instances = requirement["total_instances"] + passed_count = passed_counts.get(requirement_id, 0) + is_manual = requirement["manual_count"] == total_instances + if is_manual: + requirement_status = "MANUAL" + elif passed_count == total_instances: + requirement_status = "PASS" + else: + requirement_status = "FAIL" + + requirements_summary.append( + { + "id": requirement_id, + "framework": requirement["framework"], + "version": requirement["version"], + "description": requirement["description"], + "status": requirement_status, + } + ) + + serializer = self.get_serializer(requirements_summary, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @action(detail=False, methods=["get"], url_name="attributes") + def attributes(self, request): + compliance_id = request.query_params.get("filter[compliance_id]") + if not compliance_id: + raise ValidationError( + [ + { + "detail": "This query parameter is required.", + "status": 400, + "source": {"pointer": "filter[compliance_id]"}, + "code": "required", + } + ] + ) + + provider_type = None + try: + sample_requirement = ( + self.get_queryset().filter(compliance_id=compliance_id).first() + ) + + if sample_requirement: + provider_type = sample_requirement.scan.provider.provider + except Exception: + pass + + # If we couldn't determine from database, try each provider type + if not provider_type: + for pt in Provider.ProviderChoices.values: + if compliance_id in PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE.get(pt, {}): + provider_type = pt + break + + if not provider_type: + raise NotFound(detail=f"Compliance framework '{compliance_id}' not found.") + + compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE.get( + provider_type, {} + ) + compliance_framework = compliance_template.get(compliance_id) + + if not compliance_framework: + raise NotFound(detail=f"Compliance framework '{compliance_id}' not found.") + + attribute_data = [] + for requirement_id, requirement in compliance_framework.get( + "requirements", {} + ).items(): + check_ids = list(requirement.get("checks", {}).keys()) + + metadata = requirement.get("attributes", []) + + attribute_data.append( + { + "id": requirement_id, + "framework": compliance_framework.get("framework", ""), + "version": compliance_framework.get("version", ""), + "description": requirement.get("description", ""), + "attributes": {"metadata": metadata, "check_ids": check_ids}, + } + ) + + serializer = self.get_serializer(attribute_data, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + @extend_schema(tags=["Overview"]) @extend_schema_view( @@ -2566,7 +3051,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet): class OverviewViewSet(BaseRLSViewSet): queryset = ComplianceOverview.objects.all() http_method_names = ["get"] - ordering = ["-id"] + ordering = ["-inserted_at"] # RBAC required permissions (implicit -> MANAGE_PROVIDERS enable unlimited visibility or check the visibility of # the provider through the provider group) required_permissions = [] diff --git a/api/src/backend/config/celery.py b/api/src/backend/config/celery.py index 4c667b7bc5..b3a0ab4b68 100644 --- a/api/src/backend/config/celery.py +++ b/api/src/backend/config/celery.py @@ -1,6 +1,13 @@ +import warnings + from celery import Celery, Task from config.env import env +# Suppress specific warnings from django-rest-auth: https://github.com/iMerica/dj-rest-auth/issues/684 +warnings.filterwarnings( + "ignore", category=UserWarning, module="dj_rest_auth.registration.serializers" +) + BROKER_VISIBILITY_TIMEOUT = env.int("DJANGO_BROKER_VISIBILITY_TIMEOUT", default=86400) celery_app = Celery("tasks") diff --git a/api/src/backend/config/django/base.py b/api/src/backend/config/django/base.py index 8f3f0bb42e..47c6320ce1 100644 --- a/api/src/backend/config/django/base.py +++ b/api/src/backend/config/django/base.py @@ -10,6 +10,7 @@ from config.settings.social_login import * # noqa 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") # Application definition @@ -26,16 +27,19 @@ INSTALLED_APPS = [ "rest_framework", "corsheaders", "drf_spectacular", + "drf_spectacular_jsonapi", "django_guid", "rest_framework_json_api", "django_celery_results", "django_celery_beat", "rest_framework_simplejwt.token_blacklist", "allauth", + "django.contrib.sites", "allauth.account", "allauth.socialaccount", "allauth.socialaccount.providers.google", "allauth.socialaccount.providers.github", + "allauth.socialaccount.providers.saml", "dj_rest_auth.registration", "rest_framework.authtoken", ] diff --git a/api/src/backend/config/settings/social_login.py b/api/src/backend/config/settings/social_login.py index 6a48a0c38f..cf4e21d75b 100644 --- a/api/src/backend/config/settings/social_login.py +++ b/api/src/backend/config/settings/social_login.py @@ -11,8 +11,7 @@ GITHUB_OAUTH_CALLBACK_URL = env("SOCIAL_GITHUB_OAUTH_CALLBACK_URL", default="") # Allauth settings ACCOUNT_LOGIN_METHODS = {"email"} # Use Email / Password authentication -ACCOUNT_USERNAME_REQUIRED = False -ACCOUNT_EMAIL_REQUIRED = True +ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"] ACCOUNT_EMAIL_VERIFICATION = "none" # Do not require email confirmation ACCOUNT_USER_MODEL_USERNAME_FIELD = None REST_AUTH = { @@ -25,6 +24,11 @@ SOCIALACCOUNT_EMAIL_AUTHENTICATION = True # Connect local account and social account if local account with that email address already exists SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True SOCIALACCOUNT_ADAPTER = "api.adapters.ProwlerSocialAccountAdapter" + +# SAML keys +SAML_PUBLIC_CERT = env("SAML_PUBLIC_CERT", default="") +SAML_PRIVATE_KEY = env("SAML_PRIVATE_KEY", default="") + SOCIALACCOUNT_PROVIDERS = { "google": { "APP": { @@ -50,4 +54,18 @@ SOCIALACCOUNT_PROVIDERS = { "read:org", ], }, + "saml": { + "use_nameid_for_email": True, + "sp": { + "entity_id": "urn:prowler.com:sp", + }, + "advanced": { + "x509cert": SAML_PUBLIC_CERT, + "private_key": SAML_PRIVATE_KEY, + "name_id_format": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "authn_request_signed": True, + "want_assertion_signed": True, + "want_message_signed": True, + }, + }, } diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index e47357f0e4..c1f869f8dc 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 @@ -15,6 +16,7 @@ from tasks.jobs.backfill import backfill_resource_scan_summaries from api.db_utils import rls_transaction from api.models import ( ComplianceOverview, + ComplianceRequirementOverview, Finding, Integration, IntegrationProviderRelationship, @@ -26,9 +28,12 @@ from api.models import ( Resource, ResourceTag, Role, + SAMLConfiguration, + SAMLDomainIndex, Scan, ScanSummary, StateChoices, + StatusChoices, Task, User, UserRoleRelationship, @@ -777,6 +782,114 @@ def compliance_overviews_fixture(scans_fixture, tenants_fixture): return compliance_overview1, compliance_overview2 +@pytest.fixture +def compliance_requirements_overviews_fixture(scans_fixture, tenants_fixture): + """Fixture for ComplianceRequirementOverview objects used by the new ComplianceOverviewViewSet.""" + tenant = tenants_fixture[0] + scan1, scan2, scan3 = scans_fixture + + # Create ComplianceRequirementOverview objects for scan1 + requirement_overview1 = ComplianceRequirementOverview.objects.create( + tenant=tenant, + scan=scan1, + compliance_id="aws_account_security_onboarding_aws", + framework="AWS-Account-Security-Onboarding", + version="1.0", + description="Description for AWS Account Security Onboarding", + region="eu-west-1", + requirement_id="requirement1", + requirement_status=StatusChoices.PASS, + passed_checks=2, + failed_checks=0, + total_checks=2, + ) + + requirement_overview2 = ComplianceRequirementOverview.objects.create( + tenant=tenant, + scan=scan1, + compliance_id="aws_account_security_onboarding_aws", + framework="AWS-Account-Security-Onboarding", + version="1.0", + description="Description for AWS Account Security Onboarding", + region="eu-west-1", + requirement_id="requirement2", + requirement_status=StatusChoices.PASS, + passed_checks=2, + failed_checks=0, + total_checks=2, + ) + + requirement_overview3 = ComplianceRequirementOverview.objects.create( + tenant=tenant, + scan=scan1, + compliance_id="aws_account_security_onboarding_aws", + framework="AWS-Account-Security-Onboarding", + version="1.0", + description="Description for AWS Account Security Onboarding", + region="eu-west-2", + requirement_id="requirement1", + requirement_status=StatusChoices.PASS, + passed_checks=2, + failed_checks=0, + total_checks=2, + ) + + requirement_overview4 = ComplianceRequirementOverview.objects.create( + tenant=tenant, + scan=scan1, + compliance_id="aws_account_security_onboarding_aws", + framework="AWS-Account-Security-Onboarding", + version="1.0", + description="Description for AWS Account Security Onboarding", + region="eu-west-2", + requirement_id="requirement2", + requirement_status=StatusChoices.FAIL, + passed_checks=1, + failed_checks=1, + total_checks=2, + ) + + requirement_overview5 = ComplianceRequirementOverview.objects.create( + tenant=tenant, + scan=scan1, + compliance_id="aws_account_security_onboarding_aws", + framework="AWS-Account-Security-Onboarding", + version="1.0", + description="Description for AWS Account Security Onboarding (MANUAL)", + region="eu-west-2", + requirement_id="requirement3", + requirement_status=StatusChoices.MANUAL, + passed_checks=0, + failed_checks=0, + total_checks=0, + ) + + # Create a different compliance framework for testing + requirement_overview6 = ComplianceRequirementOverview.objects.create( + tenant=tenant, + scan=scan1, + compliance_id="cis_1.4_aws", + framework="CIS-1.4-AWS", + version="1.4", + description="CIS AWS Foundations Benchmark v1.4.0", + region="eu-west-1", + requirement_id="cis_requirement1", + requirement_status=StatusChoices.FAIL, + passed_checks=0, + failed_checks=3, + total_checks=3, + ) + + return ( + requirement_overview1, + requirement_overview2, + requirement_overview3, + requirement_overview4, + requirement_overview5, + requirement_overview6, + ) + + def get_api_tokens( api_client, user_email: str, user_password: str, tenant_id: str = None ) -> tuple[str, str]: @@ -970,6 +1083,64 @@ 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" + + 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, + ) + + 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"], + } + + account = MagicMock() + account.provider = "saml" + account.extra_data = extra_data + + sociallogin = MagicMock(spec=SocialLogin) + sociallogin.account = account + sociallogin.user = user + + return sociallogin + + def get_authorization_header(access_token: str) -> dict: return {"Authorization": f"Bearer {access_token}"} diff --git a/api/src/backend/manage.py b/api/src/backend/manage.py index 590fbeb713..b4b959d5b1 100755 --- a/api/src/backend/manage.py +++ b/api/src/backend/manage.py @@ -3,6 +3,12 @@ import os import sys +import warnings + +# Suppress specific warnings from django-rest-auth: https://github.com/iMerica/dj-rest-auth/issues/684 +warnings.filterwarnings( + "ignore", category=UserWarning, module="dj_rest_auth.registration.serializers" +) def main(): diff --git a/api/src/backend/tasks/jobs/scan.py b/api/src/backend/tasks/jobs/scan.py index 81fbfbb0e9..684fbfd759 100644 --- a/api/src/backend/tasks/jobs/scan.py +++ b/api/src/backend/tasks/jobs/scan.py @@ -13,9 +13,9 @@ from api.compliance import ( PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE, generate_scan_compliance, ) -from api.db_utils import rls_transaction +from api.db_utils import create_objects_in_batches, rls_transaction from api.models import ( - ComplianceOverview, + ComplianceRequirementOverview, Finding, Provider, Resource, @@ -119,11 +119,11 @@ def perform_prowler_scan( ValueError: If the provider cannot be connected. """ - check_status_by_region = {} exception = None unique_resources = set() scan_resource_cache: set[tuple[str, str, str, str]] = set() start_time = time.time() + exc = None with rls_transaction(tenant_id): provider_instance = Provider.objects.get(pk=provider_id) @@ -139,7 +139,7 @@ def perform_prowler_scan( provider_instance.connected = True except Exception as e: provider_instance.connected = False - raise ValueError( + exc = ValueError( f"Provider {provider_instance.provider} is not connected: {e}" ) finally: @@ -148,6 +148,11 @@ def perform_prowler_scan( ) provider_instance.save() + # If the provider is not connected, raise an exception outside the transaction. + # If raised within the transaction, the transaction will be rolled back and the provider will not be marked as not connected. + if exc: + raise exc + prowler_scan = ProwlerScan(provider=prowler_provider, checks=checks_to_execute) resource_cache = {} @@ -287,16 +292,6 @@ def perform_prowler_scan( ) finding_instance.add_resources([resource_instance]) - # Update compliance data if applicable - if finding.status.value == "MUTED": - continue - - region_dict = check_status_by_region.setdefault(finding.region, {}) - current_status = region_dict.get(finding.check_id) - if current_status == "FAIL": - continue - region_dict[finding.check_id] = finding.status.value - # Update scan resource summaries scan_resource_cache.add( ( @@ -329,63 +324,6 @@ def perform_prowler_scan( if exception is not None: raise exception - try: - regions = prowler_provider.get_regions() - except AttributeError: - regions = set() - - compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[ - provider_instance.provider - ] - compliance_overview_by_region = { - region: deepcopy(compliance_template) for region in regions - } - - for region, check_status in check_status_by_region.items(): - compliance_data = compliance_overview_by_region.setdefault( - region, deepcopy(compliance_template) - ) - for check_name, status in check_status.items(): - generate_scan_compliance( - compliance_data, - provider_instance.provider, - check_name, - status, - ) - - # Prepare compliance overview objects - compliance_overview_objects = [] - for region, compliance_data in compliance_overview_by_region.items(): - for compliance_id, compliance in compliance_data.items(): - compliance_overview_objects.append( - ComplianceOverview( - tenant_id=tenant_id, - scan=scan_instance, - region=region, - compliance_id=compliance_id, - framework=compliance["framework"], - version=compliance["version"], - description=compliance["description"], - requirements=compliance["requirements"], - requirements_passed=compliance["requirements_status"]["passed"], - requirements_failed=compliance["requirements_status"]["failed"], - requirements_manual=compliance["requirements_status"]["manual"], - total_requirements=compliance["total_requirements"], - ) - ) - try: - with rls_transaction(tenant_id): - ComplianceOverview.objects.bulk_create( - compliance_overview_objects, batch_size=500 - ) - except Exception as overview_exception: - import sentry_sdk - - sentry_sdk.capture_exception(overview_exception) - logger.error( - f"Error storing compliance overview for scan {scan_id}: {overview_exception}" - ) - try: resource_scan_summaries = [ ResourceScanSummary( @@ -564,3 +502,114 @@ def aggregate_findings(tenant_id: str, scan_id: str): for agg in aggregation } ScanSummary.objects.bulk_create(scan_aggregations, batch_size=3000) + + +def create_compliance_requirements(tenant_id: str, scan_id: str): + """ + Create detailed compliance requirement overview records for a scan. + + This function processes the compliance data collected during a scan and creates + individual records for each compliance requirement in each region. These detailed + records provide a granular view of compliance status. + + Args: + tenant_id (str): The ID of the tenant for which to create records. + scan_id (str): The ID of the scan for which to create records. + + Returns: + dict: A dictionary containing the number of requirements created and the regions processed. + + Raises: + ValidationError: If tenant_id is not a valid UUID. + """ + try: + with rls_transaction(tenant_id): + scan_instance = Scan.objects.get(pk=scan_id) + provider_instance = scan_instance.provider + prowler_provider = initialize_prowler_provider(provider_instance) + + # Get check status data by region from findings + check_status_by_region = {} + with rls_transaction(tenant_id): + findings = Finding.objects.filter(scan_id=scan_id, muted=False) + for finding in findings: + # Get region from resources + for resource in finding.resources.all(): + region = resource.region + region_dict = check_status_by_region.setdefault(region, {}) + current_status = region_dict.get(finding.check_id) + if current_status == "FAIL": + continue + region_dict[finding.check_id] = finding.status + + try: + # Try to get regions from provider + regions = prowler_provider.get_regions() + except (AttributeError, Exception): + # If not available, use regions from findings + regions = set(check_status_by_region.keys()) + + # Get compliance template for the provider + compliance_template = PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE[ + provider_instance.provider + ] + + # Create compliance data by region + compliance_overview_by_region = { + region: deepcopy(compliance_template) for region in regions + } + + # Apply check statuses to compliance data + for region, check_status in check_status_by_region.items(): + compliance_data = compliance_overview_by_region.setdefault( + region, deepcopy(compliance_template) + ) + for check_name, status in check_status.items(): + generate_scan_compliance( + compliance_data, + provider_instance.provider, + check_name, + status, + ) + + # Prepare compliance requirement objects + compliance_requirement_objects = [] + for region, compliance_data in compliance_overview_by_region.items(): + for compliance_id, compliance in compliance_data.items(): + # Create an overview record for each requirement within each compliance framework + for requirement_id, requirement in compliance["requirements"].items(): + compliance_requirement_objects.append( + ComplianceRequirementOverview( + tenant_id=tenant_id, + scan=scan_instance, + region=region, + compliance_id=compliance_id, + framework=compliance["framework"], + version=compliance["version"], + requirement_id=requirement_id, + description=requirement["description"], + passed_checks=requirement["checks_status"]["pass"], + failed_checks=requirement["checks_status"]["fail"], + total_checks=requirement["checks_status"]["total"], + requirement_status=requirement["status"], + ) + ) + + # Bulk create requirement records + create_objects_in_batches( + tenant_id, ComplianceRequirementOverview, compliance_requirement_objects + ) + + return { + "requirements_created": len(compliance_requirement_objects), + "regions_processed": list(regions), + "compliance_frameworks": ( + list(compliance_overview_by_region.get(list(regions)[0], {}).keys()) + if regions + else [] + ), + } + + except Exception as e: + logger.error(f"Error creating compliance requirements for scan {scan_id}: {e}") + raise e diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index 090c0c33d3..4f30b5fc68 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -17,7 +17,11 @@ from tasks.jobs.export import ( _generate_output_directory, _upload_to_s3, ) -from tasks.jobs.scan import aggregate_findings, perform_prowler_scan +from tasks.jobs.scan import ( + aggregate_findings, + create_compliance_requirements, + perform_prowler_scan, +) from tasks.utils import batched, get_next_execution_datetime from api.compliance import get_compliance_frameworks @@ -101,6 +105,7 @@ def perform_scan_task( 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 ), @@ -211,6 +216,9 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str): 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 ), @@ -371,3 +379,19 @@ def backfill_scan_resource_summaries_task(tenant_id: str, scan_id: str): scan_id (str): The scan identifier. """ return backfill_resource_scan_summaries(tenant_id=tenant_id, scan_id=scan_id) + + +@shared_task(base=RLSTask, name="scan-compliance-overviews") +def create_compliance_requirements_task(tenant_id: str, scan_id: str): + """ + Creates detailed compliance requirement records for a scan. + + This task processes the compliance data collected during a scan and creates + individual records for each compliance requirement in each region. These detailed + records provide a granular view of compliance status. + + Args: + tenant_id (str): The tenant ID for which to create records. + scan_id (str): The ID of the scan for which to create records. + """ + return create_compliance_requirements(tenant_id=tenant_id, scan_id=scan_id) diff --git a/api/src/backend/tasks/tests/test_scan.py b/api/src/backend/tasks/tests/test_scan.py index 8880b8bd03..e4ec0d4d41 100644 --- a/api/src/backend/tasks/tests/test_scan.py +++ b/api/src/backend/tasks/tests/test_scan.py @@ -1,16 +1,19 @@ import json import uuid +from datetime import datetime from unittest.mock import MagicMock, patch import pytest from tasks.jobs.scan import ( _create_finding_delta, _store_resources, + create_compliance_requirements, perform_prowler_scan, ) from tasks.utils import CustomEncoder from api.models import ( + ComplianceRequirementOverview, Finding, Provider, Resource, @@ -206,6 +209,10 @@ class TestPerformScan: scan.refresh_from_db() assert scan.state == StateChoices.FAILED + provider.refresh_from_db() + assert provider.connected is False + assert isinstance(provider.connection_last_checked_at, datetime) + @pytest.mark.parametrize( "last_status, new_status, expected_delta", [ @@ -230,7 +237,7 @@ class TestPerformScan: ): tenant_id = uuid.uuid4() provider_instance = MagicMock() - provider_instance.id = "provider456" + provider_instance.id = "provider123" finding = MagicMock() finding.resource_uid = "resource_uid_123" @@ -245,15 +252,16 @@ class TestPerformScan: resource_instance.region = finding.region mock_get_or_create_resource.return_value = (resource_instance, True) + tag_instance = MagicMock() mock_get_or_create_tag.return_value = (tag_instance, True) resource, resource_uid_tuple = _store_resources( - finding, tenant_id, provider_instance + finding, str(tenant_id), provider_instance ) mock_get_or_create_resource.assert_called_once_with( - tenant_id=tenant_id, + tenant_id=str(tenant_id), provider=provider_instance, uid=finding.resource_uid, defaults={ @@ -300,11 +308,11 @@ class TestPerformScan: mock_get_or_create_tag.return_value = (tag_instance, True) resource, resource_uid_tuple = _store_resources( - finding, tenant_id, provider_instance + finding, str(tenant_id), provider_instance ) mock_get_or_create_resource.assert_called_once_with( - tenant_id=tenant_id, + tenant_id=str(tenant_id), provider=provider_instance, uid=finding.resource_uid, defaults={ @@ -358,14 +366,14 @@ class TestPerformScan: ] resource, resource_uid_tuple = _store_resources( - finding, tenant_id, provider_instance + finding, str(tenant_id), provider_instance ) mock_get_or_create_tag.assert_any_call( - tenant_id=tenant_id, key="tag1", value="value1" + tenant_id=str(tenant_id), key="tag1", value="value1" ) mock_get_or_create_tag.assert_any_call( - tenant_id=tenant_id, key="tag2", value="value2" + tenant_id=str(tenant_id), key="tag2", value="value2" ) resource_instance.upsert_or_delete_tags.assert_called_once() tags_passed = resource_instance.upsert_or_delete_tags.call_args[1]["tags"] @@ -377,3 +385,808 @@ class TestPerformScan: # TODO Add tests for aggregations + + +@pytest.mark.django_db +class TestCreateComplianceRequirements: + def test_create_compliance_requirements_success( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + findings_fixture, + resources_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch("tasks.jobs.scan.generate_scan_compliance"), + patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects, + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + + scan.provider = provider + scan.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = [ + "us-east-1", + "us-west-2", + ] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + mock_compliance_template.__getitem__.return_value = { + "cis_1.4_aws": { + "framework": "CIS AWS Foundations Benchmark", + "version": "1.4.0", + "requirements": { + "1.1": { + "description": "Ensure root access key does not exist", + "checks_status": { + "pass": 0, + "fail": 0, + "manual": 0, + "total": 1, + }, + "status": "PASS", + }, + "1.2": { + "description": "Ensure MFA is enabled for root account", + "checks_status": { + "pass": 0, + "fail": 1, + "manual": 0, + "total": 1, + }, + "status": "FAIL", + }, + }, + }, + "aws_account_security_onboarding_aws": { + "framework": "AWS Account Security Onboarding", + "version": "1.0", + "requirements": { + "requirement1": { + "description": "Basic security requirement", + "checks_status": { + "pass": 1, + "fail": 0, + "manual": 0, + "total": 1, + }, + "status": "PASS", + }, + }, + }, + } + + mock_findings_filter.return_value = [] + + result = create_compliance_requirements(tenant_id, scan_id) + + assert "requirements_created" in result + assert "regions_processed" in result + assert "compliance_frameworks" in result + assert result["regions_processed"] == ["us-east-1", "us-west-2"] + assert result["requirements_created"] == 6 + assert len(result["compliance_frameworks"]) == 2 + + mock_create_objects.assert_called_once() + call_args = mock_create_objects.call_args[0] + assert call_args[0] == tenant_id + assert call_args[1] == ComplianceRequirementOverview + assert len(call_args[2]) == 6 + + compliance_objects = call_args[2] + for obj in compliance_objects: + assert isinstance(obj, ComplianceRequirementOverview) + assert obj.tenant.id == tenant.id + assert obj.scan == scan + assert obj.region in ["us-east-1", "us-west-2"] + assert obj.compliance_id in [ + "cis_1.4_aws", + "aws_account_security_onboarding_aws", + ] + + def test_create_compliance_requirements_with_findings( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch( + "tasks.jobs.scan.generate_scan_compliance" + ) as mock_generate_compliance, + patch("tasks.jobs.scan.create_objects_in_batches"), + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan.provider = provider + scan.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + mock_finding1 = MagicMock() + mock_finding1.check_id = "check1" + mock_finding1.status = "PASS" + mock_resource1 = MagicMock() + mock_resource1.region = "us-east-1" + mock_finding1.resources.all.return_value = [mock_resource1] + + mock_finding2 = MagicMock() + mock_finding2.check_id = "check2" + mock_finding2.status = "FAIL" + mock_resource2 = MagicMock() + mock_resource2.region = "us-west-2" + mock_finding2.resources.all.return_value = [mock_resource2] + + mock_findings_filter.return_value = [mock_finding1, mock_finding2] + + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = [ + "us-east-1", + "us-west-2", + ] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + mock_compliance_template.__getitem__.return_value = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": {"check_1": None}, + "checks_status": { + "pass": 2, + "fail": 1, + "manual": 0, + "total": 3, + }, + "status": "FAIL", + }, + "req_2": { + "description": "Test Requirement 2", + "checks": {"check_2": None}, + "checks_status": { + "pass": 2, + "fail": 0, + "manual": 0, + "total": 2, + }, + "status": "PASS", + }, + }, + } + } + + result = create_compliance_requirements(tenant_id, scan_id) + + mock_findings_filter.assert_called_once_with(scan_id=scan_id, muted=False) + assert mock_generate_compliance.call_count == 2 + assert result["requirements_created"] == 4 + assert set(result["regions_processed"]) == {"us-east-1", "us-west-2"} + + def test_create_compliance_requirements_no_provider_regions( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch("tasks.jobs.scan.generate_scan_compliance"), + patch("tasks.jobs.scan.create_objects_in_batches"), + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.KUBERNETES + provider.save() + scan.provider = provider + scan.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + mock_finding = MagicMock() + mock_finding.check_id = "check1" + mock_finding.status = "PASS" + mock_resource = MagicMock() + mock_resource.region = "default" + mock_finding.resources.all.return_value = [mock_resource] + mock_findings_filter.return_value = [mock_finding] + + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.side_effect = AttributeError( + "No get_regions method" + ) + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + mock_compliance_template.__getitem__.return_value = { + "kubernetes_cis": { + "framework": "CIS Kubernetes Benchmark", + "version": "1.6.0", + "requirements": { + "1.1": { + "description": "Test requirement", + "checks_status": { + "pass": 0, + "fail": 0, + "manual": 0, + "total": 1, + }, + "status": "PASS", + }, + }, + }, + } + + result = create_compliance_requirements(tenant_id, scan_id) + + assert result["regions_processed"] == ["default"] + + def test_create_compliance_requirements_empty_findings( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch( + "tasks.jobs.scan.generate_scan_compliance" + ) as mock_generate_compliance, + patch("tasks.jobs.scan.create_objects_in_batches"), + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan.provider = provider + scan.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + mock_findings_filter.return_value = [] + + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + mock_compliance_template.__getitem__.return_value = { + "cis_1.4_aws": { + "framework": "CIS AWS Foundations Benchmark", + "version": "1.4.0", + "requirements": { + "1.1": { + "description": "Test requirement", + "checks_status": { + "pass": 0, + "fail": 0, + "manual": 0, + "total": 1, + }, + "status": "PASS", + }, + }, + }, + } + + mock_findings_filter.return_value = [] + + result = create_compliance_requirements(tenant_id, scan_id) + + assert result["regions_processed"] == ["us-east-1"] + assert result["requirements_created"] == 1 + mock_generate_compliance.assert_not_called() + + def test_create_compliance_requirements_error_handling( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan.provider = provider + scan.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + mock_initialize_prowler_provider.side_effect = Exception( + "Provider initialization failed" + ) + + with pytest.raises(Exception, match="Provider initialization failed"): + create_compliance_requirements(tenant_id, scan_id) + + def test_create_compliance_requirements_muted_findings_excluded( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch("tasks.jobs.scan.generate_scan_compliance"), + patch("tasks.jobs.scan.create_objects_in_batches"), + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan.provider = provider + scan.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + mock_findings_filter.return_value = [] + + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + mock_compliance_template.__getitem__.return_value = {} + + mock_findings_filter.return_value = [] + + create_compliance_requirements(tenant_id, scan_id) + + mock_findings_filter.assert_called_once_with(scan_id=scan_id, muted=False) + + def test_create_compliance_requirements_check_status_priority( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch( + "tasks.jobs.scan.generate_scan_compliance" + ) as mock_generate_compliance, + patch("tasks.jobs.scan.create_objects_in_batches"), + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + provider = providers_fixture[0] + + provider.provider = Provider.ProviderChoices.AWS + provider.save() + scan.provider = provider + scan.save() + + tenant_id = str(tenant.id) + scan_id = str(scan.id) + + mock_finding1 = MagicMock() + mock_finding1.check_id = "check1" + mock_finding1.status = "PASS" + mock_resource1 = MagicMock() + mock_resource1.region = "us-east-1" + mock_finding1.resources.all.return_value = [mock_resource1] + + mock_finding2 = MagicMock() + mock_finding2.check_id = "check1" + mock_finding2.status = "FAIL" + mock_resource2 = MagicMock() + mock_resource2.region = "us-east-1" + mock_finding2.resources.all.return_value = [mock_resource2] + + mock_findings_filter.return_value = [mock_finding1, mock_finding2] + + mock_prowler_provider_instance = MagicMock() + mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"] + mock_initialize_prowler_provider.return_value = ( + mock_prowler_provider_instance + ) + + mock_compliance_template.__getitem__.return_value = { + "cis_1.4_aws": { + "framework": "CIS AWS Foundations Benchmark", + "version": "1.4.0", + "requirements": { + "1.1": { + "description": "Test requirement", + "checks_status": { + "pass": 0, + "fail": 0, + "manual": 0, + "total": 1, + }, + "status": "PASS", + }, + }, + }, + } + + create_compliance_requirements(tenant_id, scan_id) + + assert mock_generate_compliance.call_count == 1 + + def test_compliance_overview_aggregation_requirement_fail_priority( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch( + "tasks.jobs.scan.generate_scan_compliance" + ) as mock_generate_compliance, + patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects, + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + providers_fixture[0] + + mock_findings_filter.return_value = [] + + mock_prowler_provider = MagicMock() + mock_prowler_provider.get_regions.return_value = [ + "us-east-1", + "us-west-2", + "eu-west-1", + ] + mock_initialize_prowler_provider.return_value = mock_prowler_provider + + mock_compliance_template.__getitem__.return_value = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": {"check_1": None}, + "checks_status": { + "pass": 2, + "fail": 1, + "manual": 0, + "total": 3, + }, + "status": "FAIL", + } + }, + } + } + + mock_generate_compliance.return_value = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": { + "check_1": { + "us-east-1": {"status": "PASS"}, + "us-west-2": {"status": "FAIL"}, + "eu-west-1": {"status": "PASS"}, + } + }, + "checks_status": { + "pass": 2, + "fail": 1, + "manual": 0, + "total": 3, + }, + "status": "FAIL", + } + }, + } + } + + created_objects = [] + mock_create_objects.side_effect = ( + lambda tenant_id, model, objs, batch_size=500: created_objects.extend( + objs + ) + ) + + create_compliance_requirements(str(tenant.id), str(scan.id)) + + assert len(created_objects) == 3 + assert all(obj.requirement_status == "FAIL" for obj in created_objects) + + def test_compliance_overview_aggregation_requirement_pass_all_regions( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch( + "tasks.jobs.scan.generate_scan_compliance" + ) as mock_generate_compliance, + patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects, + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + providers_fixture[0] + + mock_findings_filter.return_value = [] + + mock_prowler_provider = MagicMock() + mock_prowler_provider.get_regions.return_value = ["us-east-1", "us-west-2"] + mock_initialize_prowler_provider.return_value = mock_prowler_provider + + mock_compliance_template.__getitem__.return_value = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": {"check_1": None}, + "checks_status": { + "pass": 2, + "fail": 0, + "manual": 0, + "total": 2, + }, + "status": "PASS", + } + }, + } + } + + mock_generate_compliance.return_value = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": { + "check_1": { + "us-east-1": {"status": "PASS"}, + "us-west-2": {"status": "PASS"}, + } + }, + "checks_status": { + "pass": 2, + "fail": 0, + "manual": 0, + "total": 2, + }, + "status": "PASS", + } + }, + } + } + + created_objects = [] + mock_create_objects.side_effect = ( + lambda tenant_id, model, objs, batch_size=500: created_objects.extend( + objs + ) + ) + + create_compliance_requirements(str(tenant.id), str(scan.id)) + + assert len(created_objects) == 2 + assert all(obj.requirement_status == "PASS" for obj in created_objects) + + def test_compliance_overview_aggregation_multiple_requirements_mixed_status( + self, + tenants_fixture, + scans_fixture, + providers_fixture, + ): + with ( + patch("api.db_utils.rls_transaction"), + patch( + "tasks.jobs.scan.initialize_prowler_provider" + ) as mock_initialize_prowler_provider, + patch( + "tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE" + ) as mock_compliance_template, + patch( + "tasks.jobs.scan.generate_scan_compliance" + ) as mock_generate_compliance, + patch("tasks.jobs.scan.create_objects_in_batches") as mock_create_objects, + patch("api.models.Finding.objects.filter") as mock_findings_filter, + ): + tenant = tenants_fixture[0] + scan = scans_fixture[0] + providers_fixture[0] + + mock_findings_filter.return_value = [] + + mock_prowler_provider = MagicMock() + mock_prowler_provider.get_regions.return_value = ["us-east-1", "us-west-2"] + mock_initialize_prowler_provider.return_value = mock_prowler_provider + + mock_compliance_template.__getitem__.return_value = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": {"check_1": None}, + "checks_status": { + "pass": 2, + "fail": 0, + "manual": 0, + "total": 2, + }, + "status": "PASS", + }, + "req_2": { + "description": "Test Requirement 2", + "checks": {"check_2": None}, + "checks_status": { + "pass": 1, + "fail": 1, + "manual": 0, + "total": 2, + }, + "status": "FAIL", + }, + }, + } + } + + mock_generate_compliance.return_value = { + "test_compliance": { + "framework": "Test Framework", + "version": "1.0", + "requirements": { + "req_1": { + "description": "Test Requirement 1", + "checks": { + "check_1": { + "us-east-1": {"status": "PASS"}, + "us-west-2": {"status": "PASS"}, + } + }, + "checks_status": { + "pass": 2, + "fail": 0, + "manual": 0, + "total": 2, + }, + "status": "PASS", + }, + "req_2": { + "description": "Test Requirement 2", + "checks": { + "check_2": { + "us-east-1": {"status": "PASS"}, + "us-west-2": {"status": "FAIL"}, + } + }, + "checks_status": { + "pass": 1, + "fail": 1, + "manual": 0, + "total": 2, + }, + "status": "FAIL", + }, + }, + } + } + + created_objects = [] + mock_create_objects.side_effect = ( + lambda tenant_id, model, objs, batch_size=500: created_objects.extend( + objs + ) + ) + + create_compliance_requirements(str(tenant.id), str(scan.id)) + + assert len(created_objects) == 4 + req_1_objects = [ + obj for obj in created_objects if obj.requirement_id == "req_1" + ] + req_2_objects = [ + obj for obj in created_objects if obj.requirement_id == "req_2" + ] + assert len(req_1_objects) == 2 + assert len(req_2_objects) == 2 + assert all(obj.requirement_status == "PASS" for obj in req_1_objects) + assert all(obj.requirement_status == "FAIL" for obj in req_2_objects) diff --git a/dashboard/common_methods.py b/dashboard/common_methods.py index 338b6e6473..a2f9ffe89b 100644 --- a/dashboard/common_methods.py +++ b/dashboard/common_methods.py @@ -2569,6 +2569,356 @@ def get_section_containers_3_levels(data, section_1, section_2, section_3): return html.Div(section_containers, className="compliance-data-layout") +def get_section_containers_threatscore(data, section_1, section_2, section_3): + data["STATUS"] = data["STATUS"].apply(map_status_to_icon) + findings_counts_marco = ( + data.groupby([section_1, "STATUS"]).size().unstack(fill_value=0) + ) + section_containers = [] + data[section_1] = data[section_1].astype(str) + data[section_2] = data[section_2].astype(str) + data[section_3] = data[section_3].astype(str) + + data.sort_values( + by=section_3, + key=lambda x: x.map(extract_numeric_values), + ascending=True, + inplace=True, + ) + + for marco in data[section_1].unique(): + success_marco = findings_counts_marco.loc[marco].get(pass_emoji, 0) + failed_marco = findings_counts_marco.loc[marco].get(fail_emoji, 0) + + fig_name = go.Figure( + [ + go.Bar( + name="Failed", + x=[failed_marco], + y=[""], + orientation="h", + marker=dict(color="#e77676"), + width=[0.8], + ), + go.Bar( + name="Success", + x=[success_marco], + y=[""], + orientation="h", + marker=dict(color="#45cc6e"), + width=[0.8], + ), + ] + ) + fig_name.update_layout( + barmode="stack", + margin=dict(l=10, r=10, t=10, b=10), + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + showlegend=False, + width=350, + height=30, + xaxis=dict(showticklabels=False, showgrid=False, zeroline=False), + yaxis=dict(showticklabels=False, showgrid=False, zeroline=False), + annotations=[ + dict( + x=success_marco + failed_marco, + y=0, + xref="x", + yref="y", + text=str(success_marco), + showarrow=False, + font=dict(color="#45cc6e", size=14), + xanchor="left", + yanchor="middle", + ), + dict( + x=0, + y=0, + xref="x", + yref="y", + text=str(failed_marco), + showarrow=False, + font=dict(color="#e77676", size=14), + xanchor="right", + yanchor="middle", + ), + ], + ) + fig_name.add_annotation( + x=failed_marco, + y=0.3, + text="|", + showarrow=False, + font=dict(size=20), + xanchor="center", + yanchor="middle", + ) + + graph_div = html.Div( + dcc.Graph( + figure=fig_name, config={"staticPlot": True}, className="info-bar" + ), + className="graph-section", + ) + direct_internal_items = [] + + for categoria in data[data[section_1] == marco][section_2].unique(): + specific_data = data[ + (data[section_1] == marco) & (data[section_2] == categoria) + ] + findings_counts_categoria = ( + specific_data.groupby([section_2, "STATUS"]) + .size() + .unstack(fill_value=0) + ) + success_categoria = findings_counts_categoria.loc[categoria].get( + pass_emoji, 0 + ) + failed_categoria = findings_counts_categoria.loc[categoria].get( + fail_emoji, 0 + ) + + fig_section = go.Figure( + [ + go.Bar( + name="Failed", + x=[failed_categoria], + y=[""], + orientation="h", + marker=dict(color="#e77676"), + width=[0.8], + ), + go.Bar( + name="Success", + x=[success_categoria], + y=[""], + orientation="h", + marker=dict(color="#45cc6e"), + width=[0.8], + ), + ] + ) + fig_section.update_layout( + barmode="stack", + margin=dict(l=10, r=10, t=10, b=10), + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + showlegend=False, + width=350, + height=30, + xaxis=dict(showticklabels=False, showgrid=False, zeroline=False), + yaxis=dict(showticklabels=False, showgrid=False, zeroline=False), + annotations=[ + dict( + x=success_categoria + failed_categoria, + y=0, + xref="x", + yref="y", + text=str(success_categoria), + showarrow=False, + font=dict(color="#45cc6e", size=14), + xanchor="left", + yanchor="middle", + ), + dict( + x=0, + y=0, + xref="x", + yref="y", + text=str(failed_categoria), + showarrow=False, + font=dict(color="#e77676", size=14), + xanchor="right", + yanchor="middle", + ), + ], + ) + fig_section.add_annotation( + x=failed_categoria, + y=0.3, + text="|", + showarrow=False, + font=dict(size=20), + xanchor="center", + yanchor="middle", + ) + + graph_div_section = html.Div( + dcc.Graph( + figure=fig_section, + config={"staticPlot": True}, + className="info-bar-child", + ), + className="graph-section-req", + ) + direct_internal_items_idgrupocontrol = [] + + for idgrupocontrol in specific_data[section_3].unique(): + specific_data2 = specific_data[ + specific_data[section_3] == idgrupocontrol + ] + findings_counts_idgrupocontrol = ( + specific_data2.groupby([section_3, "STATUS"]) + .size() + .unstack(fill_value=0) + ) + success_idgrupocontrol = findings_counts_idgrupocontrol.loc[ + idgrupocontrol + ].get(pass_emoji, 0) + failed_idgrupocontrol = findings_counts_idgrupocontrol.loc[ + idgrupocontrol + ].get(fail_emoji, 0) + + fig_idgrupocontrol = go.Figure( + [ + go.Bar( + name="Failed", + x=[failed_idgrupocontrol], + y=[""], + orientation="h", + marker=dict(color="#e77676"), + width=[0.8], + ), + go.Bar( + name="Success", + x=[success_idgrupocontrol], + y=[""], + orientation="h", + marker=dict(color="#45cc6e"), + width=[0.8], + ), + ] + ) + fig_idgrupocontrol.update_layout( + barmode="stack", + margin=dict(l=10, r=10, t=10, b=10), + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(0,0,0,0)", + showlegend=False, + width=350, + height=30, + xaxis=dict(showticklabels=False, showgrid=False, zeroline=False), + yaxis=dict(showticklabels=False, showgrid=False, zeroline=False), + annotations=[ + dict( + x=success_idgrupocontrol + failed_idgrupocontrol, + y=0, + xref="x", + yref="y", + text=str(success_idgrupocontrol), + showarrow=False, + font=dict(color="#45cc6e", size=14), + xanchor="left", + yanchor="middle", + ), + dict( + x=0, + y=0, + xref="x", + yref="y", + text=str(failed_idgrupocontrol), + showarrow=False, + font=dict(color="#e77676", size=14), + xanchor="right", + yanchor="middle", + ), + ], + ) + fig_idgrupocontrol.add_annotation( + x=failed_idgrupocontrol, + y=0.3, + text="|", + showarrow=False, + font=dict(size=20), + xanchor="center", + yanchor="middle", + ) + + graph_div_idgrupocontrol = html.Div( + dcc.Graph( + figure=fig_idgrupocontrol, + config={"staticPlot": True}, + className="info-bar-child", + ), + className="graph-section-req", + ) + + data_table = dash_table.DataTable( + data=specific_data2.to_dict("records"), + columns=[ + {"name": i, "id": i} + for i in [ + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ], + style_table={"overflowX": "auto"}, + style_as_list_view=True, + style_cell={"textAlign": "left", "padding": "5px"}, + ) + + title_internal = f"{idgrupocontrol} - {specific_data2['REQUIREMENTS_DESCRIPTION'].iloc[0]}" + + # Cut the title if it's too long + title_internal = ( + title_internal[:130] + " ..." + if len(title_internal) > 130 + else title_internal + ) + + internal_accordion_item_2 = dbc.AccordionItem( + title=title_internal, + children=[ + graph_div_idgrupocontrol, + html.Div([data_table], className="inner-accordion-content"), + ], + ) + direct_internal_items_idgrupocontrol.append( + html.Div( + [ + graph_div_idgrupocontrol, + dbc.Accordion( + [internal_accordion_item_2], + start_collapsed=True, + flush=True, + ), + ], + className="accordion-inner--child", + ) + ) + + internal_accordion_item = dbc.AccordionItem( + title=categoria, + children=direct_internal_items_idgrupocontrol, + ) + internal_section_container = html.Div( + [ + graph_div_section, + dbc.Accordion( + [internal_accordion_item], start_collapsed=True, flush=True + ), + ], + className="accordion-inner--child", + ) + direct_internal_items.append(internal_section_container) + + accordion_item = dbc.AccordionItem(title=marco, children=direct_internal_items) + section_container = html.Div( + [ + graph_div, + dbc.Accordion([accordion_item], start_collapsed=True, flush=True), + ], + className="accordion-inner", + ) + section_containers.append(section_container) + + return html.Div(section_containers, className="compliance-data-layout") + + # This function extracts and compares up to two numeric values, ensuring correct sorting for version-like strings. def extract_numeric_values(value): numbers = re.findall(r"\d+", str(value)) diff --git a/dashboard/compliance/cis_4_0_gcp.py b/dashboard/compliance/cis_4_0_gcp.py new file mode 100644 index 0000000000..94558f33ad --- /dev/null +++ b/dashboard/compliance/cis_4_0_gcp.py @@ -0,0 +1,24 @@ +import warnings + +from dashboard.common_methods import get_section_containers_cis + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ID", + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ].copy() + + return get_section_containers_cis( + aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + ) diff --git a/dashboard/compliance/nis2_aws.py b/dashboard/compliance/nis2_aws.py new file mode 100644 index 0000000000..8baac9a9a5 --- /dev/null +++ b/dashboard/compliance/nis2_aws.py @@ -0,0 +1,43 @@ +import warnings + +from dashboard.common_methods import get_section_containers_3_levels + +warnings.filterwarnings("ignore") + + +def get_table(data): + data["REQUIREMENTS_DESCRIPTION"] = ( + data["REQUIREMENTS_ID"] + " - " + data["REQUIREMENTS_DESCRIPTION"] + ) + + data["REQUIREMENTS_DESCRIPTION"] = data["REQUIREMENTS_DESCRIPTION"].apply( + lambda x: x[:150] + "..." if len(str(x)) > 150 else x + ) + + data["REQUIREMENTS_ATTRIBUTES_SECTION"] = data[ + "REQUIREMENTS_ATTRIBUTES_SECTION" + ].apply(lambda x: x[:80] + "..." if len(str(x)) > 80 else x) + + data["REQUIREMENTS_ATTRIBUTES_SUBSECTION"] = data[ + "REQUIREMENTS_ATTRIBUTES_SUBSECTION" + ].apply(lambda x: x[:150] + "..." if len(str(x)) > 150 else x) + + aux = data[ + [ + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ] + + return get_section_containers_3_levels( + aux, + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "REQUIREMENTS_DESCRIPTION", + ) diff --git a/dashboard/compliance/nis2_azure.py b/dashboard/compliance/nis2_azure.py new file mode 100644 index 0000000000..8baac9a9a5 --- /dev/null +++ b/dashboard/compliance/nis2_azure.py @@ -0,0 +1,43 @@ +import warnings + +from dashboard.common_methods import get_section_containers_3_levels + +warnings.filterwarnings("ignore") + + +def get_table(data): + data["REQUIREMENTS_DESCRIPTION"] = ( + data["REQUIREMENTS_ID"] + " - " + data["REQUIREMENTS_DESCRIPTION"] + ) + + data["REQUIREMENTS_DESCRIPTION"] = data["REQUIREMENTS_DESCRIPTION"].apply( + lambda x: x[:150] + "..." if len(str(x)) > 150 else x + ) + + data["REQUIREMENTS_ATTRIBUTES_SECTION"] = data[ + "REQUIREMENTS_ATTRIBUTES_SECTION" + ].apply(lambda x: x[:80] + "..." if len(str(x)) > 80 else x) + + data["REQUIREMENTS_ATTRIBUTES_SUBSECTION"] = data[ + "REQUIREMENTS_ATTRIBUTES_SUBSECTION" + ].apply(lambda x: x[:150] + "..." if len(str(x)) > 150 else x) + + aux = data[ + [ + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ] + + return get_section_containers_3_levels( + aux, + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "REQUIREMENTS_DESCRIPTION", + ) diff --git a/dashboard/compliance/nis2_gcp.py b/dashboard/compliance/nis2_gcp.py new file mode 100644 index 0000000000..8baac9a9a5 --- /dev/null +++ b/dashboard/compliance/nis2_gcp.py @@ -0,0 +1,43 @@ +import warnings + +from dashboard.common_methods import get_section_containers_3_levels + +warnings.filterwarnings("ignore") + + +def get_table(data): + data["REQUIREMENTS_DESCRIPTION"] = ( + data["REQUIREMENTS_ID"] + " - " + data["REQUIREMENTS_DESCRIPTION"] + ) + + data["REQUIREMENTS_DESCRIPTION"] = data["REQUIREMENTS_DESCRIPTION"].apply( + lambda x: x[:150] + "..." if len(str(x)) > 150 else x + ) + + data["REQUIREMENTS_ATTRIBUTES_SECTION"] = data[ + "REQUIREMENTS_ATTRIBUTES_SECTION" + ].apply(lambda x: x[:80] + "..." if len(str(x)) > 80 else x) + + data["REQUIREMENTS_ATTRIBUTES_SUBSECTION"] = data[ + "REQUIREMENTS_ATTRIBUTES_SUBSECTION" + ].apply(lambda x: x[:150] + "..." if len(str(x)) > 150 else x) + + aux = data[ + [ + "REQUIREMENTS_DESCRIPTION", + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ] + + return get_section_containers_3_levels( + aux, + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "REQUIREMENTS_DESCRIPTION", + ) diff --git a/dashboard/compliance/prowler_threatscore_aws.py b/dashboard/compliance/prowler_threatscore_aws.py index 94558f33ad..d86a13fd01 100644 --- a/dashboard/compliance/prowler_threatscore_aws.py +++ b/dashboard/compliance/prowler_threatscore_aws.py @@ -1,6 +1,6 @@ import warnings -from dashboard.common_methods import get_section_containers_cis +from dashboard.common_methods import get_section_containers_threatscore warnings.filterwarnings("ignore") @@ -11,6 +11,7 @@ def get_table(data): "REQUIREMENTS_ID", "REQUIREMENTS_DESCRIPTION", "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", "CHECKID", "STATUS", "REGION", @@ -19,6 +20,9 @@ def get_table(data): ] ].copy() - return get_section_containers_cis( - aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + return get_section_containers_threatscore( + aux, + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "REQUIREMENTS_ID", ) diff --git a/dashboard/compliance/prowler_threatscore_azure.py b/dashboard/compliance/prowler_threatscore_azure.py index 94558f33ad..d86a13fd01 100644 --- a/dashboard/compliance/prowler_threatscore_azure.py +++ b/dashboard/compliance/prowler_threatscore_azure.py @@ -1,6 +1,6 @@ import warnings -from dashboard.common_methods import get_section_containers_cis +from dashboard.common_methods import get_section_containers_threatscore warnings.filterwarnings("ignore") @@ -11,6 +11,7 @@ def get_table(data): "REQUIREMENTS_ID", "REQUIREMENTS_DESCRIPTION", "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", "CHECKID", "STATUS", "REGION", @@ -19,6 +20,9 @@ def get_table(data): ] ].copy() - return get_section_containers_cis( - aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + return get_section_containers_threatscore( + aux, + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "REQUIREMENTS_ID", ) diff --git a/dashboard/compliance/prowler_threatscore_gcp.py b/dashboard/compliance/prowler_threatscore_gcp.py index 94558f33ad..d86a13fd01 100644 --- a/dashboard/compliance/prowler_threatscore_gcp.py +++ b/dashboard/compliance/prowler_threatscore_gcp.py @@ -1,6 +1,6 @@ import warnings -from dashboard.common_methods import get_section_containers_cis +from dashboard.common_methods import get_section_containers_threatscore warnings.filterwarnings("ignore") @@ -11,6 +11,7 @@ def get_table(data): "REQUIREMENTS_ID", "REQUIREMENTS_DESCRIPTION", "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", "CHECKID", "STATUS", "REGION", @@ -19,6 +20,9 @@ def get_table(data): ] ].copy() - return get_section_containers_cis( - aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + return get_section_containers_threatscore( + aux, + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "REQUIREMENTS_ID", ) diff --git a/dashboard/compliance/prowler_threatscore_m365.py b/dashboard/compliance/prowler_threatscore_m365.py index 94558f33ad..d86a13fd01 100644 --- a/dashboard/compliance/prowler_threatscore_m365.py +++ b/dashboard/compliance/prowler_threatscore_m365.py @@ -1,6 +1,6 @@ import warnings -from dashboard.common_methods import get_section_containers_cis +from dashboard.common_methods import get_section_containers_threatscore warnings.filterwarnings("ignore") @@ -11,6 +11,7 @@ def get_table(data): "REQUIREMENTS_ID", "REQUIREMENTS_DESCRIPTION", "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", "CHECKID", "STATUS", "REGION", @@ -19,6 +20,9 @@ def get_table(data): ] ].copy() - return get_section_containers_cis( - aux, "REQUIREMENTS_ID", "REQUIREMENTS_ATTRIBUTES_SECTION" + return get_section_containers_threatscore( + aux, + "REQUIREMENTS_ATTRIBUTES_SECTION", + "REQUIREMENTS_ATTRIBUTES_SUBSECTION", + "REQUIREMENTS_ID", ) diff --git a/dashboard/lib/layouts.py b/dashboard/lib/layouts.py index f1b4408c38..144036f183 100644 --- a/dashboard/lib/layouts.py +++ b/dashboard/lib/layouts.py @@ -90,12 +90,28 @@ def create_layout_overview( ), html.Div( [ - ( - html.Label( - "Table Rows:", - className="text-prowler-stone-900 font-bold text-sm", - style={"margin-right": "10px"}, - ) + html.Label( + "Search:", + className="text-prowler-stone-900 font-bold text-sm", + style={"margin-right": "10px"}, + ), + dcc.Input( + id="search-input", + type="text", + placeholder="Search by check title, service, region...", + debounce=True, + style={ + "padding": "4px 8px", + "border": "1px solid #ccc", + "borderRadius": "4px", + "marginRight": "20px", + "width": "250px", + }, + ), + html.Label( + "Table Rows:", + className="text-prowler-stone-900 font-bold text-sm", + style={"margin-right": "10px"}, ), table_row_dropdown, download_button_csv, diff --git a/dashboard/pages/compliance.py b/dashboard/pages/compliance.py index 0eed0ace3f..93c0eeb2c1 100644 --- a/dashboard/pages/compliance.py +++ b/dashboard/pages/compliance.py @@ -651,58 +651,114 @@ def get_table(current_compliance, table): def get_threatscore_mean_by_pillar(df): - modified_df = df[df["STATUS"] == "FAIL"] + score_per_pillar = {} + max_score_per_pillar = {} - modified_df["REQUIREMENTS_ATTRIBUTES_LEVELOFRISK"] = pd.to_numeric( - modified_df["REQUIREMENTS_ATTRIBUTES_LEVELOFRISK"], errors="coerce" - ) + for _, row in df.iterrows(): + pillar = ( + row["REQUIREMENTS_ATTRIBUTES_SECTION"].split(" - ")[0] + if isinstance(row["REQUIREMENTS_ATTRIBUTES_SECTION"], str) + else "Unknown" + ) - pillar_means = ( - modified_df.groupby("REQUIREMENTS_ATTRIBUTES_SECTION")[ - "REQUIREMENTS_ATTRIBUTES_LEVELOFRISK" - ] - .mean() - .round(2) - ) + if pillar not in score_per_pillar: + score_per_pillar[pillar] = 0 + max_score_per_pillar[pillar] = 0 + + level_of_risk = pd.to_numeric( + row["REQUIREMENTS_ATTRIBUTES_LEVELOFRISK"], errors="coerce" + ) + level_of_risk = 1 if pd.isna(level_of_risk) else level_of_risk + + weight = 1 + if "REQUIREMENTS_ATTRIBUTES_WEIGHT" in row and not pd.isna( + row["REQUIREMENTS_ATTRIBUTES_WEIGHT"] + ): + weight = pd.to_numeric( + row["REQUIREMENTS_ATTRIBUTES_WEIGHT"], errors="coerce" + ) + weight = 1 if pd.isna(weight) else weight + + max_score_per_pillar[pillar] += level_of_risk * weight + + if row["STATUS"] == "PASS": + score_per_pillar[pillar] += level_of_risk * weight output = [] - for pillar, mean in pillar_means.items(): - output.append(f"{pillar} - [{mean}]") + for pillar in max_score_per_pillar: + risk_score = 0 + if max_score_per_pillar[pillar] > 0: + risk_score = (score_per_pillar[pillar] / max_score_per_pillar[pillar]) * 100 + + output.append(f"{pillar} - [{risk_score:.1f}%]") for value in output: - if value.split(" - ")[0] in df["REQUIREMENTS_ATTRIBUTES_SECTION"].values: + base_pillar = value.split(" - ")[0] + if base_pillar in df["REQUIREMENTS_ATTRIBUTES_SECTION"].values: df.loc[ - df["REQUIREMENTS_ATTRIBUTES_SECTION"] == value.split(" - ")[0], + df["REQUIREMENTS_ATTRIBUTES_SECTION"] == base_pillar, "REQUIREMENTS_ATTRIBUTES_SECTION", ] = value + return df def get_table_prowler_threatscore(df): - df = df[df["STATUS"] == "FAIL"] + score_per_pillar = {} + max_score_per_pillar = {} + pillars = {} - # Delete " - " from the column REQUIREMENTS_ATTRIBUTES_SECTION - df["REQUIREMENTS_ATTRIBUTES_SECTION"] = ( - df["REQUIREMENTS_ATTRIBUTES_SECTION"].str.split(" - ").str[0] - ) + df_copy = df.copy() - df["REQUIREMENTS_ATTRIBUTES_LEVELOFRISK"] = pd.to_numeric( - df["REQUIREMENTS_ATTRIBUTES_LEVELOFRISK"], errors="coerce" - ) - - score_df = ( - df.groupby("REQUIREMENTS_ATTRIBUTES_SECTION")[ - "REQUIREMENTS_ATTRIBUTES_LEVELOFRISK" - ] - .mean() - .reset_index() - .rename( - columns={ - "REQUIREMENTS_ATTRIBUTES_SECTION": "Pillar", - "REQUIREMENTS_ATTRIBUTES_LEVELOFRISK": "Score", - } + for _, row in df_copy.iterrows(): + pillar = ( + row["REQUIREMENTS_ATTRIBUTES_SECTION"].split(" - ")[0] + if isinstance(row["REQUIREMENTS_ATTRIBUTES_SECTION"], str) + else "Unknown" ) - ) + + if pillar not in pillars: + pillars[pillar] = {"FAIL": 0, "PASS": 0, "MUTED": 0} + score_per_pillar[pillar] = 0 + max_score_per_pillar[pillar] = 0 + + level_of_risk = pd.to_numeric( + row["REQUIREMENTS_ATTRIBUTES_LEVELOFRISK"], errors="coerce" + ) + level_of_risk = 1 if pd.isna(level_of_risk) else level_of_risk + + weight = 1 + if "REQUIREMENTS_ATTRIBUTES_WEIGHT" in row and not pd.isna( + row["REQUIREMENTS_ATTRIBUTES_WEIGHT"] + ): + weight = pd.to_numeric( + row["REQUIREMENTS_ATTRIBUTES_WEIGHT"], errors="coerce" + ) + weight = 1 if pd.isna(weight) else weight + + max_score_per_pillar[pillar] += level_of_risk * weight + + if row["STATUS"] == "PASS": + pillars[pillar]["PASS"] += 1 + score_per_pillar[pillar] += level_of_risk * weight + elif row["STATUS"] == "FAIL": + pillars[pillar]["FAIL"] += 1 + + if "MUTED" in row and row["MUTED"] == "True": + pillars[pillar]["MUTED"] += 1 + + result_df = [] + + for pillar in pillars.keys(): + risk_score = 0 + if max_score_per_pillar[pillar] > 0: + risk_score = (score_per_pillar[pillar] / max_score_per_pillar[pillar]) * 100 + + result_df.append({"Pillar": pillar, "Score": risk_score}) + + score_df = pd.DataFrame(result_df) + + score_df = score_df.sort_values("Score", ascending=True) fig = px.bar( score_df, @@ -710,22 +766,25 @@ def get_table_prowler_threatscore(df): y="Score", color="Score", color_continuous_scale=[ - "#45cc6e", - "#f4d44d", "#e77676", - ], # verde → amarillo → rojo - hover_data={"Score": True, "Pillar": True}, - labels={"Score": "Average Risk Score", "Pillar": "Section"}, + "#f4d44d", + "#45cc6e", + ], + labels={"Score": "Risk Score (%)", "Pillar": "Section"}, height=400, + text="Score", ) + fig.update_traces(texttemplate="%{text:.1f}%", textposition="outside") + fig.update_layout( xaxis_title="Pillar", - yaxis_title="Level of Risk", + yaxis_title="Risk Score (%)", margin=dict(l=20, r=20, t=30, b=20), plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", - coloraxis_colorbar=dict(title="Risk"), + coloraxis_colorbar=dict(title="Risk %"), + yaxis=dict(range=[0, 110]), ) return dcc.Graph( diff --git a/dashboard/pages/overview.py b/dashboard/pages/overview.py index 2688281af2..85f07b816e 100644 --- a/dashboard/pages/overview.py +++ b/dashboard/pages/overview.py @@ -83,7 +83,18 @@ def load_csv_files(csv_files): """Load CSV files into a single pandas DataFrame.""" dfs = [] for file in csv_files: - df = pd.read_csv(file, sep=";", on_bad_lines="skip") + account_columns = ["ACCOUNT_ID", "ACCOUNT_UID", "SUBSCRIPTION"] + + df_sample = pd.read_csv(file, sep=";", on_bad_lines="skip", nrows=1) + + dtype_dict = {} + for col in account_columns: + if col in df_sample.columns: + dtype_dict[col] = str + + # Read the full file with proper dtypes + df = pd.read_csv(file, sep=";", on_bad_lines="skip", dtype=dtype_dict) + if "CHECK_ID" in df.columns: if "TIMESTAMP" in df.columns or df["PROVIDER"].unique() == "aws": dfs.append(df.astype(str)) @@ -120,7 +131,6 @@ if data is None: ] ) else: - # This handles the case where we are using v3 outputs if "ASSESSMENT_START_TIME" in data.columns: data["ASSESSMENT_START_TIME"] = data["ASSESSMENT_START_TIME"].str.replace( @@ -518,6 +528,7 @@ else: Input("service-filter", "value"), Input("table-rows", "value"), Input("status-filter", "value"), + Input("search-input", "value"), Input("aws_card", "n_clicks"), Input("azure_card", "n_clicks"), Input("gcp_card", "n_clicks"), @@ -540,6 +551,7 @@ def filter_data( service_values, table_row_values, status_values, + search_value, aws_clicks, azure_clicks, gcp_clicks, @@ -1144,6 +1156,15 @@ def filter_data( } index_count = 0 + if search_value: + search_value = search_value.lower() + filtered_data = filtered_data[ + filtered_data["CHECK_TITLE"].str.lower().str.contains(search_value) + | filtered_data["SERVICE_NAME"].str.lower().str.contains(search_value) + | filtered_data["REGION"].str.lower().str.contains(search_value) + | filtered_data["STATUS"].str.lower().str.contains(search_value) + ] + full_filtered_data = filtered_data.copy() filtered_data = filtered_data.head(table_row_values) # Sort the filtered_data diff --git a/docs/getting-started/requirements.md b/docs/getting-started/requirements.md index 29b9069013..efe66d8999 100644 --- a/docs/getting-started/requirements.md +++ b/docs/getting-started/requirements.md @@ -70,7 +70,7 @@ The other three cases does not need additional configuration, `--az-cli-auth` an Prowler for Azure needs two types of permission scopes to be set: - **Microsoft Entra ID permissions**: used to retrieve metadata from the identity assumed by Prowler and specific Entra checks (not mandatory to have access to execute the tool). The permissions required by the tool are the following: - - `Directory.Read.All` + - `Domain.Read.All` - `Policy.Read.All` - `UserAuthenticationMethod.Read.All` (used only for the Entra checks related with multifactor authentication) - **Subscription scope permissions**: required to launch the checks against your resources, mandatory to launch the tool. It is required to add the following RBAC builtin roles per subscription to the entity that is going to be assumed by the tool: @@ -81,6 +81,9 @@ Prowler for Azure needs two types of permission scopes to be set: To assign the permissions, follow the instructions in the [Microsoft Entra ID permissions](../tutorials/azure/create-prowler-service-principal.md#assigning-the-proper-permissions) section and the [Azure subscriptions permissions](../tutorials/azure/subscriptions.md#assign-the-appropriate-permissions-to-the-identity-that-is-going-to-be-assumed-by-prowler) section, respectively. +???+ warning + Some permissions in `ProwlerRole` are considered **write** permissions, so if you have a `ReadOnly` lock attached to some resources you may get an error and will not get a finding for that check. + #### Checks that require ProwlerRole The following checks require the `ProwlerRole` permissions to be executed, if you want to run them, make sure you have assigned the role to the identity that is going to be assumed by Prowler: @@ -153,77 +156,34 @@ With this credentials you will only be able to run the checks that work through Authentication flag: `--env-auth` -This authentication method follows the same approach as the service principal method but introduces two additional environment variables for user credentials: `M365_USER` and `M365_ENCRYPTED_PASSWORD`. +This authentication method follows the same approach as the service principal method but introduces two additional environment variables for user credentials: `M365_USER` and `M365_PASSWORD`. ```console export AZURE_CLIENT_ID="XXXXXXXXX" export AZURE_CLIENT_SECRET="XXXXXXXXX" export AZURE_TENANT_ID="XXXXXXXXX" export M365_USER="your_email@example.com" -export M365_ENCRYPTED_PASSWORD="6500780061006d0070006c006500700061007300730077006f0072006400" # replace this to yours +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. -- `M365_USER` should be your Microsoft account email using the default domain. This means it must look like `example@YourCompany.onmicrosoft.com`. +- `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. - To ensure that you are using the default domain you can see how to verify it [here](../tutorials/microsoft365/getting-started-m365.md#step-1-obtain-your-domain). + ???+ warning + The user must not be MFA capable. Microsoft does not allow MFA capable users to authenticate programmatically. See [Microsoft documentation](https://learn.microsoft.com/en-us/entra/identity-platform/scenario-desktop-acquire-token-username-password?tabs=dotnet) for more information. - If you don't have a user created with that domain, Prowler will not work as it will not be able to ensure both app an user belong to the same tenant. To proceed, you can either create a new user with that domain or modify the domain of an existing user. + ???+ warning + Using a tenant domain other than the one assigned — even if it belongs to the same tenant — will cause Prowler to fail, as Microsoft authentication will not succeed. + + Ensure you are using the right domain for the user you are trying to authenticate with. ![User Domains](../tutorials/microsoft365/img/user-domains.png) -- `M365_ENCRYPTED_PASSWORD` must be an encrypted SecureString. To convert your password into a valid encrypted string, you need to use PowerShell. - - ???+ warning - Passwords encrypted using ConvertTo-SecureString can only be decrypted on the same OS/user context. If you generate an encrypted password on macOS or Linux (both UNIX), it should fail on Windows and vice versa. As Prowler Cloud runs on UNIX if you generate your password using Windows it won't work so you'll need to generate a new password using any UNIX distro (example above) - - If you are working from Windows and you will use your encrypted password in a different system (like for example executing Prowler in macOS or adding your password to Prowler Cloud), you will need to generate a "UNIX compatible" version of your encrypted password. This can be done using WSL which is so easy to install on Windows. - - === "UNIX" - - Open a PowerShell cmd with a [supported version](requirements.md#supported-powershell-versions) and then run the following command: - - ```console - $securePassword = ConvertTo-SecureString "examplepassword" -AsPlainText -Force - $encryptedPassword = $securePassword | ConvertFrom-SecureString - Write-Output $encryptedPassword - 6500780061006d0070006c006500700061007300730077006f0072006400 - ``` - - If everything is done correctly, you will see the encrypted string that you need to set as the `M365_ENCRYPTED_PASSWORD` environment variable. - - === "Windows" - - - How to install WSL and PowerShell on it to generate that password (you can use a different distro but this one will work for sure): - - ```console - wsl --install -d Ubuntu-22.04 - ``` - - Then, open the Ubuntu terminal and run the following commands: - - ```console - sudo apt update && sudo apt install -y wget apt-transport-https software-properties-common - wget -q "https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/packages-microsoft-prod.deb" - sudo dpkg -i packages-microsoft-prod.deb - sudo apt update - sudo apt install -y powershell - pwsh - ``` - - With this done you will see now that a prompt running PowerShell with the latest version is open so here you will be able to generate your encrypted password: - - ```console - $securePassword = ConvertTo-SecureString "examplepassword" -AsPlainText -Force - $encryptedPassword = $securePassword | ConvertFrom-SecureString - Write-Output $encryptedPassword - 6500780061006d0070006c006500700061007300730077006f0072006400 - ``` - - If everything is done correctly, you will see the encrypted string that you need to set as the `M365_ENCRYPTED_PASSWORD` environment variable. +- `M365_PASSWORD` must be the user password. + ???+ note + Before we asked for a encrypted password, but now we ask for the user password directly. Prowler will now handle the password encryption for you. ### Interactive Browser authentication @@ -242,12 +202,13 @@ Since this is a delegated permission authentication method, necessary permission 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: - **Service Principal Application Permissions**: These are set at the **application** level and are used to retrieve data from the identity being assessed: - - `Directory.Read.All`: Required for all services. - - `Policy.Read.All`: Required for all services. - - `User.Read` (IMPORTANT: this must be set as **delegated**): Required for the sign-in. - - `Sites.Read.All`: Required for SharePoint service. - - `SharePointTenantSettings.Read.All`: Required for SharePoint service. - `AuditLog.Read.All`: Required for Entra service. + - `Domain.Read.All`: Required for all services. + - `Organization.Read.All`: Required for retrieving tenant information. + - `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. + - **Powershell Modules 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. diff --git a/docs/img/AAD-permissions.png b/docs/img/AAD-permissions.png index f530293bfe..f2f37e354d 100644 Binary files a/docs/img/AAD-permissions.png and b/docs/img/AAD-permissions.png differ diff --git a/docs/img/m365-credentials.png b/docs/img/m365-credentials.png index 9c06343ce2..87191751a8 100644 Binary files a/docs/img/m365-credentials.png and b/docs/img/m365-credentials.png differ diff --git a/docs/tutorials/azure/create-prowler-service-principal.md b/docs/tutorials/azure/create-prowler-service-principal.md index 02ab7ea629..25c170df97 100644 --- a/docs/tutorials/azure/create-prowler-service-principal.md +++ b/docs/tutorials/azure/create-prowler-service-principal.md @@ -40,7 +40,7 @@ az ad sp create-for-rbac --name "ProwlerApp" To allow Prowler to retrieve metadata from the identity assumed and run specific Entra checks, it is needed to assign the following permissions: -- `Directory.Read.All` +- `Domain.Read.All` - `Policy.Read.All` - `UserAuthenticationMethod.Read.All` (used only for the Entra checks related with multifactor authentication) @@ -58,7 +58,7 @@ To assign the permissions you can make it from the Azure Portal or using the Azu 5. Then click on "+ Add a permission" and select "Microsoft Graph" 6. Once in the "Microsoft Graph" view, select "Application permissions" 7. Finally, search for "Directory", "Policy" and "UserAuthenticationMethod" select the following permissions: - - `Directory.Read.All` + - `Domain.Read.All` - `Policy.Read.All` - `UserAuthenticationMethod.Read.All` 8. Click on "Add permissions" to apply the new permissions. diff --git a/docs/tutorials/azure/getting-started-azure.md b/docs/tutorials/azure/getting-started-azure.md index b9331703e8..3fabdcc3a5 100644 --- a/docs/tutorials/azure/getting-started-azure.md +++ b/docs/tutorials/azure/getting-started-azure.md @@ -90,7 +90,7 @@ A Service Principal is required to grant Prowler the necessary privileges. Assign the following Microsoft Graph permissions: - - Directory.Read.All + - Domain.Read.All - Policy.Read.All @@ -107,11 +107,11 @@ Assign the following Microsoft Graph permissions: 3. Search and select: - - `Directory.Read.All` + - `Domain.Read.All` - `Policy.Read.All` - `UserAuthenticationMethod.Read.All` - ![Permission Screenshots](./img/directory-permission.png) + ![Permission Screenshots](./img/domain-permission.png) 4. Click `Add permissions`, then grant admin consent diff --git a/docs/tutorials/azure/img/directory-permission.png b/docs/tutorials/azure/img/directory-permission.png deleted file mode 100644 index 34dc81abeb..0000000000 Binary files a/docs/tutorials/azure/img/directory-permission.png and /dev/null differ diff --git a/docs/tutorials/azure/img/domain-permission.png b/docs/tutorials/azure/img/domain-permission.png new file mode 100644 index 0000000000..467ec8ff36 Binary files /dev/null and b/docs/tutorials/azure/img/domain-permission.png differ diff --git a/docs/tutorials/configuration_file.md b/docs/tutorials/configuration_file.md index 78b9865183..dc1e66c96c 100644 --- a/docs/tutorials/configuration_file.md +++ b/docs/tutorials/configuration_file.md @@ -109,6 +109,15 @@ The following list includes all the Microsoft 365 checks with configurable varia | `exchange_organization_mailtips_enabled` | `recommended_mailtips_large_audience_threshold` | Integer | +## GitHub + +### Configurable Checks +The following list includes all the GitHub checks with configurable variables that can be changed in the configuration yaml file: + +| Check Name | Value | Type | +|--------------------------------------------|---------------------------------------------|---------| +| `repository_inactive_not_archived` | `inactive_not_archived_days_threshold` | Integer | + ## Config YAML File Structure ???+ note @@ -525,5 +534,10 @@ m365: # m365.exchange_organization_mailtips_enabled recommended_mailtips_large_audience_threshold: 25 # maximum number of recipients +# GitHub Configuration +github: + # github.repository_inactive_not_archived + inactive_not_archived_days_threshold: 180 + ``` diff --git a/docs/tutorials/gcp/getting-started-gcp.md b/docs/tutorials/gcp/getting-started-gcp.md index 9047b5e0ef..ea34f293e8 100644 --- a/docs/tutorials/gcp/getting-started-gcp.md +++ b/docs/tutorials/gcp/getting-started-gcp.md @@ -1,7 +1,5 @@ # Getting Started with GCP on Prowler Cloud/App - - Set up your GCP project to enable security scanning using Prowler Cloud/App. ## Requirements diff --git a/docs/tutorials/microsoft365/getting-started-m365.md b/docs/tutorials/microsoft365/getting-started-m365.md index 067b31a1dd..f45fde5c2c 100644 --- a/docs/tutorials/microsoft365/getting-started-m365.md +++ b/docs/tutorials/microsoft365/getting-started-m365.md @@ -4,9 +4,9 @@ Set up your M365 account to enable security scanning using Prowler Cloud/App. ## Requirements -To configure your M365 account, you’ll need: +To configure your M365 account, you'll need: -1. Obtain your `Default Domain` from the Entra ID portal. +1. Obtain a domain from the Entra ID portal. 2. Access Prowler Cloud/App and add a new cloud provider `Microsoft 365`. @@ -18,8 +18,6 @@ To configure your M365 account, you’ll need: 3.3 Assign the required roles to your user. - 3.4 Retrieve your encrypted password. - 4. Add the credentials to Prowler Cloud/App. ## Step 1: Obtain your Domain @@ -32,9 +30,7 @@ Go to the Entra ID portal, then you can search for `Domain` or go to Identity > ![Custom Domain Names](./img/custom-domain-names.png) -Once you are there just look for the `Default Domain` this should be something similar to `YourCompany.onmicrosoft.com`. To ensure that you are picking the correct domain just click on it and verify that the type is `Initial` and you can't delete it. - -![Search Default Domain](./img/search-default-domain.png) +Once you are there just select the domain you want to use. --- @@ -78,11 +74,11 @@ A Service Principal is required to grant Prowler the necessary privileges. ![New Registration](./img/new-registration.png) -4. Go to `Certificates & secrets` > `+ New client secret` +4. Go to `Certificates & secrets` > `Client secrets` > `+ New client secret` ![Certificate & Secrets nav](./img/certificates-and-secrets.png) -5. Fill in the required fields and click `Add`, then copy the generated value (that value will be `AZURE_CLIENT_SECRET`) +5. Fill in the required fields and click `Add`, then copy the generated `value` (that value will be `AZURE_CLIENT_SECRET`) ![New Client Secret](./img/new-client-secret.png) @@ -99,12 +95,11 @@ With this done you will have all the needed keys, summarized in the following ta ### Grant required API permissions Assign the following Microsoft Graph permissions: - -- `Directory.Read.All`: Required for all services. +- `AuditLog.Read.All`: Required for Entra service. +- `Domain.Read.All`: Required for all services. - `Policy.Read.All`: Required for all services. -- `User.Read` (IMPORTANT: this is set as **delegated**): Required for the sign-in. -- `Sites.Read.All`: Required for SharePoint service. - `SharePointTenantSettings.Read.All`: Required for SharePoint service. +- `User.Read` (IMPORTANT: this is set as **delegated**): Required for the sign-in. Follow these steps to assign the permissions: @@ -117,31 +112,35 @@ Follow these steps to assign the permissions: ![Add API Permission](./img/add-app-api-permission.png) 3. Search and select every permission below and once all are selected click on `Add permissions`: - - - `Directory.Read.All` + - `AuditLog.Read.All`: Required for Entra service. + - `Domain.Read.All` + - `Organization.Read.All` - `Policy.Read.All` - - `Sites.Read.All` - `SharePointTenantSettings.Read.All` + ![Permission Screenshots](./img/directory-permission.png) -4. Click `Add permissions`, then grant admin consent + ![Application Permissions](./img/app-permissions.png) - ![Grant Admin Consent](./img/grant-admin-consent.png) -5. Click `+ Add a permission` > `Microsoft Graph` > `Delegated permissions` +4. Click `+ Add a permission` > `Microsoft Graph` > `Delegated permissions` ![Add API Permission](./img/add-delegated-api-permission.png) -6. Search and select: +5. Search and select: - `User.Read` ![Permission Screenshots](./img/directory-permission-delegated.png) -7. Click `Add permissions`, then grant admin consent +6. Click `Add permissions`, then **grant admin consent** - ![Grant Admin Consent](./img/grant-admin-consent-delegated.png) + ![Grant Admin Consent](./img/grant-admin-consent.png) + + The final result of permission assignment should be this: + + ![Final Permission Assignment](./img/final-permissions-m365.png) --- @@ -174,25 +173,20 @@ Follow these steps to assign the role: --- -### Get your encrypted password - -For this step you will need to use PowerShell, here you will have to create your Encrypted Password based on the password of the User that you are going to use. For more information about how to generate this Password go [here](../../getting-started/requirements.md#service-principal-and-user-credentials-authentication-recommended) and follow the steps needed to obtain `M365_ENCRYPTED_PASSWORD`. - ---- - ## Step 4: Add credentials to Prowler Cloud/App 1. Go to your App Registration overview and copy the `Client ID` and `Tenant ID` ![App Overview](./img/app-overview.png) + 2. Go to Prowler Cloud/App and paste: - `Client ID` - `Tenant ID` - `AZURE_CLIENT_SECRET` from earlier - - `M365_USER` your user using the default domain, more info [here](../../getting-started/requirements.md#service-principal-and-user-credentials-authentication-recommended) - - `M365_ENCRYPTED_PASSWORD` generated before + - `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 ![Prowler Cloud M365 Credentials](./img/m365-credentials.png) diff --git a/docs/tutorials/microsoft365/img/app-permissions.png b/docs/tutorials/microsoft365/img/app-permissions.png new file mode 100644 index 0000000000..386447d31f Binary files /dev/null and b/docs/tutorials/microsoft365/img/app-permissions.png differ diff --git a/docs/tutorials/microsoft365/img/final-permissions-m365.png b/docs/tutorials/microsoft365/img/final-permissions-m365.png new file mode 100644 index 0000000000..4d094ee082 Binary files /dev/null and b/docs/tutorials/microsoft365/img/final-permissions-m365.png differ diff --git a/docs/tutorials/microsoft365/img/grant-admin-consent-delegated.png b/docs/tutorials/microsoft365/img/grant-admin-consent-delegated.png index aa2e96ce70..d87903e271 100644 Binary files a/docs/tutorials/microsoft365/img/grant-admin-consent-delegated.png and b/docs/tutorials/microsoft365/img/grant-admin-consent-delegated.png differ diff --git a/docs/tutorials/microsoft365/img/grant-admin-consent.png b/docs/tutorials/microsoft365/img/grant-admin-consent.png index 2258d31b8e..b5953c6771 100644 Binary files a/docs/tutorials/microsoft365/img/grant-admin-consent.png and b/docs/tutorials/microsoft365/img/grant-admin-consent.png differ diff --git a/docs/tutorials/microsoft365/img/m365-credentials.png b/docs/tutorials/microsoft365/img/m365-credentials.png index 9c06343ce2..87191751a8 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-rbac.md b/docs/tutorials/prowler-app-rbac.md index 5532cda094..db9339cadf 100644 --- a/docs/tutorials/prowler-app-rbac.md +++ b/docs/tutorials/prowler-app-rbac.md @@ -4,6 +4,9 @@ The **Prowler App** supports multiple users within a single tenant, enabling sea [Roles](#roles) help you control user permissions, determining what actions each user can perform and the data they can access within Prowler. By default, each account includes an immutable **admin** role, ensuring that your account always retains administrative access. +???+ note + If the account is created without an invitation, a new tenant will be provisioned for it. However, if the account is created through an invitation, the user will join the inviter’s tenant. + ## Membership To get to User-Invitation Management we will focus on the Membership section. @@ -156,6 +159,9 @@ Follow these steps to create a role for your account: Role parameters +???+ note + To assign read-only access, select only the `Unlimited Visibility` permission when creating the role. Then, go to the Users page and assign this role to the appropriate user. + #### Editing a Role Follow these steps to edit a role on your account: diff --git a/docs/tutorials/prowler-app-sso.md b/docs/tutorials/prowler-app-sso.md new file mode 100644 index 0000000000..a82971c8a9 --- /dev/null +++ b/docs/tutorials/prowler-app-sso.md @@ -0,0 +1,186 @@ +# 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). + +--- + +## Environment Configuration + +### `DJANGO_ALLOWED_HOSTS` + +Update this variable to specify which domains Django should accept incoming requests from. This typically includes: + +- `localhost` for local development +- container hostnames (e.g. `prowler-api`) +- public-facing domains or tunnels (e.g. ngrok) + +**Example**: + +```env +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. + +- GET /api/v1/saml-config: Retrieve the current configuration + +- POST /api/v1/saml-config: Create a new configuration + +- PATCH /api/v1/saml-config: Update the existing configuration + +- DELETE /api/v1/saml-config: Remove the current configuration + + +???+ note "API Note" + SSO with SAML API documentation.[Prowler API Reference - Upload SAML configuration](https://api.prowler.com/api/v1/docs#tag/SAML/operation/saml_config_create) + +# SAML Initiate + +### 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. + +- POST /api/v1/accounts/saml/initiate/ + +???+ note + Important: This endpoint is intended to be used from a browser, as it returns a 302 redirect that needs to be followed to continue the SAML authentication flow. For testing purposes, it is better to use a browser or a tool that follows redirects (such as Postman) rather than relying on unit tests that cannot capture the redirect behavior. + +### Expected payload +``` +{ + "email_domain": "user@domain.com" +} +``` + +### Possible responses + + • 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. + +### Validation logic + + • Looks up the domain in SAMLDomainIndex. + + • 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 + +This document outlines the process for testing the SAML integration functionality. + +--- + +## 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 + +Start ngrok on port 8080: +``` +ngrok http 8080 +``` + +Then, copy the generated ngrok URL and include it in the ALLOWED_HOSTS setting. If you’re using the development environment, it usually defaults to *, but in some cases this may not work properly, like in my tests (investigate): + +``` +ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"]) +``` + +## 4. Configure the Identity Provider (IdP) + +Start your environment and configure your IdP. You will need to download the IdP’s metadata XML file. + +Your Assertion Consumer Service (ACS) URL must follow this format: + +``` +https:///api/v1/accounts/saml//acs/ +``` + +## 5. IdP Attribute Mapping + +The following fields are expected from the IdP: + +- firstName + +- lastName + +- userType (this is the name of the role the user should be assigned) + +- companyName (this is filled automatically if the IdP includes an “organization” field) + +These values are dynamic. If the values change in the IdP, they will be updated on the next login. + +## 6. SAML Configuration API (POST) + +SAML configuration is managed via a CRUD API. Use the following POST request to create a new configuration: + +```bash +curl --location 'http://localhost:8080/api/v1/saml-config' \ +--header 'Content-Type: application/vnd.api+json' \ +--header 'Accept: application/vnd.api+json' \ +--header 'Authorization: Bearer ' \ +--data '{ + "data": { + "type": "saml-configurations", + "attributes": { + "email_domain": "prowler.com", + "metadata_xml": "" + } + } +}' +``` + +## 7. Start SAML Login Flow + +Once everything is configured, start the SAML login process by visiting the following URL: + +``` +https:///api/v1/accounts/saml//login/?email= +``` + +At the end you will get a valid access and refresh token + +## 8. Notes on the initiate Endpoint + +The initiate endpoint is not strictly required. It was created to allow extra checks or behavior modifications (like enumeration mitigation). It also simplifies UI integration with SAML, but again, it’s optional. diff --git a/mkdocs.yml b/mkdocs.yml index b01fa5a2bb..3ceb8c241a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -53,6 +53,7 @@ nav: - Getting Started: tutorials/prowler-app.md - 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 - CLI: - Miscellaneous: tutorials/misc.md - Reporting: tutorials/reporting.md diff --git a/poetry.lock b/poetry.lock index 01c3b39bd3..bea5bae5ff 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2184,6 +2184,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] @@ -3969,32 +3971,6 @@ cffi = ">=1.4.1" docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] -[[package]] -name = "pynacl" -version = "1.5.0" -description = "Python binding to the Networking and Cryptography (NaCl) library" -optional = false -python-versions = ">=3.6" -files = [ - {file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"}, - {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"}, - {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"}, - {file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"}, - {file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"}, - {file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"}, - {file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"}, -] - -[package.dependencies] -cffi = ">=1.4.1" - -[package.extras] -docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] -tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] - [[package]] name = "pyparsing" version = "3.2.3" diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index bfe2c91df2..3119eb941a 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,17 +2,61 @@ All notable changes to the **Prowler SDK** are documented in this file. -## [5.8.0] (Prowler v5.8.0) +## [v5.8.0] (Prowler UNRELEASED) ### Added - Add CIS 1.11 compliance framework for Kubernetes. [(#7790)](https://github.com/prowler-cloud/prowler/pull/7790) - Support `HTTPS_PROXY` and `K8S_SKIP_TLS_VERIFY` in Kubernetes. [(#7720)](https://github.com/prowler-cloud/prowler/pull/7720) -- Add new check `entra_users_mfa_capable`. [(#7734)](https://github.com/prowler-cloud/prowler/pull/7734) -- Add new check `admincenter_organization_customer_lockbox_enabled`. [(#7732)](https://github.com/prowler-cloud/prowler/pull/7732) -- Add new check `admincenter_external_calendar_sharing_disabled`. [(#7733)](https://github.com/prowler-cloud/prowler/pull/7733) +- Add Weight for Prowler ThreatScore scoring. [(#7795)](https://github.com/prowler-cloud/prowler/pull/7795) +- Add new check `entra_users_mfa_capable` for M365 provider. [(#7734)](https://github.com/prowler-cloud/prowler/pull/7734) +- Add new check `admincenter_organization_customer_lockbox_enabled` for M365 provider. [(#7732)](https://github.com/prowler-cloud/prowler/pull/7732) +- Add new check `admincenter_external_calendar_sharing_disabled` for M365 provider. [(#7733)](https://github.com/prowler-cloud/prowler/pull/7733) +- Add a level for Prowler ThreatScore in the accordion in Dashboard. [(#7739)](https://github.com/prowler-cloud/prowler/pull/7739) +- Add CIS 4.0 compliance framework for GCP. [(7785)](https://github.com/prowler-cloud/prowler/pull/7785) +- Add `repository_has_codeowners_file` check for GitHub provider. [(#7752)](https://github.com/prowler-cloud/prowler/pull/7752) +- Add `repository_default_branch_requires_signed_commits` check for GitHub provider. [(#7777)](https://github.com/prowler-cloud/prowler/pull/7777) +- Add `repository_inactive_not_archived` check for GitHub provider. [(#7786)](https://github.com/prowler-cloud/prowler/pull/7786) +- Add `repository_dependency_scanning_enabled` check for GitHub provider. [(#7771)](https://github.com/prowler-cloud/prowler/pull/7771) +- Add `repository_secret_scanning_enabled` check for GitHub provider. [(#7759)](https://github.com/prowler-cloud/prowler/pull/7759) +- Add `repository_default_branch_requires_codeowners_review` check for GitHub provider. [(#7753)](https://github.com/prowler-cloud/prowler/pull/7753) +- Add NIS 2 compliance framework for AWS. [(#7839)](https://github.com/prowler-cloud/prowler/pull/7839) +- Add NIS 2 compliance framework for Azure. [(#7857)](https://github.com/prowler-cloud/prowler/pull/7857) +- Add search bar in Dashboard Overview page. [(#7804)](https://github.com/prowler-cloud/prowler/pull/7804) +- Add NIS 2 compliance framework for GCP. [(#7912)](https://github.com/prowler-cloud/prowler/pull/7912) +- Add `storage_account_key_access_disabled` check for Azure provider. [(#7974)](https://github.com/prowler-cloud/prowler/pull/7974) +- Add `storage_ensure_file_shares_soft_delete_is_enabled` check for Azure provider. [(#7966)](https://github.com/prowler-cloud/prowler/pull/7966) +- Make `validate_mutelist` method static inside `Mutelist` class. [(#7811)](https://github.com/prowler-cloud/prowler/pull/7811) + +### Fixed +- Add github provider to `usage` section of `prowler -h`: [(#7906)](https://github.com/prowler-cloud/prowler/pull/7906) + +--- +## [v5.7.5] (Prowler v5.7.5) + +### Fixed +- Fix `apiserver_strong_ciphers_only` check for K8S provider. [(#7952)](https://github.com/prowler-cloud/prowler/pull/7952) +- Handle `0` at the start and end of account uids in Prowler Dashboard. [(#7955)](https://github.com/prowler-cloud/prowler/pull/7955) +- Fix typo in PCI 4.0 for K8S provider. [(#7971)](https://github.com/prowler-cloud/prowler/pull/7971) + +--- + +## [v5.7.3] (Prowler v5.7.3) + +### Fixed +- Automatically encrypt password in Microsoft365 provider. [(#7784)](https://github.com/prowler-cloud/prowler/pull/7784) +- Remove last encrypted password appearances. [(#7825)](https://github.com/prowler-cloud/prowler/pull/7825) + +--- + +## [v5.7.2] (Prowler v5.7.2) ### Fixed - Fix `m365_powershell test_credentials` to use sanitized credentials. [(#7761)](https://github.com/prowler-cloud/prowler/pull/7761) +- Fix `admincenter_users_admins_reduced_license_footprint` check logic to pass when admin user has no license. [(#7779)](https://github.com/prowler-cloud/prowler/pull/7779) +- Fix `m365_powershell` to close the PowerShell sessions in msgraph services. [(#7816)](https://github.com/prowler-cloud/prowler/pull/7816) +- Fix `defender_ensure_notify_alerts_severity_is_high`check to accept high or lower severity. [(#7862)](https://github.com/prowler-cloud/prowler/pull/7862) +- Replace `Directory.Read.All` permission with `Domain.Read.All` which is more restrictive. [(#7888)](https://github.com/prowler-cloud/prowler/pull/7888) +- Split calls to list Azure Functions attributes. [(#7778)](https://github.com/prowler-cloud/prowler/pull/7778) --- @@ -47,7 +91,6 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [v5.6.0] (Prowler v5.6.0) ### Added - - Add SOC2 compliance framework to Azure. [(#7489)](https://github.com/prowler-cloud/prowler/pull/7489) - Add check for unused Service Accounts in GCP. [(#7419)](https://github.com/prowler-cloud/prowler/pull/7419) - Add Powershell to Microsoft365. [(#7331)](https://github.com/prowler-cloud/prowler/pull/7331) @@ -97,7 +140,6 @@ All notable changes to the **Prowler SDK** are documented in this file. - Add Microsoft User and User Credential auth to reports [(#7681)](https://github.com/prowler-cloud/prowler/pull/7681) ### Fixed - - Fix package name location in pyproject.toml while replicating for prowler-cloud. [(#7531)](https://github.com/prowler-cloud/prowler/pull/7531) - Remove cache in PyPI release action. [(#7532)](https://github.com/prowler-cloud/prowler/pull/7532) - Add the correct values for logger.info inside iam service. [(#7526)](https://github.com/prowler-cloud/prowler/pull/7526) @@ -118,7 +160,6 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [v5.5.1] (Prowler v5.5.1) ### Fixed - - Add default name to contacts in Azure Defender. [(#7483)](https://github.com/prowler-cloud/prowler/pull/7483) - Handle projects without ID in GCP. [(#7496)](https://github.com/prowler-cloud/prowler/pull/7496) - Restore packages location in PyProject. [(#7510)](https://github.com/prowler-cloud/prowler/pull/7510) diff --git a/prowler/compliance/aws/nis2_aws.json b/prowler/compliance/aws/nis2_aws.json new file mode 100644 index 0000000000..a8c5aabce8 --- /dev/null +++ b/prowler/compliance/aws/nis2_aws.json @@ -0,0 +1,2104 @@ +{ + "Framework": "NIS2", + "Version": "", + "Provider": "AWS", + "Description": "ANNEX to the Commission Implementing Regulation laying down rules for the application of Directive (EU) 2022/2555 as regards technical and methodological requirements of cybersecurity risk-management measures and further specification of the cases in which an incident is considered to be significant with regard to DNS service providers, TLD name registries, cloud computing service providers, data centre service providers, content delivery network providers, managed service providers, managed security service providers, providers of online market places, of online search engines and of social networking services platforms, and trust service providers", + "Requirements": [ + { + "Id": "1.1.1.a", + "Description": "set out the relevant entities approach to managing the security of their network and information systems;", + "Checks": [ + "route53_domains_privacy_protection_enabled", + "secretsmanager_not_publicly_accessible", + "account_security_contact_information_is_registered" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "generic" + } + ] + }, + { + "Id": "1.1.1.c", + "Description": "set out network and information security objectives;", + "Checks": [ + "route53_domains_privacy_protection_enabled" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "route53" + } + ] + }, + { + "Id": "1.1.1.d", + "Description": "include a commitment to continual improvement of the security of network and information systems;", + "Checks": [ + "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_rotate_access_key_90_days", + "route53_domains_privacy_protection_enabled", + "secretsmanager_secret_rotated_periodically" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "generic" + } + ] + }, + { + "Id": "1.1.1.h", + "Description": "list the documentation to be kept and the duration of retention of the documentation;", + "Checks": [ + "kinesis_stream_data_retention_period", + "s3_bucket_server_access_logging_enabled", + "cloudwatch_log_group_retention_policy_specific_days_enabled" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "generic" + } + ] + }, + { + "Id": "1.1.2", + "Description": "The network and information system security policy shall be reviewed and, where appropriate, updated by management bodies at least annually and when significant incidents or significant changes to operations or risks occur. The result of the reviews shall be documented.", + "Checks": [ + "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_rotate_access_key_90_days", + "secretsmanager_secret_rotated_periodically" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "generic" + } + ] + }, + { + "Id": "1.2.1", + "Description": "As part of their policy on the security of network and information systems referred to in point 1.1., the relevant entities shall lay down responsibilities and authorities for network and information system security and assign them to roles, allocate them according to the relevant entities needs, and communicate them to the management bodies.", + "Checks": [ + "iam_policy_attached_only_to_group_or_roles", + "iam_role_administratoraccess_policy", + "iam_policy_cloudshell_admin_not_attached" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.2 Roles, responsibilities and authorities", + "Service": "iam" + } + ] + }, + { + "Id": "1.2.3", + "Description": "At least one person shall report directly to the management bodies on matters of network and information system security.", + "Checks": [ + "account_security_contact_information_is_registered" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.2 Roles, responsibilities and authorities", + "Service": "account" + } + ] + }, + { + "Id": "1.2.4", + "Description": "Depending on the size of the relevant entities, network and information system security shall be covered by dedicated roles or duties carried out in addition to existing roles.", + "Checks": [ + "iam_securityaudit_role_created" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.2 Roles, responsibilities and authorities", + "Service": "iam" + } + ] + }, + { + "Id": "2.1.1", + "Description": "For the purpose of Article 21(2), point (a) of Directive (EU) 2022/2555, the relevant entities shall establish and maintain an appropriate risk management framework to identify and address the risks posed to the security of network and information systems. The relevant entities shall perform and document risk assessments and, based on the results, establish, implement and monitor a risk treatment plan. Risk assessment results and residual risks shall be accepted by management bodies or, where applicable, by persons who are accountable and have the authority to manage risks, provided that the relevant entities ensure adequate reporting to the management bodies.", + "Checks": [ + "ssmincidents_enabled_with_plans", + "iam_securityaudit_role_created", + "iam_support_role_created" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.2 Roles, responsibilities and authorities", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.a", + "Description": "follow a risk management methodology;", + "Checks": [ + "ssmincidents_enabled_with_plans", + "iam_securityaudit_role_created", + "iam_support_role_created" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.d", + "Description": "in line with an all-hazards approach, identify and document the risks posed to the security of network and information systems, in particular in relation to third parties and risks that could lead to disruptions in the availability, integrity, authenticity and confidentiality of the network and information systems, including the identification of single point of failures;", + "Checks": [ + "networkfirewall_multi_az", + "networkfirewall_policy_rule_group_associated", + "backup_plans_exist" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.e", + "Description": "analyse the risks posed to the security of network and information systems, including threat, likelihood, impact, and risk level, taking into account cyber threat intelligence and vulnerabilities;", + "Checks": [ + "iam_securityaudit_role_created" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "iam" + } + ] + }, + { + "Id": "2.1.2.f", + "Description": "evaluate the identified risks based on the risk criteria;", + "Checks": [ + "iam_securityaudit_role_created", + "iam_policy_attached_only_to_group_or_roles", + "secretsmanager_secret_rotated_periodically" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.g", + "Description": "identify and prioritise appropriate risk treatment options and measures;", + "Checks": [ + "guardduty_no_high_severity_findings", + "inspector2_active_findings_exist", + "accessanalyzer_enabled_without_findings" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.h", + "Description": "continuously monitor the implementation of the risk treatment measures;", + "Checks": [ + "guardduty_no_high_severity_findings", + "inspector2_active_findings_exist", + "accessanalyzer_enabled_without_findings" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.i", + "Description": "identify who is responsible for implementing the risk treatment measures and when they should be implemented;", + "Checks": [ + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "ssm" + } + ] + }, + { + "Id": "2.1.2.j", + "Description": "document the chosen risk treatment measures in a risk treatment plan and the reasons justifying the acceptance of residual risks in a comprehensible manner.", + "Checks": [ + "backup_plans_exist", + "backup_reportplans_exist" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "backup" + } + ] + }, + { + "Id": "2.1.3", + "Description": "When identifying and prioritising appropriate risk treatment options and measures, the relevant entities shall take into account the risk assessment results, the results of the procedure to assess the effectiveness of cybersecurity risk-management measures, the cost of implementation in relation to the expected benefit, the asset classification referred to in point 12.1., and the business impact analysis referred to in point 4.1.3.", + "Checks": [ + "backup_recovery_point_encrypted" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "backup" + } + ] + }, + { + "Id": "2.1.4", + "Description": "The relevant entities shall review and, where appropriate, update the risk assessment results and the risk treatment plan at planned intervals and at least annually, and when significant changes to operations or risks or significant incidents occur.", + "Checks": [ + "secretsmanager_secret_rotated_periodically", + "iam_rotate_access_key_90_days" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.2.1", + "Description": "The relevant entities shall regularly review the compliance with their policies on network and information system security, topic-specific policies, rules, and standards. The management bodies shall be informed of the status of network and information security on the basis of the compliance reviews by means of regular reporting.", + "Checks": [ + "networkfirewall_policy_rule_group_associated", + "account_security_contact_information_is_registered", + "iam_support_role_created", + "iam_securityaudit_role_created" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.2 Compliance monitoring", + "Service": "generic" + } + ] + }, + { + "Id": "2.2.3", + "Description": "The relevant entities shall perform the compliance monitoring at planned intervals and when significant incidents or significant changes to operations or risks occur.", + "Checks": [ + "secretsmanager_secret_rotated_periodically", + "account_maintain_current_contact_details", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_security_group_changes" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.2 Compliance monitoring", + "Service": "generic" + } + ] + }, + { + "Id": "2.3.1", + "Description": "The relevant entities shall review independently their approach to managing network and information system security and its implementation including people, processes and technologies.", + "Checks": [ + "iam_rotate_access_key_90_days", + "iam_securityaudit_role_created", + "secretsmanager_secret_rotated_periodically", + "cloudwatch_log_metric_filter_root_usage" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.3 Independent review of information and network security", + "Service": "generic" + } + ] + }, + { + "Id": "3.1.1", + "Description": "For the purpose of Article 21(2), point (b) of Directive (EU) 2022/2555, the relevant entities shall establish and implement an incident handling policy laying down the roles, responsibilities, and procedures for detecting, analysing, containing or responding to, recovering from, documenting and reporting of incidents in a timely manner.", + "Checks": [ + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "ssm" + } + ] + }, + { + "Id": "3.1.2.a", + "Description": "a categorisation system for incidents that is consistent with the event assessment and classification carried out pursuant to point 3.4.1.;", + "Checks": [ + "cloudtrail_multi_region_enabled_logging_management_events", + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "generic" + } + ] + }, + { + "Id": "3.1.2.c", + "Description": "assignment of roles to detect and appropriately respond to incidents to competent employees;", + "Checks": [ + "iam_role_cross_service_confused_deputy_prevention", + "iam_securityaudit_role_created", + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "generic" + } + ] + }, + { + "Id": "3.1.2.d", + "Description": "documents to be used in the course of incident detection and response such as incident response manuals, escalation charts, contact lists and templates.", + "Checks": [ + "ssmincidents_enabled_with_plans", + "ssm_documents_set_as_public", + "iam_support_role_created", + "account_security_contact_information_is_registered" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "generic" + } + ] + }, + { + "Id": "3.1.3", + "Description": "The roles, responsibilities and procedures laid down in the policy shall be tested and reviewed and, where appropriate, updated at planned intervals and after significant incidents or significant changes to operations or risks.", + "Checks": [ + "secretsmanager_secret_rotated_periodically", + "iam_rotate_access_key_90_days", + "iam_securityaudit_role_created" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.1", + "Description": "The relevant entities shall lay down procedures and use tools to monitor and log activities on their network and information systems to detect events that could be considered as incidents and respond accordingly to mitigate the impact.", + "Checks": [ + "cloudwatch_log_metric_filter_root_usage" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "3.2.2", + "Description": "To the extent feasible, monitoring shall be automated and carried out either continuously or in periodic intervals, subject to business capabilities. The relevant entities shall implement their monitoring activities in a way which minimises false positives and false negatives.", + "Checks": [ + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_root_usage" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "3.2.3.a", + "Description": "relevant outbound and inbound network traffic;", + "Checks": [ + "cloudfront_distributions_origin_traffic_encrypted", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "ec2_launch_template_no_public_ip" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.b", + "Description": "creation, modification or deletion of users of the relevant entities network and information systems and extension of the permissions;", + "Checks": [ + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "3.2.3.c", + "Description": "access to systems and applications;", + "Checks": [ + "acm_certificates_transparency_logs_enabled", + "apigateway_restapi_logging_enabled", + "apigatewayv2_api_access_logging_enabled", + "appsync_field_level_logging_enabled", + "athena_workgroup_logging_enabled", + "awslambda_function_invoke_api_operations_cloudtrail_logging_enabled", + "bedrock_model_invocation_logging_enabled", + "bedrock_model_invocation_logs_encryption_enabled", + "cloudfront_distributions_logging_enabled", + "cloudtrail_cloudwatch_logging_enabled", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "cloudtrail_logs_s3_bucket_is_not_publicly_accessible", + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "cloudwatch_log_group_not_publicly_accessible", + "cloudwatch_log_group_retention_policy_specific_days_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "cloudwatch_log_metric_filter_unauthorized_api_calls", + "codebuild_project_logging_enabled", + "codebuild_project_s3_logs_encrypted", + "datasync_task_logging_enabled", + "dms_replication_task_source_logging_enabled", + "dms_replication_task_target_logging_enabled", + "ec2_client_vpn_endpoint_connection_logging_enabled", + "ecs_task_definitions_logging_block_mode", + "ecs_task_definitions_logging_enabled", + "eks_control_plane_logging_all_types_enabled", + "elasticbeanstalk_environment_cloudwatch_logging_enabled", + "elb_logging_enabled", + "elbv2_logging_enabled", + "glue_etl_jobs_cloudwatch_logs_encryption_enabled", + "glue_etl_jobs_logging_enabled", + "guardduty_eks_audit_log_enabled", + "mq_broker_logging_enabled", + "neptune_cluster_integration_cloudwatch_logs", + "networkfirewall_logging_enabled", + "opensearch_service_domains_audit_logging_enabled", + "opensearch_service_domains_cloudwatch_logging_enabled", + "rds_cluster_integration_cloudwatch_logs", + "rds_instance_integration_cloudwatch_logs", + "redshift_cluster_audit_logging", + "route53_public_hosted_zones_cloudwatch_logging_enabled", + "s3_bucket_server_access_logging_enabled", + "stepfunctions_statemachine_logging_enabled", + "vpc_flow_logs_enabled", + "waf_global_webacl_logging_enabled", + "wafv2_webacl_logging_enabled", + "wafv2_webacl_rule_logging_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.d", + "Description": "authentication-related events;", + "Checks": [ + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "directoryservice_supported_mfa_radius_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.e", + "Description": "all privileged access to systems and applications, and activities performed by administrative accounts;", + "Checks": [ + "accessanalyzer_enabled", + "cloudwatch_log_metric_filter_root_usage" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.f", + "Description": "access or changes to critical configuration and backup files;", + "Checks": [ + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_security_group_changes" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.g", + "Description": "event logs and logs from security tools, such as antivirus, intrusion detection systems or firewalls;", + "Checks": [ + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "cloudwatch_log_metric_filter_unauthorized_api_calls", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_s3_dataevents_write_enabled", + "cloudtrail_cloudwatch_logging_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.h", + "Description": "use of system resources, as well as their performance;", + "Checks": [ + "rds_instance_enhanced_monitoring_enabled", + "ec2_instance_detailed_monitoring_enabled", + "guardduty_eks_runtime_monitoring_enabled", + "kafka_cluster_enhanced_monitoring_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.4", + "Description": "The logs shall be regularly reviewed for any unusual or unwanted trends. Where appropriate, the relevant entities shall lay down appropriate values for alarm thresholds. If the laid down values for alarm threshold are exceeded, an alarm shall be triggered, where appropriate, automatically. The relevant entities shall ensure that, in case of an alarm, a qualified and appropriate response is initiated in a timely manner.", + "Checks": [ + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_unauthorized_api_calls", + "cloudwatch_changes_to_network_route_tables_alarm_configured" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "3.2.5", + "Description": "The relevant entities shall maintain and back up logs for a predefined period and shall protect them from unauthorised access or changes.", + "Checks": [ + "cloudwatch_log_group_retention_policy_specific_days_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "3.4.1", + "Description": "The relevant entities shall assess suspicious events to determine whether they constitute incidents and, if so, determine their nature and severity.", + "Checks": [ + "cloudtrail_threat_detection_privilege_escalation", + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.4 Event assessment and classification", + "Service": "cloudtrail" + } + ] + }, + { + "Id": "3.4.2.c", + "Description": "review the appropriate logs for the purposes of event assessment and classification;", + "Checks": [ + "cloudtrail_multi_region_enabled_logging_management_events", + "cloudtrail_s3_dataevents_read_enabled", + "cloudtrail_cloudwatch_logging_enabled", + "cloudwatch_log_metric_filter_unauthorized_api_calls" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.4 Event assessment and classification", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "3.4.2.d", + "Description": "put in place a process for log correlation and analysis, and reassess and reclassify events in case of new information becoming available or after analysis of previously available information.", + "Checks": [ + "cloudtrail_log_file_validation_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.4 Event assessment and classification", + "Service": "generic" + } + ] + }, + { + "Id": "3.5.1", + "Description": "The relevant entities shall respond to incidents in accordance with documented procedures and in a timely manner.", + "Checks": [ + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.5 Incident response", + "Service": "ssm" + } + ] + }, + { + "Id": "3.5.3.a", + "Description": "with the Computer Security Incident Response Teams (CSIRTs) or, where applicable, the competent authorities, related to incident notification;", + "Checks": [ + "route53_domains_privacy_protection_enabled", + "account_security_contact_information_is_registered", + "account_maintain_current_contact_details", + "ec2_instance_secrets_user_data", + "secretsmanager_not_publicly_accessible" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.5 Incident response", + "Service": "generic" + } + ] + }, + { + "Id": "3.5.4", + "Description": "The relevant entities shall log incident response activities in accordance with the procedures referred to in point 3.2.1., and record evidence.", + "Checks": [ + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_security_group_changes", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "cloudwatch_log_metric_filter_unauthorized_api_calls" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.5 Incident response", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "3.6.1", + "Description": "Where appropriate, the relevant entities shall carry out post-incident reviews after recovery from incidents. The post-incident reviews shall identify, where possible, the root cause of the incident and result in documented lessons learned to reduce the occurrence and consequences of future incidents.", + "Checks": [ + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.6 Post-incident reviews", + "Service": "ssm" + } + ] + }, + { + "Id": "3.6.2", + "Description": "The relevant entities shall ensure that post-incident reviews contribute to improving their approach to network and information security, to risk treatment measures, and to incident handling, detection and response procedures.", + "Checks": [ + "ssmincidents_enabled_with_plans", + "backup_plans_exist", + "backup_recovery_point_encrypted", + "backup_reportplans_exist", + "backup_vaults_encrypted", + "backup_vaults_exist", + "documentdb_cluster_backup_enabled", + "dynamodb_table_protected_by_backup_plan", + "ec2_ebs_volume_protected_by_backup_plan", + "efs_have_backup_enabled", + "elasticache_redis_cluster_backup_enabled", + "fsx_file_system_copy_tags_to_backups_enabled", + "neptune_cluster_backup_enabled", + "rds_cluster_protected_by_backup_plan", + "rds_instance_backup_enabled", + "rds_instance_protected_by_backup_plan" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.6 Post-incident reviews", + "Service": "generic" + } + ] + }, + { + "Id": "3.6.3", + "Description": "The relevant entities shall review at planned intervals if incidents led to post-incident reviews.", + "Checks": [ + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.6 Post-incident reviews", + "Service": "generic" + } + ] + }, + { + "Id": "4.1.1", + "Description": "For the purpose of Article 21(2), point (c) of Directive (EU) 2022/2555, the relevant entities shall lay down and maintain a business continuity and disaster recovery plan to apply in the case of incidents.", + "Checks": [ + "backup_recovery_point_encrypted", + "backup_reportplans_exist", + "backup_plans_exist", + "rds_instance_backup_enabled", + "rds_instance_protected_by_backup_plan", + "rds_cluster_protected_by_backup_plan" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "generic" + } + ] + }, + { + "Id": "4.1.2.f", + "Description": "recovery plans for specific operations, including recovery objectives;", + "Checks": [ + "backup_plans_exist", + "backup_reportplans_exist", + "backup_vaults_exist" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "backup" + } + ] + }, + { + "Id": "4.1.2.g", + "Description": "required resources, including backups and redundancies;", + "Checks": [ + "backup_plans_exist", + "backup_recovery_point_encrypted", + "backup_reportplans_exist", + "backup_vaults_encrypted", + "backup_vaults_exist", + "documentdb_cluster_backup_enabled", + "dynamodb_table_protected_by_backup_plan", + "ec2_ebs_volume_protected_by_backup_plan", + "efs_have_backup_enabled", + "elasticache_redis_cluster_backup_enabled", + "fsx_file_system_copy_tags_to_backups_enabled", + "neptune_cluster_backup_enabled", + "rds_cluster_protected_by_backup_plan", + "rds_instance_backup_enabled", + "rds_instance_protected_by_backup_plan" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "generic" + } + ] + }, + { + "Id": "4.1.3", + "Description": "The relevant entities shall carry out a business impact analysis to assess the potential impact of severe disruptions to their business operations and shall, based on the results of the business impact analysis, establish continuity requirements for the network and information systems.", + "Checks": [ + "backup_plans_exist" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "backup" + } + ] + }, + { + "Id": "4.1.4", + "Description": "The business continuity plan and disaster recovery plan shall be tested, reviewed and, where appropriate, updated at planned intervals and following significant incidents or significant changes to operations or risks. The relevant entities shall ensure that the plans incorporate lessons learnt from such tests.", + "Checks": [ + "backup_plans_exist", + "backup_reportplans_exist", + "rds_cluster_protected_by_backup_plan", + "rds_instance_protected_by_backup_plan", + "dynamodb_table_protected_by_backup_plan" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "generic" + } + ] + }, + { + "Id": "4.2.2.b", + "Description": "assurance that backup copies are complete and accurate, including configuration data and data stored in cloud computing service environment;", + "Checks": [ + "backup_vaults_encrypted", + "backup_vaults_exist", + "backup_recovery_point_encrypted", + "backup_plans_exist" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.2 Backup and redundancy management", + "Service": "backup" + } + ] + }, + { + "Id": "4.2.2.e", + "Description": "restoring data from backup copies;", + "Checks": [ + "rds_cluster_backtrack_enabled", + "rds_instance_backup_enabled", + "backup_vaults_exist" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.2 Backup and redundancy management", + "Service": "generic" + } + ] + }, + { + "Id": "4.2.2.f", + "Description": "retention periods based on business and regulatory requirements.", + "Checks": [ + "kinesis_stream_data_retention_period", + "cloudwatch_log_group_retention_policy_specific_days_enabled" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.2 Backup and redundancy management", + "Service": "generic" + } + ] + }, + { + "Id": "4.3.1", + "Description": "The relevant entities shall put in place a process for crisis management.", + "Checks": [ + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.3 Crisis management", + "Service": "ssm" + } + ] + }, + { + "Id": "4.3.2.a", + "Description": "roles and responsibilities for personnel and, where appropriate, suppliers and service providers, specifying the allocation of roles in crisis situations, including specific steps to follow;", + "Checks": [ + "iam_support_role_created" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.3 Crisis management", + "Service": "iam" + } + ] + }, + { + "Id": "4.3.2.c", + "Description": "application of appropriate measures to ensure the maintenance of network and information system security in crisis situations.", + "Checks": [ + "iam_no_expired_server_certificates_stored", + "route53_domains_privacy_protection_enabled", + "backup_vaults_encrypted" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.3 Crisis management", + "Service": "generic" + } + ] + }, + { + "Id": "5.1.2.a", + "Description": "the cybersecurity practices of the suppliers and service providers, including their secure development procedures;", + "Checks": [ + "codebuild_report_group_export_encrypted", + "codebuild_project_s3_logs_encrypted", + "codebuild_project_source_repo_url_no_sensitive_credentials", + "cloudfront_distributions_origin_traffic_encrypted", + "bedrock_model_invocation_logs_encryption_enabled" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "generic" + } + ] + }, + { + "Id": "5.1.4.f", + "Description": "an obligation on suppliers and service providers to handle vulnerabilities that present a risk to the security of the network and information systems of the relevant entities;", + "Checks": [ + "guardduty_no_high_severity_findings", + "inspector2_active_findings_exist" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "generic" + } + ] + }, + { + "Id": "5.1.7.b", + "Description": "review incidents related to ICT products and ICT services from suppliers and service providers;", + "Checks": [ + "ssmincidents_enabled_with_plans", + "iam_support_role_created", + "account_security_contact_information_is_registered", + "account_maintain_current_contact_details" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "generic" + } + ] + }, + { + "Id": "5.1.7.d", + "Description": "analyse the risks presented by changes related to ICT products and ICT services from suppliers and service providers and, where appropriate, take mitigating measures in a timely manner.", + "Checks": [ + "guardduty_no_high_severity_findings", + "inspector2_active_findings_exist" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "generic" + } + ] + }, + { + "Id": "6.1.2.b", + "Description": "requirements regarding security updates throughout the entire lifetime of the ICT services or ICT products, or replacement after the end of the support period;", + "Checks": [ + "elasticbeanstalk_environment_managed_updates_enabled", + "opensearch_service_domains_updated_to_the_latest_service_software_version" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.1 Security in acquisition of ICT services or ICT products", + "Service": "generic" + } + ] + }, + { + "Id": "6.2.1", + "Description": "Before developing a network and information system, including software, the relevant entities shall lay down rules for the secure development of network and information systems and apply them when developing network and information systems in-house, or when outsourcing the development of network and information systems. The rules shall cover all development phases, including specification, design, development, implementation and testing.", + "Checks": [ + "glue_development_endpoints_cloudwatch_logs_encryption_enabled", + "glue_development_endpoints_job_bookmark_encryption_enabled", + "glue_development_endpoints_s3_encryption_enabled", + "sagemaker_models_network_isolation_enabled", + "ecs_task_definitions_host_networking_mode_users", + "eks_cluster_network_policy_enabled", + "networkfirewall_deletion_protection", + "networkfirewall_in_all_vpc", + "networkfirewall_policy_default_action_fragmented_packets", + "networkfirewall_policy_default_action_full_packets" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "generic" + } + ] + }, + { + "Id": "6.2.2.a", + "Description": "carry out an analysis of security requirements at the specification and design phases of any development or acquisition project undertaken by the relevant entities or on behalf of those entities;", + "Checks": [ + "iam_securityaudit_role_created" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "generic" + } + ] + }, + { + "Id": "6.2.2.b", + "Description": "apply principles for engineering secure systems and secure coding principles to any information system development activities such as promoting cybersecurity-by-design, zero-trust architectures;", + "Checks": [ + "codebuild_report_group_export_encrypted", + "codebuild_project_logging_enabled", + "codebuild_project_no_secrets_in_variables", + "codebuild_project_older_90_days", + "codebuild_project_s3_logs_encrypted", + "codebuild_project_source_repo_url_no_sensitive_credentials", + "codebuild_project_user_controlled_buildspec" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "generic" + } + ] + }, + { + "Id": "6.2.2.c", + "Description": "lay down security requirements regarding development environments;", + "Checks": [ + "codebuild_project_no_secrets_in_variables", + "codebuild_project_s3_logs_encrypted", + "codebuild_report_group_export_encrypted", + "codebuild_project_source_repo_url_no_sensitive_credentials", + "glue_development_endpoints_job_bookmark_encryption_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "generic" + } + ] + }, + { + "Id": "6.2.4", + "Description": "The relevant entities shall review and, where necessary, update their secure development rules at planned intervals.", + "Checks": [ + "secretsmanager_secret_rotated_periodically", + "iam_rotate_access_key_90_days" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "generic" + } + ] + }, + { + "Id": "6.4.1", + "Description": "The relevant entities shall apply change management procedures to control changes of network and information systems. Where applicable, the procedures shall be consistent with the relevant entities general policies concerning change management.", + "Checks": [ + "cloudwatch_changes_to_network_route_tables_alarm_configured", + "cloudwatch_changes_to_network_acls_alarm_configured", + "cloudwatch_changes_to_network_gateways_alarm_configured", + "cloudwatch_changes_to_vpcs_alarm_configured" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.4 Change management, repairs and maintenance", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "6.6.1.a", + "Description": "security patches are applied within a reasonable time after they become available;", + "Checks": [ + "ssm_managed_compliant_patching", + "memorydb_cluster_auto_minor_version_upgrades", + "elasticbeanstalk_environment_managed_updates_enabled", + "elasticache_redis_cluster_auto_minor_version_upgrades", + "glue_development_endpoints_job_bookmark_encryption_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.6 Security patch management", + "Service": "generic" + } + ] + }, + { + "Id": "6.7.2.b", + "Description": "determine and apply controls to protect the relevant entities internal network domains from unauthorised access;", + "Checks": [ + "networkfirewall_deletion_protection", + "networkfirewall_in_all_vpc", + "networkfirewall_logging_enabled", + "networkfirewall_multi_az", + "networkfirewall_policy_default_action_fragmented_packets", + "networkfirewall_policy_default_action_full_packets", + "networkfirewall_policy_rule_group_associated" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "networkfirewall" + } + ] + }, + { + "Id": "6.7.2.e", + "Description": "not use systems used for administration of the security policy implementation for other purposes;", + "Checks": [ + "iam_inline_policy_no_administrative_privileges", + "iam_avoid_root_usage" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "iam" + } + ] + }, + { + "Id": "6.7.2.g", + "Description": "where appropriate, exclusively allow access to the relevant entities network and information systems by devices authorised by those entities;", + "Checks": [ + "ec2_instance_port_cassandra_exposed_to_internet", + "ec2_instance_port_cifs_exposed_to_internet", + "ec2_instance_port_elasticsearch_kibana_exposed_to_internet", + "ec2_instance_port_ftp_exposed_to_internet", + "ec2_instance_port_kafka_exposed_to_internet", + "ec2_instance_port_kerberos_exposed_to_internet", + "ec2_instance_port_ldap_exposed_to_internet", + "ec2_instance_port_memcached_exposed_to_internet", + "ec2_instance_port_mongodb_exposed_to_internet", + "ec2_instance_port_mysql_exposed_to_internet", + "ec2_instance_port_oracle_exposed_to_internet", + "ec2_instance_port_postgresql_exposed_to_internet", + "ec2_instance_port_rdp_exposed_to_internet", + "ec2_instance_port_redis_exposed_to_internet", + "ec2_instance_port_sqlserver_exposed_to_internet", + "ec2_instance_port_ssh_exposed_to_internet", + "ec2_instance_port_telnet_exposed_to_internet", + "ec2_networkacl_allow_ingress_tcp_port_22", + "ec2_networkacl_allow_ingress_tcp_port_3389" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "ec2" + } + ] + }, + { + "Id": "6.7.2.i", + "Description": "establish communication between distinct systems only through trusted channels that are isolated using logical, cryptographic or physical separation from other communication channels and provide assured identification of their end points and protection of the channel data from modification or disclosure;", + "Checks": [ + "kafka_cluster_in_transit_encryption_enabled", + "kafka_connector_in_transit_encryption_enabled", + "dynamodb_accelerator_cluster_in_transit_encryption_enabled", + "redshift_cluster_in_transit_encryption_enabled", + "transfer_server_in_transit_encryption_enabled", + "elbv2_nlb_tls_termination_enabled", + "kafka_cluster_mutual_tls_authentication_enabled", + "autoscaling_group_launch_configuration_requires_imdsv2", + "ec2_instance_account_imdsv2_enabled", + "ec2_instance_imdsv2_enabled", + "ec2_launch_template_imdsv2_required" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "generic" + } + ] + }, + { + "Id": "6.7.2.l", + "Description": "apply best practices for the security of the DNS, and for Internet routing security and routing hygiene of traffic originating from and destined to the network.", + "Checks": [ + "route53_domains_privacy_protection_enabled", + "route53_dangling_ip_subdomain_takeover", + "elbv2_nlb_tls_termination_enabled", + "kafka_cluster_mutual_tls_authentication_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "generic" + } + ] + }, + { + "Id": "6.8.2.a", + "Description": "consider the functional, logical and physical relationship, including location, between trustworthy systems and services;", + "Checks": [ + "iam_role_cross_service_confused_deputy_prevention", + "vpc_endpoint_services_allowed_principals_trust_boundaries", + "vpc_endpoint_connections_trust_boundaries" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.8 Network segmentation", + "Service": "generic" + } + ] + }, + { + "Id": "6.9.2", + "Description": "For that purpose, the relevant entities shall in particular implement measures that detect or prevent the use of malicious or unauthorised software. The relevant entities shall, where appropriate, ensure that their network and information systems are equipped with detection and response software, which is updated regularly in accordance with the risk assessment carried out pursuant to point 2.1 and the contractual agreements with the providers.", + "Checks": [ + "cloudtrail_threat_detection_privilege_escalation", + "cloudtrail_threat_detection_enumeration", + "cloudtrail_threat_detection_llm_jacking" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.9 Protection against malicious and unauthorised software", + "Service": "cloudtrail" + } + ] + }, + { + "Id": "7.2.b", + "Description": "the methods for monitoring, measurement, analysis and evaluation, as applicable, to ensure valid results;", + "Checks": [ + "cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_changes_enabled", + "cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_changes_enabled", + "cloudwatch_log_metric_filter_authentication_failures", + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk", + "cloudwatch_log_metric_filter_for_s3_bucket_policy_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_root_usage" + ], + "Attributes": [ + { + "Section": "7 POLICIES AND PROCEDURES TO ASSESS THE EFFECTIVENESS OF CYBERSECURITY RISK-MANAGEMENT MEASURES (ARTICLE 21(2), POINT (F), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "7.2 The policy and procedures referred to in point 7.1. shall take into account results of the risk assessment pursuant to point 2.1. and past significant incidents. The relevant entities shall determine:", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "7.2.d", + "Description": "who is responsible for monitoring and measuring the effectiveness of the cybersecurity risk-management measures;", + "Checks": [ + "iam_securityaudit_role_created" + ], + "Attributes": [ + { + "Section": "7 POLICIES AND PROCEDURES TO ASSESS THE EFFECTIVENESS OF CYBERSECURITY RISK-MANAGEMENT MEASURES (ARTICLE 21(2), POINT (F), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "7.2 The policy and procedures referred to in point 7.1. shall take into account results of the risk assessment pursuant to point 2.1. and past significant incidents. The relevant entities shall determine:", + "Service": "iam" + } + ] + }, + { + "Id": "7.2.e", + "Description": "when the results from monitoring and measurement are to be analysed and evaluated;", + "Checks": [ + "iam_securityaudit_role_created" + ], + "Attributes": [ + { + "Section": "7 POLICIES AND PROCEDURES TO ASSESS THE EFFECTIVENESS OF CYBERSECURITY RISK-MANAGEMENT MEASURES (ARTICLE 21(2), POINT (F), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "7.2 The policy and procedures referred to in point 7.1. shall take into account results of the risk assessment pursuant to point 2.1. and past significant incidents. The relevant entities shall determine:", + "Service": "iam" + } + ] + }, + { + "Id": "7.2.f", + "Description": "who has to analyse and evaluate these results.", + "Checks": [ + "iam_securityaudit_role_created" + ], + "Attributes": [ + { + "Section": "7 POLICIES AND PROCEDURES TO ASSESS THE EFFECTIVENESS OF CYBERSECURITY RISK-MANAGEMENT MEASURES (ARTICLE 21(2), POINT (F), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "7.2 The policy and procedures referred to in point 7.1. shall take into account results of the risk assessment pursuant to point 2.1. and past significant incidents. The relevant entities shall determine:", + "Service": "iam" + } + ] + }, + { + "Id": "9.2.a", + "Description": "in accordance with the relevant entities classification of assets, the type, strength and quality of the cryptographic measures required to protect the relevant entities assets, including data at rest and data in transit;", + "Checks": [ + "apigateway_restapi_cache_encrypted", + "athena_workgroup_encryption", + "backup_recovery_point_encrypted", + "cloudtrail_kms_encryption_enabled", + "codebuild_project_s3_logs_encrypted", + "codebuild_report_group_export_encrypted", + "dynamodb_accelerator_cluster_encryption_enabled", + "dms_endpoint_redis_in_transit_encryption_enabled", + "dynamodb_accelerator_cluster_in_transit_encryption_enabled", + "elasticache_redis_cluster_in_transit_encryption_enabled", + "kafka_connector_in_transit_encryption_enabled", + "redshift_cluster_in_transit_encryption_enabled", + "transfer_server_in_transit_encryption_enabled" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c", + "Description": "the relevant entities approach to key management, including, where appropriate, methods for the following:", + "Checks": [ + "iam_rotate_access_key_90_days", + "secretsmanager_secret_rotated_periodically", + "kms_key_not_publicly_accessible", + "iam_no_root_access_key", + "iam_user_no_setup_initial_access_key", + "iam_user_two_active_access_key" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.i", + "Description": "generating different keys for cryptographic systems and applications;", + "Checks": [ + "acm_certificates_with_secure_key_algorithms" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "acm" + } + ] + }, + { + "Id": "9.2.c.ii", + "Description": "issuing and obtaining public key certificates;", + "Checks": [ + "acm_certificates_with_secure_key_algorithms", + "acm_certificates_transparency_logs_enabled", + "route53_domains_privacy_protection_enabled", + "iam_no_expired_server_certificates_stored" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.iii", + "Description": "distributing keys to intended entities, including how to activate keys when received;", + "Checks": [ + "iam_user_no_setup_initial_access_key" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "iam" + } + ] + }, + { + "Id": "9.2.c.iv", + "Description": "storing keys, including how authorised users obtain access to keys;", + "Checks": [ + "secretsmanager_automatic_rotation_enabled", + "secretsmanager_not_publicly_accessible", + "secretsmanager_secret_rotated_periodically", + "secretsmanager_secret_unused" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "secretsmanager" + } + ] + }, + { + "Id": "9.2.c.v", + "Description": "changing or updating keys, including rules on when and how to change keys;", + "Checks": [ + "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_password_policy_lowercase", + "iam_password_policy_minimum_length_14", + "iam_password_policy_number", + "iam_password_policy_reuse_24", + "iam_password_policy_symbol", + "iam_password_policy_uppercase" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "iam" + } + ] + }, + { + "Id": "9.2.c.vi", + "Description": "backing up or archiving keys;", + "Checks": [ + "backup_vaults_encrypted" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "backup" + } + ] + }, + { + "Id": "9.2.c.vii", + "Description": "logging and auditing of key management-related activities;", + "Checks": [ + "cloudwatch_log_metric_filter_root_usage", + "cloudwatch_log_metric_filter_sign_in_without_mfa" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "9.2.c.xii", + "Description": "setting activation and deactivation dates for keys ensuring that the keys can only be used for the specified period of time according to the organization's rules on key management.", + "Checks": [ + "iam_rotate_access_key_90_days" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "iam" + } + ] + }, + { + "Id": "11.1.1", + "Description": "For the purpose of Article 21(2), point (i) of Directive (EU) 2022/2555, the relevant entities shall establish, document and implement logical and physical access control policies for the access to their network and information systems, based on business requirements as well as network and information system security requirements.", + "Checks": [ + "accessanalyzer_enabled", + "apigateway_restapi_client_certificate_enabled", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "cognito_identity_pool_guest_access_disabled", + "ec2_ebs_snapshot_account_block_public_access", + "ec2_instance_profile_attached", + "ecs_task_definitions_containers_readonly_access", + "efs_access_point_enforce_root_directory", + "efs_access_point_enforce_user_identity", + "efs_not_publicly_accessible" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.1 Access control policy", + "Service": "generic" + } + ] + }, + { + "Id": "11.1.2.c", + "Description": "ensure that access is only granted to users that have been adequately authenticated.", + "Checks": [ + "apigatewayv2_api_access_logging_enabled", + "cognito_identity_pool_guest_access_disabled", + "iam_user_mfa_enabled_console_access", + "iam_administrator_access_with_mfa" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.1 Access control policy", + "Service": "generic" + } + ] + }, + { + "Id": "11.2.1", + "Description": "The relevant entities shall provide, modify, remove and document access rights to network and information systems in accordance with the access control policy referred to in point 11.1.", + "Checks": [ + "accessanalyzer_enabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "accessanalyzer" + } + ] + }, + { + "Id": "11.2.2.a", + "Description": "assign and revoke access rights based on the principles of need-to-know, least privilege and separation of duties;", + "Checks": [ + "iam_policy_attached_only_to_group_or_roles", + "iam_policy_allows_privilege_escalation", + "iam_policy_no_full_access_to_cloudtrail", + "iam_policy_no_full_access_to_kms" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "iam" + } + ] + }, + { + "Id": "11.2.2.d", + "Description": "ensure that access rights appropriately address third-party access, such as visitors, suppliers and service providers, in particular by limiting access rights in scope and in duration;", + "Checks": [ + "apigateway_restapi_client_certificate_enabled", + "cognito_identity_pool_guest_access_disabled", + "ec2_ebs_snapshot_account_block_public_access", + "ec2_instance_profile_attached", + "ecs_task_definitions_containers_readonly_access", + "efs_access_point_enforce_root_directory" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "generic" + } + ] + }, + { + "Id": "11.2.2.e", + "Description": "maintain a register of access rights granted;", + "Checks": [ + "accessanalyzer_enabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "accessanalyzer" + } + ] + }, + { + "Id": "11.2.2.f", + "Description": "apply logging to the management of access rights.", + "Checks": [ + "apigatewayv2_api_access_logging_enabled", + "cloudtrail_logs_s3_bucket_access_logging_enabled", + "s3_bucket_server_access_logging_enabled", + "wafv2_webacl_logging_enabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "generic" + } + ] + }, + { + "Id": "11.3.1", + "Description": "The relevant entities shall maintain policies for management of privileged accounts and system administration accounts as part of the access control policy referred to in point 11.1.", + "Checks": [ + "iam_role_administratoraccess_policy" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "iam" + } + ] + }, + { + "Id": "11.3.2.a", + "Description": "establish strong identification, authentication such as multi-factor authentication, and authorisation procedures for privileged accounts and system administration accounts;", + "Checks": [ + "iam_user_mfa_enabled_console_access", + "iam_root_mfa_enabled", + "iam_administrator_access_with_mfa" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "iam" + } + ] + }, + { + "Id": "11.3.2.b", + "Description": "set up specific accounts to be used for system administration operations exclusively, such as installation, configuration, management or maintenance;", + "Checks": [ + "iam_avoid_root_usage" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "iam" + } + ] + }, + { + "Id": "11.3.2.c", + "Description": "individualise and restrict system administration privileges to the highest extent possible,", + "Checks": [ + "iam_avoid_root_usage", + "iam_inline_policy_no_administrative_privileges" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "iam" + } + ] + }, + { + "Id": "11.3.2.d", + "Description": "provide that system administration accounts are only used to connect to system administration systems.", + "Checks": [ + "iam_user_console_access_unused" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "iam" + } + ] + }, + { + "Id": "11.4.2.a", + "Description": "only use system administration systems for system administration purposes, and not for any other operations;", + "Checks": [ + "iam_avoid_root_usage" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.4 Administration systems", + "Service": "iam" + } + ] + }, + { + "Id": "11.4.2.b", + "Description": "separate logically such systems from application software not used for system administrative purposes,", + "Checks": [ + "iam_inline_policy_no_administrative_privileges" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.4 Administration systems", + "Service": "iam" + } + ] + }, + { + "Id": "11.4.2.c", + "Description": "protect access to system administration systems through authentication and encryption.", + "Checks": [ + "dms_endpoint_mongodb_authentication_enabled", + "iam_user_mfa_enabled_console_access", + "kafka_cluster_mutual_tls_authentication_enabled", + "neptune_cluster_iam_authentication_enabled", + "opensearch_service_domains_use_cognito_authentication_for_kibana", + "rds_cluster_iam_authentication_enabled", + "rds_instance_iam_authentication_enabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.4 Administration systems", + "Service": "generic" + } + ] + }, + { + "Id": "11.5.2.a", + "Description": "set up unique identities for network and information systems and their users;", + "Checks": [ + "fsx_file_system_copy_tags_to_backups_enabled", + "fsx_file_system_copy_tags_to_volumes_enabled", + "neptune_cluster_copy_tags_to_snapshots", + "organizations_tags_policies_enabled_and_attached", + "rds_cluster_copy_tags_to_snapshots", + "rds_instance_copy_tags_to_snapshots" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.5 Identification", + "Service": "generic" + } + ] + }, + { + "Id": "11.5.2.d", + "Description": "apply logging to the management of identities.", + "Checks": [ + "cloudwatch_log_metric_filter_aws_organizations_changes", + "cloudwatch_log_metric_filter_policy_changes", + "cloudwatch_log_metric_filter_security_group_changes" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.5 Identification", + "Service": "cloudwatch" + } + ] + }, + { + "Id": "11.5.4", + "Description": "The relevant entities shall regularly review the identities for network and information systems and their users and, if no longer needed, deactivate them without delay.", + "Checks": [ + "iam_user_accesskey_unused", + "iam_user_console_access_unused" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.5 Identification", + "Service": "iam" + } + ] + }, + { + "Id": "11.6.1", + "Description": "The relevant entities shall implement secure authentication procedures and technologies based on access restrictions and the policy on access control.", + "Checks": [ + "cognito_user_pool_mfa_enabled", + "iam_root_hardware_mfa_enabled", + "iam_root_mfa_enabled", + "iam_user_mfa_enabled_console_access" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "generic" + } + ] + }, + { + "Id": "11.6.2.a", + "Description": "ensure the strength of authentication is appropriate to the classification of the asset to be accessed;", + "Checks": [ + "cognito_user_pool_password_policy_lowercase", + "cognito_user_pool_password_policy_minimum_length_14", + "cognito_user_pool_password_policy_number", + "cognito_user_pool_password_policy_symbol", + "cognito_user_pool_password_policy_uppercase", + "iam_password_policy_expires_passwords_within_90_days_or_less", + "iam_password_policy_lowercase" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "generic" + } + ] + }, + { + "Id": "11.6.2.c", + "Description": "require the change of authentication credentials initially, at predefined intervals and upon suspicion that the credentials were compromised;", + "Checks": [ + "secretsmanager_secret_rotated_periodically", + "iam_rotate_access_key_90_days", + "kms_cmk_rotation_enabled", + "secretsmanager_automatic_rotation_enabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "generic" + } + ] + }, + { + "Id": "11.6.2.d", + "Description": "require the reset of authentication credentials and the blocking of users after a predefined number of unsuccessful log-in attempts;", + "Checks": [ + "cognito_user_pool_blocks_compromised_credentials_sign_in_attempts", + "cognito_user_pool_blocks_potential_malicious_sign_in_attempts" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "cognito" + } + ] + }, + { + "Id": "11.6.2.e", + "Description": "terminate inactive sessions after a predefined period of inactivity; and", + "Checks": [ + "appstream_fleet_session_idle_disconnect_timeout", + "appstream_fleet_maximum_session_duration", + "appstream_fleet_session_disconnect_timeout" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "appstream" + } + ] + }, + { + "Id": "11.7.2", + "Description": "The relevant entities shall ensure that the strength of authentication is appropriate for the classification of the asset to be accessed.", + "Checks": [ + "cloudtrail_bucket_requires_mfa_delete", + "cloudwatch_log_metric_filter_sign_in_without_mfa", + "cognito_user_pool_mfa_enabled", + "directoryservice_supported_mfa_radius_enabled", + "iam_administrator_access_with_mfa", + "iam_root_hardware_mfa_enabled", + "iam_root_mfa_enabled", + "iam_user_hardware_mfa_enabled", + "iam_user_mfa_enabled_console_access", + "s3_bucket_no_mfa_delete" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.7 Multi-factor authentication", + "Service": "generic" + } + ] + }, + { + "Id": "12.1.2.c", + "Description": "align the availability requirements of the assets with the delivery and recovery objectives set out in their business continuity and disaster recovery plans.", + "Checks": [ + "backup_vaults_exist", + "backup_plans_exist", + "dynamodb_table_protected_by_backup_plan", + "ec2_ebs_volume_protected_by_backup_plan", + "eks_control_plane_logging_all_types_enabled", + "rds_cluster_protected_by_backup_plan", + "rds_instance_protected_by_backup_plan", + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.1 Asset classification", + "Service": "generic" + } + ] + }, + { + "Id": "12.2.2.a", + "Description": "cover the entire life cycle of the assets, including acquisition, use, storage, transportation and disposal;", + "Checks": [ + "dlm_ebs_snapshot_lifecycle_policy_exists", + "ecr_repositories_lifecycle_policy_enabled", + "s3_bucket_lifecycle_enabled" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.2 Handling of assets", + "Service": "generic" + } + ] + }, + { + "Id": "12.2.2.b", + "Description": "provide rules on the safe use, safe storage, safe transport, and the irretrievable deletion and destruction of the assets;", + "Checks": [ + "backup_vaults_exist", + "backup_plans_exist", + "dynamodb_table_protected_by_backup_plan", + "ec2_ebs_volume_protected_by_backup_plan", + "eks_control_plane_logging_all_types_enabled", + "rds_cluster_protected_by_backup_plan", + "rds_instance_protected_by_backup_plan", + "ssmincidents_enabled_with_plans" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.2 Handling of assets", + "Service": "generic" + } + ] + }, + { + "Id": "12.2.2.c", + "Description": "provide that the transfer shall take place in a secure manner, in accordance with the type of asset to be transferred.", + "Checks": [ + "transfer_server_in_transit_encryption_enabled" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.2 Handling of assets", + "Service": "transfer" + } + ] + } + ] +} diff --git a/prowler/compliance/aws/prowler_threatscore_aws.json b/prowler/compliance/aws/prowler_threatscore_aws.json index c53b7e1590..8ef5d24b93 100644 --- a/prowler/compliance/aws/prowler_threatscore_aws.json +++ b/prowler/compliance/aws/prowler_threatscore_aws.json @@ -17,7 +17,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.", "AdditionalInformation": "Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -34,7 +35,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.", "AdditionalInformation": "A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -51,7 +53,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "To enhance security and reduce the risk of unauthorized access, Multi-Factor Authentication (MFA) should be enabled for all IAM users who have access to the AWS Management Console.", "AdditionalInformation": "Without Multi-Factor Authentication (MFA), a compromised password alone is enough to allow an attacker to access the console, gaining full visibility and control over AWS resources.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -68,7 +71,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "Password policies help enforce password complexity requirements to strengthen account security. In AWS IAM, password policies can be configured to ensure that user passwords meet specific criteria, including a minimum length requirement. It is recommended to enforce a minimum password length of 14 characters to enhance security.", "AdditionalInformation": "Requiring longer and more complex passwords reduces the risk of compromise from brute force attacks, credential stuffing, and other password-based threats. A 14-character minimum makes it significantly harder for attackers to guess or crack passwords, improving overall account security and resilience.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -85,7 +89,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "IAM password policies can be configured to prevent users from reusing previous passwords. This ensures that users create new, unique passwords instead of cycling through old ones. It is recommended to enforce password history restrictions to enhance security.", "AdditionalInformation": "Blocking password reuse helps mitigate the risk of credential-based attacks, such as brute force and credential stuffing. It prevents users from reverting to previously compromised passwords, reducing the likelihood of unauthorized access.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -102,7 +107,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "Password policies help enforce password complexity requirements to strengthen account security. In AWS IAM, password policies can be configured to ensure that user passwords meet specific criteria, including using at least number as requirement. It is recommended to enforce the usage of one number to enhance security.", "AdditionalInformation": "Requiring more complex passwords reduces the risk of compromise from brute force attacks, credential stuffing, and other password-based threats. Using a number at least makes it significantly harder for attackers to guess or crack passwords, improving overall account security and resilience.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -119,7 +125,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "IAM password policies can be configured to enforce the use of at least one special character (symbol) in user passwords. Special characters (e.g., @, #, $, %) add complexity, making passwords harder to guess or crack. It is recommended to require at least one symbol in IAM passwords to enhance security.", "AdditionalInformation": "Requiring a symbol in passwords increases entropy, making brute-force and dictionary attacks more difficult. Attackers often rely on common or predictable password patterns, and enforcing special characters helps reduce the effectiveness of such attacks. This policy strengthens overall password security and aligns with industry best practices.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -136,7 +143,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "IAM password policies can be configured to enforce the use of at least one lowercase letter in user passwords. Including lowercase letters increases password complexity, making them more resistant to brute-force and dictionary attacks. It is recommended to require at least one lowercase letter in IAM passwords to strengthen security.", "AdditionalInformation": "Requiring at least one lowercase letter ensures that passwords are not composed solely of numbers or uppercase letters, which are easier to guess. Attackers often use wordlists and predictable patterns when attempting to crack passwords. By enforcing lowercase letters, password complexity improves, reducing the likelihood of unauthorized access.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -153,7 +161,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "IAM password policies can be configured to enforce the use of at least one uppercase letter in user passwords. Including uppercase letters increases password complexity, making them more resilient to brute-force and dictionary attacks. It is recommended to require at least one uppercase letter in IAM passwords to enhance security.", "AdditionalInformation": "Requiring at least one uppercase letter ensures that passwords are not composed solely of lowercase letters or numbers, which are more predictable and easier to crack. Attackers often rely on common word variations in password attacks, and enforcing uppercase letters adds an additional layer of complexity, reducing the risk of unauthorized access.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -171,7 +180,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "AWS IAM users can authenticate and access AWS resources using various types of credentials, including passwords and access keys. To minimize security risks, it is recommended to deactivate or remove any credentials that have been unused for 45 days or more.", "AdditionalInformation": "Disabling or removing inactive credentials reduces the attack surface and prevents unauthorized access through compromised or forgotten credentials. Unused credentials pose a security risk, as attackers may exploit them if they remain active without regular monitoring. Regularly auditing and revoking stale credentials enhances overall account security.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -188,7 +198,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "Access keys consist of an access key ID and a secret access key, which are used to authenticate and sign programmatic requests made to AWS. These keys allow users and applications to interact with AWS services via the AWS Command Line Interface (CLI), AWS SDKs, PowerShell tools, or direct API calls. To maintain security, it is recommended that all access keys be rotated regularly to minimize the risk of unauthorized access.", "AdditionalInformation": "Regularly rotating access keys reduces the risk of compromised credentials being exploited. If an access key is leaked, cracked, or stolen, rotating it limits the window of opportunity for malicious use. Additionally, rotating keys ensures that inactive or outdated credentials cannot be used for unauthorized access, enhancing overall security and compliance.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -205,7 +216,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "IAM password policies can be configured to enforce password expiration after a defined period. It is recommended that passwords be set to expire within 90 days or less to ensure users regularly update their credentials. This helps mitigate security risks associated with stale or compromised passwords that remain active for extended periods.", "AdditionalInformation": "Requiring password expiration within 90 days or less reduces the risk of credential-based attacks, such as brute-force attacks and credential stuffing, by ensuring that old passwords cannot be used indefinitely. If a password has been exposed or compromised without detection, regular expiration limits the window of opportunity for an attacker to exploit it. This policy enforces stronger access control and aligns with industry security best practices.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -222,7 +234,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "The root account in AWS has unrestricted administrative privileges and should be protected with the highest security measures. Access keys provide programmatic access to AWS, but when linked to the root account, they pose a significant security risk. It is recommended that no access keys be associated with the root account, ensuring that all programmatic access is managed through IAM roles and users with least privilege access.", "AdditionalInformation": "The root account holds the highest level of privileges in an AWS environment. AWS Access Keys enable programmatic access to AWS resources, but when associated with the root account, they pose a significant security risk. It is recommended to remove all access keys linked to the root account to minimize potential attack vectors. Eliminating root access keys reduces the risk of unauthorized access and enforces the use of role-based IAM accounts with least privilege, promoting a more secure and controlled access management approach.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -239,7 +252,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "IAM policies define permissions that control access to AWS resources. To ensure scalability, security, and manageability, it is recommended that IAM policies be attached only to groups or roles rather than individual users. By assigning permissions at the group or role level, organizations can apply consistent security policies and avoid permission sprawl.", "AdditionalInformation": "Attaching policies to groups or roles simplifies access control, reduces security risks, and improves compliance tracking. This approach prevents overprivileged accounts and ensures a structured, scalable IAM policy framework.", - "LevelOfRisk": 1 + "LevelOfRisk": 1, + "Weight": 1 } ] }, @@ -256,7 +270,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "IAM users gain access to AWS services, functions, and data through IAM policies. There are four ways to assign policies to a user: 1.Inline (User-Specific) Policy – Editing the policy directly within the user’s profile.2.Directly Attached Policy – Assigning a standalone policy to a user.3.Group-Based Policy (Recommended) – Adding the user to an IAM group with an attached policy. 4.Group with Inline Policy – Assigning an inline policy to a group that includes the user.Among these methods, only the third approach (group-based policies) is recommended for security and manageability.", "AdditionalInformation": "Managing IAM permissions exclusively through groups ensures consistent, scalable, and role-based access control. This approach reduces the risk of excessive privileges, simplifies auditing, and aligns user permissions with organizational roles.", - "LevelOfRisk": 1 + "LevelOfRisk": 1, + "Weight": 1 } ] }, @@ -274,7 +289,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "IAM policies define permissions for users, groups, and roles, controlling access to AWS resources. Following the principle of least privilege, users should be granted only the permissions necessary to perform their tasks. Instead of assigning broad administrative privileges, permissions should be carefully crafted to allow only the required actions.", "AdditionalInformation": "Starting with minimal permissions and granting additional access as needed is significantly more secure than providing excessive permissions and attempting to restrict them later. Assigning full administrative privileges increases the risk of unauthorized or accidental actions that could compromise AWS resources. IAM policies containing Effect: Allow, Action: , Resource: should be removed to prevent unrestricted access and enforce security best practices.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -291,7 +307,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "AWS offers a Support Center for incident notification, response, technical support, and customer service assistance. To ensure secure and controlled access, an IAM role should be created with a properly assigned policy, allowing only authorized users to manage incidents with AWS Support.", "AdditionalInformation": "Implementing least privilege access control ensures that only designated users can interact with AWS Support. Assigning an IAM role with a specific policy limits access to only necessary actions, reducing the risk of unauthorized modifications or exposure of sensitive account information.", - "LevelOfRisk": 1 + "LevelOfRisk": 1, + "Weight": 1 } ] }, @@ -308,7 +325,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "AWS instances can access AWS resources either by embedding access keys in API calls or by assigning an IAM role with the necessary permissions. Using IAM roles ensures secure, controlled access without hardcoding credentials.", "AdditionalInformation": "IAM roles eliminate the risks associated with hardcoded credentials, reducing exposure to external threats. Unlike access keys, which can be used outside AWS if compromised, IAM roles require an attacker to maintain control of an instance to exploit privileges. Additionally, IAM roles simplify credential management by ensuring permissions are automatically updated without the need for manual key rotation.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -325,7 +343,8 @@ "SubSection": "4.1 In-Transit", "AttributeDescription": "To enable HTTPS connections for applications and websites hosted on AWS, an SSL/TLS server certificate is required. AWS provides two options for managing certificates: AWS Certificate Manager (ACM) – The preferred method for managing SSL/TLS certificates, automating renewals and deployment. IAM Certificate Storage – Used only when deploying SSL/TLS certificates in regions not supported by ACM. IAM securely encrypts private keys and stores them, but certificates must be obtained from an external provider. ACM certificates cannot be uploaded to IAM, and IAM certificates cannot be managed from the IAM Console.", "AdditionalInformation": "Removing expired SSL/TLS certificates prevents the accidental deployment of invalid certificates, which could cause service disruptions, security warnings, and loss of credibility for applications using AWS services like Elastic Load Balancer (ELB). As a best practice, expired certificates should be deleted to maintain a secure and trusted application environment.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -342,7 +361,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "The root account in AWS has unrestricted administrative privileges and should be used only for initial account setup and emergency scenarios. Regular operations should be performed using IAM users or roles with least privilege access to minimize security risks.", "AdditionalInformation": "Using the root account increases the risk of unauthorized access, accidental misconfigurations, and privilege misuse. By restricting root account usage and delegating tasks to IAM users or roles, organizations can enforce better access control, auditing, and security best practices.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -359,7 +379,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "Enable IAM Access Analyzer for all AWS regions to monitor IAM policies and identify resources with unintended external access. IAM Access Analyzer, introduced at AWS re:Invent 2019, scans resource-based policies and provides visibility into which resources—such as KMS keys, IAM roles, S3 buckets, Lambda functions, and SQS queues—are accessible by external accounts or federated users. This allows administrators to enforce least privilege access and mitigate unauthorized access risks. IAM Access Analyzer operates within the same AWS region as the resources being analyzed.", "AdditionalInformation": "IAM Access Analyzer enhances security visibility by detecting AWS resources shared with external entities, helping organizations identify potential security risks and ensure compliance with least privilege principles. It continuously evaluates resource-based policies using logic-based analysis, allowing teams to promptly remediate misconfigurations that could lead to unauthorized access or data exposure.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -376,7 +397,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "In multi-account AWS environments, centralizing IAM user management improves control, security, and access management efficiency. Instead of creating separate IAM users in each account, access should be managed through role assumption. This can be achieved using AWS Organizations or federation with an external identity provider (e.g., AWS IAM Identity Center, Okta, or Active Directory).", "AdditionalInformation": "Centralizing IAM user management into a single identity store simplifies administration, reduces the risk of access misconfigurations, and enforces consistent security policies across all accounts. This approach enhances security, scalability, and compliance while minimizing user duplication and permission errors.", - "LevelOfRisk": 1 + "LevelOfRisk": 1, + "Weight": 1 } ] }, @@ -393,7 +415,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "AWS CloudShell provides a managed command-line interface (CLI) for interacting with AWS services. The AWSCloudShellFullAccess IAM policy grants full access to CloudShell, including file upload and download capabilities between a user’s local system and the CloudShell environment. Within CloudShell, users have sudo privileges and unrestricted internet access, making it possible to install software—such as file transfer tools—that could facilitate data movement to external servers.", "AdditionalInformation": "Access to AWSCloudShellFullAccess should be restricted, as it can serve as a potential data exfiltration vector for malicious or compromised cloud administrators. Granting full permissions to CloudShell increases the risk of unauthorized data transfers outside the AWS environment. AWS provides guidance on creating more restrictive IAM policies to limit file transfer capabilities, reducing security risks.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -410,7 +433,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "IAM inline policies define permissions directly attached to users, groups, or roles, rather than being managed as standalone policies. If improperly configured, these policies can grant actions that enable privilege escalation, allowing users to elevate their access beyond intended permissions. Privilege escalation can occur through misconfigured IAM roles, excessive permissions, or indirect access paths, potentially leading to unauthorized control over AWS resources.", "AdditionalInformation": "Users with some IAM permissions are allowed to elevate their privileges up to administrator rights.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -427,7 +451,8 @@ "SubSection": "4.1 In-Transit", "AttributeDescription": "Amazon S3 bucket permissions can be configured using a bucket policy to enforce access restrictions. To enhance security, objects within the bucket should be made accessible only via HTTPS, ensuring encrypted data transmission.", "AdditionalInformation": "By default, Amazon S3 accepts both HTTP and HTTPS requests, which can expose data to interception. To enforce secure access, HTTP requests should be explicitly denied in the bucket policy. Simply allowing HTTPS without blocking HTTP does not fully comply with security best practices, as unencrypted requests may still be accepted.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -444,7 +469,8 @@ "SubSection": "4.1 In-Transit", "AttributeDescription": "AWS EC2 instances allow users to choose between Instance Metadata Service Version 1 (IMDSv1), which uses a request/response model, or Instance Metadata Service Version 2 (IMDSv2), which uses a session-based approach for enhanced security", "AdditionalInformation": "Instance metadata refers to the data about an EC2 instance, such as host names, events, and security groups, that is used for managing and configuring the instance. When enabling the Metadata Service, users can opt for either IMDSv1, which operates via a simple request/response model, or IMDSv2, which implements session authentication for additional security. With IMDSv2, each request is secured by session-based authentication, ensuring that all interactions with the instance's metadata and credentials are protected. IMDSv1, on the other hand, may expose instances to Server-Side Request Forgery (SSRF) attacks. To improve security, Amazon recommends using IMDSv2", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -461,7 +487,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Enabling MFA Delete on a sensitive or classified Amazon S3 bucket adds an extra layer of protection by requiring two-factor authentication for critical actions, such as deleting object versions or changing the bucket’s versioning state.", "AdditionalInformation": "MFA Delete helps prevent accidental or malicious deletions by requiring an additional authentication step. This mitigates the risk of data loss due to compromised credentials or unauthorized access, ensuring that critical objects remain protected.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -478,7 +505,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Amazon S3 buckets may store sensitive data that needs to be discovered, classified, monitored, and protected to maintain security and compliance. Amazon Macie, along with third-party tools, can automatically inventory S3 buckets and identify sensitive data at scale.", "AdditionalInformation": "Using automated data discovery and classification tools, such as Amazon Macie, enhances security by continuously monitoring S3 buckets for sensitive information. Macie leverages machine learning and pattern matching to detect and protect critical data, reducing the risk of data leaks and unauthorized access.", - "LevelOfRisk": 1 + "LevelOfRisk": 1, + "Weight": 1 } ] }, @@ -495,7 +523,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "Each Amazon VPC includes a default security group that initially denies all inbound traffic, allows all outbound traffic, and permits unrestricted communication between instances within the group. If no security group is specified when launching an instance, it is automatically assigned to this default security group. Since security groups control stateful ingress and egress traffic, it is recommended to restrict all inbound and outbound traffic in the default security group.", "AdditionalInformation": "Restricting all traffic in the default security group enforces least privilege access by ensuring that AWS resources are explicitly assigned to well-defined security groups. This approach reduces unintended exposure, improves network segmentation, and promotes secure resource placement within AWS environments.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -512,7 +541,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "After establishing a VPC peering connection, routing tables must be updated to enable communication between the peered VPCs. Routes can be configured with granular specificity, allowing connections to be restricted to a single host or a specific subnet within the peered VPC.", "AdditionalInformation": "Defining highly specific routes in VPC peering connections enhances security by limiting access to only the necessary resources. This minimizes the potential impact of a security breach, ensuring that resources outside the defined routes remain inaccessible, reducing the risk of lateral movement within the network.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -531,7 +561,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "Network Access Control Lists (NACLs) provide stateless filtering of ingress and egress traffic to AWS resources. It is recommended that NACLs do not allow unrestricted inbound access to remote administration ports, such as SSH (port 22) and RDP (port 3389), over TCP (6), UDP (17), or ALL (-1) protocols to prevent unauthorized access.", "AdditionalInformation": "Exposing remote server administration ports (e.g., SSH on 22 and RDP on 3389) to the public internet increases the attack surface, making resources more vulnerable to brute-force attacks and unauthorized access. Restricting inbound access to these ports helps reduce security risks and limit potential exploitation.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -550,7 +581,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "Security groups enforce stateful filtering of ingress and egress traffic to AWS resources. To enhance security, no security group should allow unrestricted inbound access to remote administration ports, such as SSH (port 22) and RDP (port 3389), over TCP (6), UDP (17), or ALL (-1) protocols.", "AdditionalInformation": "Exposing remote administration ports to the public internet significantly increases the attack surface, making resources more vulnerable to brute-force attacks, exploitation, and unauthorized access. Restricting ingress traffic to these ports helps reduce security risks and prevent potential system compromises.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -567,7 +599,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Amazon Elastic Block Store (EBS) snapshots contain backups of EC2 volumes, which may include sensitive data such as credentials, application configurations, or customer information. EBS snapshots should never be publicly accessible to prevent unauthorized access and data exposure. By default, snapshots are private, but they can be manually shared with other AWS accounts or made public, which poses a significant security risk if misconfigured.", "AdditionalInformation": "Exposing EBS snapshots publicly increases the risk of data breaches, unauthorized access, and compliance violations. Attackers can scan for publicly accessible snapshots and extract sensitive information. To prevent data leaks, snapshots should be restricted to specific AWS accounts or kept private unless explicitly needed for sharing. Implementing proper access controls helps protect critical data and maintain compliance with security best practices.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -584,7 +617,8 @@ "SubSection": "4.2 At-Rest", "AttributeDescription": "Amazon Elastic Compute Cloud (EC2) supports encryption at rest for Elastic Block Store (EBS) volumes, ensuring that stored data remains protected. While EBS encryption is disabled by default, organizations can enforce automatic encryption of newly created volumes to enhance data security and compliance.", "AdditionalInformation": "Enforcing EBS volume encryption reduces the risk of data exposure, unauthorized access, and compliance violations. If encryption remains intact, even if storage is compromised, data remains unreadable to unauthorized users. Encrypting data at rest ensures that sensitive information is protected against accidental disclosure, insider threats, and external attacks.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -601,7 +635,8 @@ "SubSection": "4.2 At-Rest", "AttributeDescription": "Amazon Relational Database Service (RDS) supports encryption at rest using the industry-standard AES-256 encryption algorithm to secure database instances and their associated storage. Once enabled, RDS encryption automatically handles access authentication and decryption, ensuring secure data storage with minimal performance impact.", "AdditionalInformation": "Databases often contain sensitive and business-critical information, making encryption essential to protect against unauthorized access and data breaches. Enabling RDS encryption ensures that underlying storage, automated backups, read replicas, and snapshots are all encrypted, preventing accidental or malicious data exposure while maintaining compliance with security best practices.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -618,7 +653,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Amazon Simple Notification Service (SNS) topics enable messaging between AWS services, applications, and users. By default, SNS topics should be restricted to trusted AWS accounts or IAM roles to prevent unauthorized access. Allowing global send (sns:Publish) or subscribe (sns:Subscribe) permissions means any AWS account or unauthenticated entity could send messages or subscribe to the topic, potentially leading to spam, data leaks, or misuse of notifications.", "AdditionalInformation": "SNS topics with global send or subscribe permissions expose AWS environments to unauthorized message injection, data exfiltration, and Denial-of-Service (DoS) attacks. An attacker could flood an SNS topic with malicious or fraudulent messages, leading to unexpected charges or service disruptions. Restricting access ensures that only authorized AWS accounts, applications, or IAM roles can send and receive messages, reducing security risks and protecting system integrity.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -635,7 +671,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Amazon Relational Database Service (RDS) snapshots store backups of database instances, potentially containing sensitive data such as customer records, credentials, and application configurations. By default, RDS snapshots are private, but they can be shared with other AWS accounts or made public, which can lead to data exposure if misconfigured. To prevent unauthorized access, RDS snapshots should never be publicly accessible unless explicitly required and secured.", "AdditionalInformation": "Publicly accessible RDS snapshots create a serious security risk, as anyone can copy the snapshot and restore the database, exposing sensitive information. Attackers actively scan for publicly available snapshots to extract credentials, personally identifiable information (PII), or business-critical data. To prevent unauthorized access and data leaks, RDS snapshots should remain private or restricted to trusted AWS accounts following the principle of least privilege.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -652,7 +689,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "AWS CloudTrail logs record account activity, including API calls, user actions, and resource modifications, making them critical for security monitoring and compliance auditing. These logs are typically stored in an Amazon S3 bucket for long-term retention and analysis. To protect sensitive security data, the S3 bucket storing CloudTrail logs should never be publicly accessible.", "AdditionalInformation": "If the S3 bucket containing CloudTrail logs is publicly accessible, unauthorized users could access sensitive security information, including API calls, IAM activity, and infrastructure changes. Exposing CloudTrail logs can help attackers reconstruct system activity, identify vulnerabilities, and plan targeted attacks. To prevent data leaks and unauthorized access, CloudTrail log buckets should be restricted using IAM policies, bucket policies, and S3 Block Public Access settings.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -669,7 +707,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Amazon Redshift clusters store and process large-scale data for analytics and business intelligence workloads. By default, Redshift clusters can be configured with a public endpoint, making them accessible from the internet. To minimize security risks, Redshift clusters should be restricted to private networks and should not have a public endpoint unless absolutely necessary and properly secured.", "AdditionalInformation": "Exposing a Redshift cluster to the public internet increases the risk of unauthorized access, data breaches, and cyberattacks. Attackers could attempt brute-force login attempts, exploit misconfigurations, or access sensitive business data. Keeping Redshift clusters within private subnets and restricting access via security groups, VPC settings, and IAM policies ensures that only trusted networks and users can connect, reducing the attack surface and enhancing data security.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -686,7 +725,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "AWS API Gateway allows developers to create, deploy, and manage APIs that connect applications to backend services. By default, API Gateway endpoints can be publicly accessible, meaning they can be invoked from anywhere on the internet. To enhance security, API Gateway endpoints should be restricted to private networks using VPC links, private API settings, or access control mechanisms to ensure that only authorized entities can interact with the API.", "AdditionalInformation": "Publicly accessible API Gateway endpoints can expose backend services to unauthorized access, data leaks, and potential exploitation. Attackers may attempt brute-force authentication, injection attacks, or abuse API functionality if access is not properly restricted. To reduce the attack surface, API Gateway endpoints should be limited to internal use or protected with authentication, IAM permissions, WAF rules, or private VPC access to ensure only trusted users and systems can invoke the API.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -703,7 +743,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Amazon EC2 instances launched via Auto Scaling groups can automatically scale workloads based on demand. By default, instances can be assigned public IP addresses, making them accessible from the internet. To enhance security, EC2 instances in Auto Scaling group launch configurations should not have public IP addresses, ensuring they remain within a private network and are only accessible through secure channels such as bastion hosts or VPN connections.", "AdditionalInformation": "Assigning public IP addresses to Auto Scaling group instances increases the risk of unauthorized access, brute-force attacks, and potential exploitation. Publicly accessible instances can become targets for malicious actors, leading to data breaches or service disruptions. By restricting public IP addresses, organizations can enforce network segmentation, ensuring that EC2 instances are accessed securely via private networks, VPNs, or load balancers.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -720,7 +761,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "AWS Lambda functions allow running code without managing servers. Lambda supports resource-based policies that define who can invoke the function. If a Lambda function’s resource-based policy allows public access, it can be triggered by anyone on the internet, posing a significant security risk. To prevent unauthorized execution, Lambda functions should not be publicly accessible unless explicitly required and properly secured.", "AdditionalInformation": "Publicly accessible Lambda functions can be abused for unauthorized execution, leading to service disruptions, data exfiltration, or increased AWS costs due to excessive invocations. Attackers could exploit misconfigured functions to perform malicious actions, extract sensitive data, or abuse compute resources. To reduce security risks, Lambda functions should only be accessible to specific IAM roles, AWS services, or trusted accounts, enforcing least privilege access and maintaining secure function execution.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -737,7 +779,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "AWS Lambda function URLs provide a built-in HTTPS endpoint that allows functions to be invoked directly via HTTP requests. By default, Lambda function URLs can be publicly accessible, meaning anyone on the internet can invoke the function if proper access controls are not enforced. To minimize security risks, Lambda function URLs should not be publicly accessible unless explicitly required and properly restricted.", "AdditionalInformation": "Exposing Lambda function URLs to the public internet increases the risk of unauthorized access, API abuse, and potential exploitation. Attackers may invoke functions maliciously, leading to data leaks, unauthorized operations, increased costs, or denial-of-service (DoS) attacks. To enhance security, Lambda function URLs should be restricted to specific IAM roles, AWS services, or trusted clients, ensuring that only authorized users can trigger the function.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -754,7 +797,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "AWS Database Migration Service (DMS) instances facilitate data migration between databases across on-premises and cloud environments. By default, DMS instances can be configured with publicly accessible endpoints, making them reachable from the internet. To enhance security and prevent unauthorized access, DMS instances should not be publicly accessible unless explicitly required and properly secured.", "AdditionalInformation": "Publicly accessible DMS instances increase the risk of unauthorized access, data interception, and potential exploitation. Attackers could target exposed instances to steal or manipulate sensitive data during migration. Restricting public access ensures data migrations remain secure, limiting access to trusted networks, private VPCs, and authorized IAM roles, thereby reducing the attack surface and ensuring compliance with security best practices.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -771,7 +815,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "AWS DocumentDB manual cluster snapshots store backups of DocumentDB clusters, containing sensitive database information such as application data, configurations, and credentials. By default, snapshots are private, but they can be manually shared or made public, which poses a significant security risk. To prevent unauthorized access, DocumentDB manual cluster snapshots should never be publicly accessible unless explicitly required and properly secured.", "AdditionalInformation": "Publicly accessible DocumentDB snapshots expose critical database information, increasing the risk of data breaches, unauthorized access, and compliance violations. Attackers could restore the snapshot in their own AWS account and gain full access to the database content. To protect sensitive data, DocumentDB snapshots should only be shared with specific AWS accounts or remain private, following least privilege principles and AWS security best practices.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -788,7 +833,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Amazon EC2 Amazon Machine Images (AMIs) contain pre-configured operating system and application environments that can be used to launch new EC2 instances. By default, AMIs are private, but they can be manually shared or made public, which poses a security risk if sensitive data or proprietary configurations are exposed. To prevent unauthorized access and data leaks, EC2 AMIs should not be set as public unless explicitly required and properly secured.", "AdditionalInformation": "Publicly accessible EC2 AMIs increase the risk of data exposure, unauthorized access, and compliance violations. Attackers could copy, analyze, or exploit public AMIs to extract sensitive credentials, misconfigurations, or proprietary software. Keeping AMIs private or shared only with specific AWS accounts ensures that only trusted users or teams can access and launch instances from them, reducing security risks and preventing unintended data exposure.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -805,7 +851,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Amazon Elastic Block Store (EBS) snapshots are backups of EC2 volumes that may contain sensitive data, such as credentials, application configurations, and customer records. By default, EBS snapshots are private, but they can be manually shared or made public, allowing anyone to copy or restore them. To prevent unauthorized access and data exposure, public access to EBS snapshots should always be disabled.", "AdditionalInformation": "Publicly accessible EBS snapshots pose a significant security risk, as attackers can restore and extract sensitive data if a snapshot is exposed. Misconfigured public snapshots have led to data breaches and compliance violations in the past. To mitigate this risk, EBS snapshots should be kept private or explicitly shared only with trusted AWS accounts, following least privilege principles to protect critical data and maintain security compliance.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -838,7 +885,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "Amazon EC2 instances can run various services that communicate over common ports such as 22 (SSH), 3389 (RDP), 80 (HTTP), and 443 (HTTPS) (and more). If these ports are open to the internet, attackers can attempt unauthorized access, brute-force attacks, or exploit known vulnerabilities. To reduce security risks, EC2 instances should be configured so that common ports are not exposed to the public internet, unless explicitly required and properly secured.", "AdditionalInformation": "Exposing common ports directly to the internet increases the attack surface and risks unauthorized access or system compromise. Attackers frequently scan for open ports to target misconfigured or unpatched services. To enhance security, access to EC2 common ports should be restricted using security groups, network ACLs, and VPC configurations, ensuring that only trusted networks and users can connect.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -869,7 +917,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "Amazon EC2 security groups act as virtual firewalls, controlling inbound and outbound traffic to instances. If a security group allows ingress (incoming traffic) from the internet (0.0.0.0/0 or ::/0) to common ports such as 22 (SSH), 3389 (RDP), 80 (HTTP), or 443 (HTTPS) (and more), it creates a significant security risk. To minimize exposure, security groups should be configured to restrict ingress access to these ports to only trusted IP addresses or internal networks.", "AdditionalInformation": "Allowing unrestricted inbound traffic to common ports increases the risk of brute-force attacks, unauthorized access, and exploitation of vulnerabilities. Attackers actively scan for open ports on public-facing EC2 instances to gain unauthorized control. To reduce security risks, ingress rules should be restricted using least privilege principles, IP whitelisting, VPN access, or bastion hosts, ensuring that only authorized users and networks can connect.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -886,7 +935,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Amazon Elastic Container Registry (ECR) repositories store and manage container images for deployment in AWS services. By default, ECR repositories are private, but they can be manually configured as public, allowing anyone to pull container images. To prevent unauthorized access and potential security risks, ECR repositories should not be set as public unless explicitly required and properly secured.", "AdditionalInformation": "Publicly accessible ECR repositories expose container images to unauthorized users, increasing the risk of intellectual property theft, malware injection, or unauthorized use of containerized applications. Attackers could analyze public images for vulnerabilities or use misconfigured images for malicious purposes. To mitigate this risk, ECR repositories should remain private or be explicitly shared with trusted AWS accounts, ensuring secure access and compliance with best practices.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -903,7 +953,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Amazon Elastic Container Service (ECS) allows running containerized applications on AWS. By default, ECS services can be configured to assign public IP addresses to tasks or services, making them directly accessible from the internet. To enhance security, ECS services should be configured not to automatically assign public IPs, ensuring they remain within a private network and are accessed securely through internal load balancers, VPC peering, or private endpoints.", "AdditionalInformation": "Automatically assigning public IPs to ECS services exposes them to the internet, increasing the risk of unauthorized access, brute-force attacks, and data breaches. Attackers could target publicly exposed containers, exploit vulnerabilities, or disrupt services. To mitigate these risks, ECS services should be restricted to private subnets and accessed through secure networking configurations, such as AWS PrivateLink, VPNs, or internal ALBs.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -920,7 +971,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Amazon Elastic Container Service (ECS) task sets manage multiple versions of a service during deployments. By default, ECS task sets can be configured to automatically assign public IP addresses, making them directly accessible from the internet. To enhance security, ECS task sets should be restricted to private subnets and should not automatically receive public IP addresses unless explicitly required and properly secured.", "AdditionalInformation": "Automatically assigning public IPs to ECS task sets increases the risk of unauthorized access, cyberattacks, and data exposure. Publicly exposed tasks can be targeted by attackers, leading to service disruptions or exploitation of vulnerabilities. To mitigate these risks, ECS task sets should be restricted to private networking environments, accessed only through internal load balancers, VPC endpoints, or secure VPN connections, ensuring controlled and secure communication.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -937,7 +989,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Amazon Elastic File System (EFS) provides scalable, shared file storage for AWS services. EFS mount targets allow instances to connect to the file system within a VPC. By default, EFS mount targets can be configured with public accessibility, making them reachable from the internet. To enhance security, EFS mount targets should be restricted to private networks and should not be publicly accessible unless explicitly required and properly secured.", "AdditionalInformation": "Publicly accessible EFS mount targets expose stored data to unauthorized access, cyberattacks, and data breaches. Attackers could exploit misconfigured security groups or network ACLs to access or modify files. To reduce security risks, EFS mount targets should be restricted to private subnets, with access limited to trusted VPCs, security groups, and IAM roles, ensuring secure file storage and controlled access.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -954,7 +1007,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Amazon Elastic File System (EFS) provides shared storage that can be accessed by multiple EC2 instances and services within a VPC. EFS access is controlled through resource-based policies that define which clients can connect. If an EFS policy allows access to any client within the VPC, it increases the risk of unauthorized access and data exposure. To enhance security, EFS policies should be restricted to specific IAM roles, security groups, or trusted resources instead of granting broad access to all VPC clients.", "AdditionalInformation": "Allowing any client within a VPC to access an EFS file system increases the risk of data leaks, accidental modifications, or unauthorized access by compromised instances or misconfigured services. To minimize exposure, EFS policies should enforce least privilege access, restricting permissions to specific instances, roles, or users that require access, ensuring secure file storage and controlled data access.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -971,7 +1025,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "A Network Policy defines how network traffic is controlled and restricted between workloads within a cloud environment. Enforcing network policies ensures that only authorized communication occurs between services, reducing the risk of unauthorized access and lateral movement. It is recommended to enable Network Policies and configure them appropriately to enforce least privilege access and secure communication between workloads.", "AdditionalInformation": "Without properly configured Network Policies, workloads may be exposed to unnecessary or unauthorized network traffic, increasing the risk of data leaks, exploitation, or lateral movement by attackers. By enabling and enforcing Network Policies, organizations can limit communication between workloads, ensuring that only approved and necessary network interactions are allowed, minimizing the attack surface and enhancing overall security.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -988,7 +1043,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "Amazon Elastic Kubernetes Service (EKS) clusters manage containerized applications and can be configured with either private or public access. If an EKS cluster is publicly accessible, it means that the Kubernetes API endpoint can be reached from the internet, increasing the risk of unauthorized access and attacks. To enhance security, EKS clusters should be restricted to private networks and accessed only through secure VPNs, VPC peering, or AWS PrivateLink.", "AdditionalInformation": "Exposing an EKS cluster to the public internet increases the risk of brute-force attacks, credential theft, and unauthorized access to Kubernetes workloads. Attackers could exploit misconfigured RBAC policies or API vulnerabilities to gain control over the cluster. To reduce security risks, EKS clusters should be configured with private endpoints, ensuring that only trusted networks and IAM-authenticated users can manage Kubernetes resources.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1005,7 +1061,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "Amazon Elastic Kubernetes Service (EKS) clusters run workloads on worker nodes, which can be either public or private. If EKS clusters are created with public nodes, these nodes are assigned public IP addresses, making them accessible from the internet, which increases the risk of unauthorized access and potential attacks. To enhance security, EKS clusters should be created with private nodes that operate within private subnets and are only accessible through secured networking configurations such as VPNs, VPC peering, or AWS PrivateLink.", "AdditionalInformation": "Using public nodes in EKS exposes Kubernetes workloads to the internet, increasing the risk of unauthorized access, lateral movement, and potential exploitation. Attackers can target misconfigured workloads, open services, or unsecured API endpoints. By creating EKS clusters with private nodes, organizations can restrict access, limit exposure to public threats, and enforce network segmentation, ensuring that workloads remain secure and isolated within a private VPC environment.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1022,7 +1079,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Amazon ElastiCache provides in-memory caching services using Redis and Memcached. By default, ElastiCache clusters can be deployed in either public or private subnets. If an ElastiCache cluster is placed in a public subnet, it becomes accessible from the internet, which significantly increases the risk of unauthorized access and data breaches. To enhance security, ElastiCache clusters should only be deployed in private subnets, ensuring restricted access within a VPC.", "AdditionalInformation": "Deploying an ElastiCache cluster in a public subnet exposes it to external threats, such as unauthorized access, brute-force attacks, and potential data exfiltration. Attackers could exploit misconfigurations to access cached data or disrupt services. By restricting ElastiCache clusters to private subnets, organizations can limit access to trusted resources, enforce VPC security controls, and reduce the attack surface, ensuring secure and efficient caching operations.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1039,7 +1097,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Amazon Elastic Load Balancers (ELBs) distribute incoming traffic across multiple targets, such as EC2 instances, containers, and Lambda functions. By default, ELBs can be configured as either internet-facing or internal (private). If an ELB is publicly accessible, it exposes backend services to the internet, increasing the risk of unauthorized access and attacks. To enhance security, ELBs should be restricted to private networks unless explicitly required and properly secured.", "AdditionalInformation": "Publicly accessible Elastic Load Balancers can serve as entry points for unauthorized traffic, brute-force attacks, and potential data breaches. Attackers may exploit misconfigured security groups, open ports, or exposed application endpoints behind the load balancer. To reduce security risks, ELBs should be configured as internal (private), allowing access only from trusted networks, VPNs, or specific VPCs, ensuring that backend services remain protected and isolated from external threats.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1056,7 +1115,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Amazon Elastic MapReduce (EMR) is a managed big data processing service that can access S3, EC2, and other AWS resources. The EMR Account Public Access Block setting helps prevent public access to EMR resources, such as data stored in S3 buckets. If this setting is not enabled, there is a risk that EMR-related data and configurations could be exposed to the public, leading to unauthorized access or data breaches. To enhance security, the Public Access Block should be enabled for the EMR account.", "AdditionalInformation": "Allowing public access to EMR resources increases the risk of data leaks, unauthorized access, and compliance violations. Attackers could exploit misconfigured policies or publicly accessible S3 buckets to access sensitive data processed by EMR. Enabling EMR Account Public Access Block ensures that S3 data and other EMR-related resources cannot be accessed publicly, reducing exposure and maintaining strong access controls in AWS.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1073,7 +1133,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Amazon Elastic MapReduce (EMR) is a managed service for processing big data workloads using Apache Spark, Hadoop, and other frameworks. By default, EMR clusters can be configured with public or private access. If an EMR cluster is publicly accessible, it exposes data processing nodes and services to the internet, increasing the risk of unauthorized access and potential exploitation. To enhance security, EMR clusters should only be deployed in private subnets and restricted to trusted networks.", "AdditionalInformation": "Publicly accessible EMR clusters increase the risk of data breaches, unauthorized access, and attacks on running workloads. Malicious actors could exploit misconfigured security groups, open ports, or weak authentication settings to compromise the cluster. To reduce exposure, EMR clusters should be placed in private subnets, restricted using VPC security controls, IAM permissions, and firewall rules, ensuring secure data processing and access management.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1090,7 +1151,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "AWS EventBridge is a serverless event bus service that enables communication between AWS services, third-party applications, and custom event sources. By default, EventBridge event buses can be configured to allow events from any AWS account or external source. If an event bus is exposed to everyone, unauthorized entities could send events to your environment, potentially leading to security risks, data injection attacks, or service disruptions. To enhance security, event buses should be restricted to specific AWS accounts, services, or trusted IAM roles.", "AdditionalInformation": "Allowing unrestricted access to an EventBridge event bus increases the risk of malicious event injection, unauthorized access, and data manipulation. Attackers could flood the event bus with malicious events, leading to unexpected behavior, security breaches, or excessive AWS costs. To reduce exposure, event buses should be secured using IAM policies and resource-based permissions, ensuring that only trusted AWS services and accounts can send or receive events.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1107,7 +1169,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Amazon S3 Glacier provides low-cost, long-term storage for archival data. Glacier vaults can be configured with resource-based policies that control access. If a Glacier vault policy allows access to everyone, unauthorized users could retrieve or delete archived data, leading to data exposure or loss. To enhance security, Glacier vault policies should be restricted to specific AWS accounts, IAM roles, or trusted entities, ensuring only authorized users can access or manage archived data.", "AdditionalInformation": "Allowing public access to S3 Glacier vaults poses a significant security risk, increasing the chance of data breaches, unauthorized deletions, or compliance violations. Attackers could restore and download sensitive archived data if the vault is misconfigured. To prevent unauthorized access, Glacier vaults should have strict access controls, using IAM policies, encryption, and resource-based permissions, ensuring that only trusted users and systems can interact with archived data.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -1124,7 +1187,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "AWS Glue Data Catalog is a centralized metadata repository used to store and manage schema information for data lakes and analytics workflows. By default, Glue Data Catalogs can be configured to allow public access, which poses a significant security risk if sensitive metadata is exposed. To enhance security, Glue Data Catalogs should be restricted to specific AWS accounts, IAM roles, or trusted services, ensuring that only authorized users can access or modify catalog information.", "AdditionalInformation": "Allowing public access to Glue Data Catalogs increases the risk of unauthorized access, data leaks, and compliance violations. Attackers could gain insights into an organization’s data structure or modify catalog entries, leading to potential data corruption or unauthorized data exposure. To reduce security risks, Glue Data Catalogs should be secured using IAM policies, resource-based permissions, and AWS Lake Formation, ensuring that only trusted accounts and services can interact with metadata.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1141,7 +1205,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Amazon Managed Streaming for Apache Kafka (MSK) allows organizations to build and manage real-time data streaming applications. If a Kafka cluster is publicly accessible, it exposes data streams, configurations, and messaging topics to the internet, increasing the risk of unauthorized access, data interception, and service disruptions. To enhance security, Kafka clusters should be restricted to private networks, ensuring that only trusted AWS resources, VPCs, and IAM-authenticated users can interact with the service.", "AdditionalInformation": "Exposing a Kafka cluster to the public internet creates significant security risks, including unauthorized data ingestion, data leaks, and message tampering. Attackers could consume, modify, or inject malicious data into Kafka topics, disrupting real-time analytics and application workflows. To mitigate these risks, Kafka clusters should be deployed in private subnets, with access restricted via VPC security groups, IAM policies, and AWS PrivateLink, ensuring secure and controlled data streaming.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1158,7 +1223,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "AWS Key Management Service (KMS) provides secure encryption key management for data encryption and cryptographic operations. If KMS keys are exposed to the internet, unauthorized entities could potentially use, modify, or compromise encryption keys, leading to data breaches and security vulnerabilities. To enhance security, KMS keys should be restricted to trusted AWS accounts, IAM roles, and specific AWS services, ensuring that only authorized users and systems can access and manage them.", "AdditionalInformation": "Exposing KMS keys to the public poses a critical security risk, as compromised keys can lead to unauthorized data decryption, loss of data integrity, and compliance violations. Attackers could potentially use public KMS keys to encrypt or decrypt sensitive data, undermining security controls. To prevent unauthorized access, KMS key policies should enforce strict access control using IAM permissions, VPC endpoint policies, and AWS PrivateLink, ensuring that encryption operations remain fully secured and isolated from the public internet.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1175,7 +1241,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "AWS Lightsail Databases provide managed database solutions for applications. If a Lightsail database is set to public mode, it is directly accessible from the internet, increasing the risk of unauthorized access and data breaches. To enhance security, Lightsail databases should be configured in private mode, ensuring they are accessible only from trusted instances, private networks, or VPN connections.", "AdditionalInformation": "Publicly accessible Lightsail databases expose sensitive data to unauthorized access, brute-force attacks, and potential exploitation. Attackers can attempt to compromise credentials, inject malicious queries, or exfiltrate data. To mitigate these risks, Lightsail databases should remain private, with access controlled through firewalls, IAM authentication, and private networking configurations, ensuring secure database connectivity and data protection.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1192,7 +1259,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "AWS Lightsail instances provide a simple way to deploy and manage cloud-based virtual machines. If a Lightsail instance is publicly accessible, it can be directly reached from the internet, increasing the risk of unauthorized access, attacks, and data breaches. To enhance security, Lightsail instances should be restricted to private access, ensuring they are reachable only through secure connections, such as VPNs, bastion hosts, or private networking configurations.", "AdditionalInformation": "Publicly exposed Lightsail instances create a larger attack surface, making them vulnerable to brute-force attacks, unauthorized access, and exploitation of software vulnerabilities. Attackers could compromise credentials, gain control over the instance, or disrupt services. To mitigate these risks, Lightsail instances should be secured using firewalls, private IP configurations, security group restrictions, and IAM-based access controls, ensuring that only trusted users and networks can connect.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1209,7 +1277,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "AWS MQ brokers manage message queues for applications, facilitating secure and reliable communication between distributed services. If an MQ broker is publicly accessible, it can be reached from the internet, increasing the risk of unauthorized access, message interception, and data breaches. To enhance security, MQ brokers should be restricted to private networks, ensuring they are accessible only from trusted VPCs, private endpoints, or secure VPN connections.", "AdditionalInformation": "Publicly exposed MQ brokers pose a significant security risk, as attackers can attempt to intercept messages, inject malicious data, or disrupt message delivery. This could lead to data manipulation, unauthorized access to sensitive information, and system-wide outages. To mitigate these risks, MQ brokers should be configured within private subnets, with access restricted using security groups, IAM policies, and VPC endpoint controls, ensuring secure and controlled message queue operations.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1226,7 +1295,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Amazon NeptuneDB manual cluster snapshots store backups of graph database clusters, containing sensitive data such as relationships, metadata, and application configurations. By default, NeptuneDB snapshots are private, but they can be manually shared or made public, which can expose critical database information. To enhance security, NeptuneDB manual snapshots should never be publicly accessible, ensuring they are only shared with trusted AWS accounts when necessary.", "AdditionalInformation": "Publicly accessible NeptuneDB snapshots pose a significant security risk, as attackers could restore the snapshot in their own AWS account and gain full access to the database contents. This could lead to data leaks, compliance violations, and unauthorized access to sensitive business information. To prevent data exposure, NeptuneDB snapshots should be restricted using IAM policies and AWS resource-based permissions, ensuring that only authorized users and services can access and manage database backups securely.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -1243,7 +1313,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Amazon Neptune clusters provide a fully managed graph database service designed for applications requiring complex relationship queries. By default, Neptune clusters can be deployed in either public or private subnets. If a Neptune cluster is placed in a public subnet, it becomes accessible from the internet, significantly increasing the risk of unauthorized access and data breaches. To enhance security, Neptune clusters should only be deployed in private subnets, ensuring access is restricted to trusted VPCs, IAM roles, and security group configurations.", "AdditionalInformation": "Deploying a Neptune cluster in a public subnet exposes the database endpoints to external threats, making them vulnerable to brute-force attacks, unauthorized queries, and data exfiltration. Attackers could exploit misconfigurations to gain access to sensitive graph data, leading to potential compliance violations and security incidents. To reduce exposure, Neptune clusters should be restricted to private subnets, with access controlled through VPC security groups, IAM authentication, and private endpoint configurations, ensuring secure database operations and protected data access.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1260,7 +1331,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Amazon OpenSearch (formerly Elasticsearch) domains provide search, analytics, and log management capabilities for applications. If an OpenSearch/Elasticsearch domain is publicly accessible, it can be reached from the internet, exposing sensitive data and administrative controls to unauthorized users. To enhance security, OpenSearch domains should be restricted to private networks, ensuring access is limited to trusted VPCs, IAM roles, or specific security group rules.", "AdditionalInformation": "Publicly accessible OpenSearch/Elasticsearch domains pose a significant security risk, as attackers could execute unauthorized queries, modify data, or gain administrative control over the cluster. This could lead to data breaches, service disruptions, and compliance violations. To mitigate these risks, OpenSearch domains should be deployed in private subnets, with access controlled using VPC restrictions, fine-grained access control (FGAC), IAM policies, and security group rules, ensuring secure and isolated search and analytics operations.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -1277,7 +1349,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Amazon S3 buckets store and manage data, files, and application assets. Bucket policies control access permissions, and if an S3 bucket has a policy that allows WRITE access to everyone, unauthorized users can upload, modify, or delete objects, leading to data tampering, security breaches, or service disruptions. To enhance security, S3 bucket policies should be restricted to specific AWS accounts, IAM roles, or trusted services, ensuring only authorized users have WRITE permissions.", "AdditionalInformation": "Allowing unrestricted WRITE access to an S3 bucket increases the risk of unauthorized modifications, data injection attacks, and accidental data loss. Attackers could upload malicious files, delete critical data, or overwrite important configurations. To prevent unauthorized changes, S3 bucket policies should explicitly deny public WRITE access, enforce least privilege access control, and use AWS Block Public Access settings to ensure secure and controlled data storage.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1294,7 +1367,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Amazon S3 buckets store sensitive data and should have restricted access permissions. If an S3 bucket is listable by Everyone or Any AWS customer, unauthorized users can enumerate the objects within the bucket, potentially exposing sensitive information such as filenames, metadata, or even public datasets. To enhance security, S3 bucket permissions should be configured to restrict LIST access to only authorized IAM roles, AWS accounts, or specific services.", "AdditionalInformation": "Allowing public or AWS-wide LIST access increases the risk of data enumeration, unauthorized access, and information leaks. Attackers or unauthorized users could identify and analyze stored files, extract metadata, or infer sensitive data. To mitigate this risk, S3 bucket policies should explicitly deny public LIST access, enforce least privilege permissions, and use AWS Block Public Access settings to prevent unintended data exposure.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1311,7 +1385,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Amazon S3 buckets should have strict access controls to prevent unauthorized modifications. If an S3 bucket is writable by Everyone or Any AWS customer, it allows unauthorized users to upload, modify, or delete objects, leading to data corruption, security breaches, and compliance risks. To enhance security, S3 bucket permissions should be restricted to trusted IAM roles, AWS accounts, or specific services.", "AdditionalInformation": "Allowing public or AWS-wide WRITE access creates a significant security risk, as attackers can inject malicious files, overwrite critical data, or delete essential objects. This could lead to data loss, malware distribution, or unauthorized system modifications. To prevent unauthorized changes, S3 bucket policies should explicitly deny public WRITE access, enforce least privilege access, and use AWS Block Public Access settings to secure data integrity and prevent unauthorized modifications.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1328,7 +1403,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Amazon SageMaker Notebook instances provide an interactive environment for machine learning development and data analysis. By default, these instances can be configured with direct internet access, which increases the risk of unauthorized access, data leaks, and exposure to malicious external threats. To enhance security, SageMaker Notebook instances should be restricted to private networks, ensuring they are accessed only through secure VPC connections, IAM authentication, or VPNs.", "AdditionalInformation": "Allowing direct internet access to SageMaker Notebook instances poses a significant security risk, as attackers could exploit misconfigurations, exfiltrate data, or inject malicious code. Publicly accessible notebooks can lead to data breaches, intellectual property theft, or compromised model training workflows. To mitigate these risks, SageMaker Notebook instances should be configured within private subnets, with internet access disabled, and restricted using security groups, IAM policies, and VPC endpoint configurations to ensure secure and controlled machine learning operations.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1345,7 +1421,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "AWS Secrets Manager is used to securely store and manage sensitive information, such as API keys, database credentials, and encryption keys. By default, Secrets Manager secrets should be restricted to authorized IAM roles and AWS services. If a secret is publicly accessible, it can be exposed to unauthorized users, leading to data leaks, security breaches, and potential exploitation of sensitive credentials. To enhance security, Secrets Manager secrets should be strictly controlled using IAM policies and resource-based permissions.", "AdditionalInformation": "Allowing public access to Secrets Manager secrets creates a critical security vulnerability, as attackers could retrieve, misuse, or exfiltrate sensitive information. Compromised secrets could lead to unauthorized access to databases, applications, or cloud services, resulting in data breaches, financial loss, or compliance violations. To mitigate this risk, Secrets Manager secrets should be restricted using least privilege IAM permissions, encrypted with AWS KMS, and accessed only by trusted AWS services and roles, ensuring secure and controlled secret management.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1362,7 +1439,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Amazon Simple Email Service (SES) identities (such as email addresses or domains) are used to send and receive emails through AWS. By default, SES identities should be restricted to authorized AWS accounts and IAM roles. If an SES identity is publicly accessible, unauthorized users could send emails using the identity, leading to email spoofing, phishing attacks, or misuse of the domain for malicious purposes. To enhance security, SES identities should be properly restricted using IAM policies and verified senders.", "AdditionalInformation": "Allowing public access to SES identities creates a security and reputational risk, as attackers could impersonate the identity, send spam, or launch phishing campaigns. This could lead to domain blacklisting, compliance violations, and damage to the organization’s email reputation. To mitigate these risks, SES identities should be restricted to trusted AWS accounts and IAM roles, ensuring that only authorized services and users can send emails, protecting the integrity and security of email communications.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1379,7 +1457,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Amazon Simple Queue Service (SQS) queues enable asynchronous message processing between distributed systems. By default, SQS queues should be restricted to authorized AWS accounts and IAM roles. If an SQS queue has a public policy, it allows anyone on the internet to send, receive, or delete messages, leading to data leaks, unauthorized message injection, and potential denial-of-service (DoS) attacks. To enhance security, SQS queue policies should be configured to allow access only to trusted AWS accounts, IAM roles, or specific AWS services.", "AdditionalInformation": "Publicly accessible SQS queues pose a significant security risk, as attackers could inject malicious messages, disrupt processing workflows, or delete critical messages, leading to system failures and data integrity issues. To prevent unauthorized access, SQS policies should explicitly deny public access, enforce least privilege access control, and use IAM policies and VPC endpoint restrictions to ensure secure and controlled messaging operations.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -1396,7 +1475,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "AWS Systems Manager (SSM) Documents define configuration, automation, and maintenance tasks for AWS resources. By default, SSM documents should be restricted to specific AWS accounts, IAM roles, or AWS services. If an SSM document is set as public, unauthorized users could access, modify, or execute automation tasks on AWS infrastructure, leading to misconfigurations, security breaches, or unintended system modifications. To enhance security, SSM documents should be kept private and assigned only to trusted AWS entities.", "AdditionalInformation": "Publicly accessible SSM documents pose a significant security risk, as attackers could execute malicious commands, modify system configurations, or disrupt AWS operations. This could lead to unauthorized access, data leaks, compliance violations, or system downtime. To prevent security threats, SSM documents should explicitly deny public access, enforce least privilege permissions, and use IAM policies and resource-based access controls to ensure only trusted users and systems can manage AWS resources.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1413,7 +1493,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "AWS CloudTrail is a service that records and monitors AWS API calls across an account, providing detailed logs of who performed what action, when, and from where. CloudTrail captures API activity from the AWS Management Console, SDKs, CLI, and AWS services such as CloudFormation. The logs include key details such as the identity of the API caller, timestamp, source IP address, request parameters, and response elements.", "AdditionalInformation": "CloudTrail enhances security, auditing, and compliance by providing a complete history of API activities in an AWS account. Enabling a multi-region trail ensures: Detection of unauthorized activity in rarely used AWS regions. Global Service Logging is automatically enabled, capturing API calls from global services such as IAM and AWS Organizations. Tracking of all management events, ensuring that both read and write operations across AWS resources are recorded for improved security monitoring and compliance.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1430,7 +1511,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "AWS CloudTrail log file validation generates digitally signed digest files containing cryptographic hashes of each log file stored in Amazon S3. These digest files allow users to verify whether logs have been altered, deleted, or remain unchanged after being delivered by CloudTrail. Enabling log file validation ensures data integrity and auditability for security and compliance purposes.", "AdditionalInformation": "Enabling log file validation enhances security by ensuring the integrity of CloudTrail logs, preventing tampering or unauthorized modifications. This helps: Detect log file alterations, ensuring logs remain trustworthy for audits and investigations. Improve compliance with frameworks that require log integrity, such as PCI DSS, SOC 2, and ISO 27001. Strengthen forensic capabilities, allowing security teams to verify log authenticity in case of a security incident.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1447,7 +1529,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "AWS Config is a service that continuously monitors, records, and evaluates configuration changes in AWS resources within an account. It tracks configuration items, relationships between resources, and changes over time, delivering logs for security analysis, change management, and compliance auditing. To ensure comprehensive monitoring, AWS Config should be enabled in all regions.", "AdditionalInformation": "Enabling AWS Config in all regions improves security, visibility, and compliance by: Tracking resource changes, allowing for quick identification of misconfigurations. Supporting security audits and forensic investigations by maintaining a historical record of configurations.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1464,7 +1547,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "Server access logging provides detailed records of requests made to an S3 bucket, including request type, accessed resources, timestamp, and requester details. Enabling server access logging on the CloudTrail S3 bucket ensures that all interactions with CloudTrail logs are recorded, improving security visibility and auditability", "AdditionalInformation": "Enabling server access logging on CloudTrail S3 buckets enhances security monitoring, incident response, and compliance by: Capturing all events affecting CloudTrail logs, helping detect unauthorized access or modifications. Providing an audit trail for forensic investigations and compliance reporting. Enhancing security workflows by storing access logs in a separate, dedicated logging bucket for improved log integrity and analysis.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1481,7 +1565,8 @@ "SubSection": "4.2 At-Rest", "AttributeDescription": "AWS CloudTrail records API activity across an AWS account, and its logs contain sensitive security and operational data. AWS Key Management Service (KMS) provides encryption key management using customer-managed keys (CMKs) and Hardware Security Modules (HSMs) to ensure secure key storage and usage. CloudTrail logs can be encrypted using Server-Side Encryption (SSE) with KMS (SSE-KMS) to add an extra layer of protection and access control", "AdditionalInformation": "Using SSE-KMS encryption for CloudTrail logs enhances security by adding an extra layer of access control. This ensures that only authorized users with both S3 read permissions and KMS decryption rights can access log data, protecting sensitive security information from unauthorized access or tampering. It also helps maintain compliance with security and regulatory standards by enforcing strict encryption controls.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1498,7 +1583,8 @@ "SubSection": "3.2 Retention", "AttributeDescription": "AWS Key Management Service (KMS) allows users to manage encryption keys securely. Key rotation enables the automatic replacement of the backing key (the cryptographic material tied to a customer-managed key (CMK)), ensuring continuous security without disrupting access to previously encrypted data. AWS automatically retains previous backing keys to allow seamless decryption of older data while using a newly generated key for encryption. It is recommended to enable key rotation for symmetric CMKs, as asymmetric keys do not support this feature.", "AdditionalInformation": "Regularly rotating encryption keys minimizes the risk associated with key compromise by ensuring that newly encrypted data is protected with a fresh key, reducing the potential impact of an exposed key. Since AWS KMS retains prior backing keys for seamless decryption, rotation does not disrupt access to previously encrypted data. Implementing key rotation enhances security by limiting the exposure window of any single encryption key and aligning with best practices for cryptographic hygiene.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1515,7 +1601,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "VPC Flow Logs capture and record IP traffic information for network interfaces within a VPC, allowing administrators to monitor and analyze network activity. These logs are stored in Amazon CloudWatch Logs for retrieval and analysis. It is recommended to enable VPC Flow Logs for rejected packets to track unauthorized access attempts, misconfigurations, or potential security threats within the VPC.", "AdditionalInformation": "Enabling VPC Flow Logs for rejected traffic enhances network visibility and security monitoring by detecting suspicious activity, failed connection attempts, and potential threats. These logs help identify anomalous traffic patterns, troubleshoot connectivity issues, and support incident response workflows, improving overall security posture.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1532,7 +1619,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "S3 object-level API operations, such as GetObject, PutObject, and DeleteObject, are classified as data events in AWS CloudTrail. By default, CloudTrail does not log data events, meaning detailed tracking of individual object interactions is not enabled. To enhance visibility and security, it is recommended to enable object-level logging for S3 buckets to monitor access and modification activities.", "AdditionalInformation": "Enabling object-level logging helps organizations meet compliance requirements, enhance security monitoring, and detect unauthorized access. It allows administrators to analyze user behavior, track modifications to critical data, and respond to security incidents in real time using Amazon CloudWatch Events, ensuring greater control over S3 bucket activity.", - "LevelOfRisk": 1 + "LevelOfRisk": 1, + "Weight": 1 } ] }, @@ -1549,7 +1637,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "S3 object-level API operations, such as GetObject, PutObject, and DeleteObject, are classified as data events in AWS CloudTrail. By default, CloudTrail does not log data events, meaning individual object interactions are not tracked unless explicitly enabled. To improve security monitoring and compliance, it is recommended to enable object-level logging for S3 buckets.", "AdditionalInformation": "Object-level logging enhances data security, compliance, and operational visibility by providing detailed tracking of who accessed, modified, or deleted objects within S3 buckets. This enables organizations to monitor user behavior, detect unauthorized access, and quickly respond to potential security incidents using Amazon CloudWatch Events.", - "LevelOfRisk": 1 + "LevelOfRisk": 1, + "Weight": 1 } ] }, @@ -1566,7 +1655,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Real-time monitoring of API calls can be achieved by forwarding CloudTrail logs to Amazon CloudWatch Logs or an external Security Information and Event Management (SIEM) system. By configuring metric filters and alarms, organizations can automatically detect and respond to unauthorized API calls, improving security visibility. It is recommended to establish a metric filter and alarm for unauthorized API calls to enhance threat detection and incident response.", "AdditionalInformation": "Monitoring unauthorized API calls helps identify potential security incidents faster, reducing the time attackers have to exploit vulnerabilities. CloudWatch provides real-time monitoring and alerting, while SIEM solutions offer centralized security event analysis. Detecting unauthorized API calls early allows organizations to take immediate action, investigate potential threats, and strengthen overall AWS security posture.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1583,7 +1673,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Real-time monitoring of AWS API calls can be implemented by directing CloudTrail logs to Amazon CloudWatch Logs or an external Security Information and Event Management (SIEM) system. By setting up metric filters and alarms, organizations can detect and respond to security risks effectively. It is recommended to establish a metric filter and alarm for AWS console logins that are not protected by multi-factor authentication (MFA) to enhance security monitoring.", "AdditionalInformation": "Monitoring console logins without MFA improves visibility into accounts that lack strong authentication controls. Accounts without MFA are more vulnerable to credential theft, brute-force attacks, and unauthorized access. By detecting these login attempts in real-time, organizations can identify security gaps, enforce MFA policies, and reduce the risk of account compromise.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1600,7 +1691,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Real-time monitoring of AWS API calls can be achieved by directing CloudTrail logs to Amazon CloudWatch Logs or an external Security Information and Event Management (SIEM) system. Setting up metric filters and alarms helps detect potential security threats. It is recommended to establish a metric filter and alarm for root account login attempts to identify unauthorized access or improper use of the highly privileged root account.", "AdditionalInformation": "Monitoring root account logins enhances visibility into the usage of the most privileged AWS account, which should be used only in exceptional cases. Frequent or unauthorized root logins increase security risks by exposing critical administrative controls. Detecting root login attempts in real time enables organizations to identify potential security incidents, enforce least privilege principles, and limit unnecessary use of the root account.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1617,7 +1709,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Real-time monitoring of AWS API calls can be implemented by directing CloudTrail logs to Amazon CloudWatch Logs or an external Security Information and Event Management (SIEM) system. By configuring metric filters and alarms, organizations can detect critical security events. It is recommended to establish a metric filter and alarm for changes to Identity and Access Management (IAM) policies to track modifications that could affect authentication and authorization controls.", "AdditionalInformation": "Monitoring IAM policy changes helps ensure that access controls remain secure and intact. Unauthorized or unintended modifications to IAM policies can lead to privilege escalation, misconfigurations, and security breaches. Detecting these changes in real-time allows organizations to respond quickly to potential threats, enforce least privilege principles, and maintain a strong security posture.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1634,7 +1727,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Real-time monitoring of AWS API calls can be implemented by directing CloudTrail logs to Amazon CloudWatch Logs or an external Security Information and Event Management (SIEM) system. By configuring metric filters and alarms, organizations can track critical security events. It is recommended to establish a metric filter and alarm to detect changes to CloudTrail configurations, ensuring that logging remains active and tamper-proof.", "AdditionalInformation": "Monitoring CloudTrail configuration changes helps maintain continuous visibility into AWS account activity. Unauthorized modifications to CloudTrail settings could disable or alter logging, potentially allowing malicious activity to go undetected. Detecting these changes in real time enables organizations to quickly respond to threats, enforce security best practices, and ensure compliance with auditing requirements.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1651,7 +1745,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Real-time monitoring of AWS API calls can be implemented by directing CloudTrail logs to Amazon CloudWatch Logs or an external Security Information and Event Management (SIEM) system. By setting up metric filters and alarms, organizations can detect potential security threats. It is recommended to establish a metric filter and alarm for failed console authentication attempts to identify potential unauthorized access attempts or brute-force attacks.", "AdditionalInformation": "Monitoring failed console logins helps detect brute-force attempts and unauthorized access attempts early. Repeated failed authentication attempts can indicate malicious activity, and tracking them allows security teams to identify suspicious IP addresses, correlate with other security events, and take proactive measures to protect AWS accounts.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1668,7 +1763,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Real-time monitoring of AWS API calls can be achieved by forwarding CloudTrail logs to Amazon CloudWatch Logs or an external Security Information and Event Management (SIEM) system. By configuring metric filters and alarms, organizations can detect security-critical changes. It is recommended to set up a metric filter and alarm for customer-managed KMS keys (CMKs) that are disabled or scheduled for deletion to prevent unintended encryption key loss.", "AdditionalInformation": "Disabling or deleting a customer-managed CMK can render encrypted data permanently inaccessible, leading to data loss and service disruptions. Monitoring CMK state changes helps detect unauthorized or accidental modifications, ensuring encryption keys remain available and aligned with security policies. Detecting such changes in real time allows organizations to prevent data loss, maintain compliance, and take corrective action if needed.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1685,7 +1781,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Real-time monitoring of AWS API calls can be implemented by forwarding CloudTrail logs to Amazon CloudWatch Logs or an external Security Information and Event Management (SIEM) system. By configuring metric filters and alarms, organizations can track critical security-related changes. It is recommended to set up a metric filter and alarm for modifications to S3 bucket policies to detect potential misconfigurations or unauthorized access changes.", "AdditionalInformation": "Monitoring S3 bucket policy changes helps detect and respond to overly permissive configurations that could expose sensitive data. Unauthorized or accidental modifications may grant public access or excessive permissions, increasing the risk of data breaches and compliance violations. Real-time alerts allow security teams to quickly identify, investigate, and correct risky policy changes, reducing exposure and strengthening data security.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1702,7 +1799,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Real-time monitoring of AWS API calls can be implemented by directing CloudTrail logs to Amazon CloudWatch Logs or an external Security Information and Event Management (SIEM) system. By configuring metric filters and alarms, organizations can detect critical configuration changes. It is recommended to establish a metric filter and alarm for modifications to AWS Config’s configurations to ensure continuous monitoring and compliance.", "AdditionalInformation": "Monitoring AWS Config configuration changes helps maintain visibility and control over resource configurations. Unauthorized or accidental modifications to AWS Config settings may result in gaps in security monitoring, misconfigurations going undetected, and compliance violations. Real-time alerts allow security teams to quickly detect, investigate, and respond to changes, ensuring the integrity of configuration tracking across the AWS environment.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1719,7 +1817,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Real-time monitoring of AWS API calls can be implemented by forwarding CloudTrail logs to Amazon CloudWatch Logs or an external Security Information and Event Management (SIEM) system. Security groups act as stateful packet filters that control inbound and outbound traffic within a VPC. It is recommended to establish a metric filter and alarm to detect changes to security groups to prevent unauthorized modifications that could expose resources to security threats.", "AdditionalInformation": "Monitoring security group changes helps ensure that network access controls remain secure and that AWS resources are not unintentionally exposed. Unauthorized or accidental modifications to security groups can create security gaps, increasing the risk of data breaches and unauthorized access. Real-time alerts enable security teams to detect, investigate, and respond to security group changes quickly, maintaining a strong network security posture.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1736,7 +1835,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Real-time monitoring of AWS API calls can be implemented by forwarding CloudTrail logs to Amazon CloudWatch Logs or an external Security Information and Event Management (SIEM) system. Network Access Control Lists (NACLs) act as stateless packet filters that control inbound and outbound traffic for subnets within a VPC. It is recommended to establish a metric filter and alarm to detect changes to NACLs to prevent unauthorized modifications that could compromise network security.", "AdditionalInformation": "Monitoring NACL changes helps ensure that network traffic controls remain properly configured and that AWS resources are not unintentionally exposed. Unauthorized or accidental modifications to NACL rules can lead to misconfigured security policies, increased attack surfaces, and potential data breaches. Real-time alerts enable security teams to detect, investigate, and respond to NACL modifications quickly, maintaining strong network security controls.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1753,7 +1853,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Real-time monitoring of AWS API calls can be implemented by forwarding CloudTrail logs to Amazon CloudWatch Logs or an external Security Information and Event Management (SIEM) system. Network gateways serve as the primary route for traffic entering and leaving a VPC, facilitating communication with external networks. It is recommended to establish a metric filter and alarm for changes to network gateways to ensure that network traffic is securely routed through controlled paths.", "AdditionalInformation": "Monitoring network gateway changes helps maintain secure ingress and egress traffic flows within a VPC. Unauthorized or accidental modifications to network gateways can disrupt connectivity, introduce security vulnerabilities, or expose AWS resources to external threats. Real-time alerts enable security teams to detect, investigate, and respond to changes quickly, ensuring that all traffic follows a controlled and secure routing policy.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1770,7 +1871,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Real-time monitoring of AWS API calls can be achieved by forwarding CloudTrail logs to Amazon CloudWatch Logs or an external Security Information and Event Management (SIEM) system. Route tables determine how network traffic is directed between subnets and network gateways within a VPC. It is recommended to establish a metric filter and alarm for changes to route tables to detect unauthorized or accidental modifications that could impact network security and connectivity.", "AdditionalInformation": "Monitoring route table changes ensures that VPC traffic follows the intended and secure routing paths. Unauthorized modifications can result in misrouted traffic, exposure of sensitive resources, or connectivity disruptions. Real-time alerts enable security teams to detect, investigate, and respond to route table changes promptly, preventing potential security risks and maintaining a controlled and secure network environment.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1787,7 +1889,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Real-time monitoring of AWS API calls can be implemented by forwarding CloudTrail logs to Amazon CloudWatch Logs or an external Security Information and Event Management (SIEM) system. AWS accounts can contain multiple Virtual Private Clouds (VPCs), and VPC peering connections allow network traffic to flow between them. It is recommended to establish a metric filter and alarm for changes made to VPC configurations to detect unauthorized modifications that could impact network security and connectivity.", "AdditionalInformation": "Monitoring VPC configuration changes helps ensure network integrity, security, and proper traffic flow within AWS environments. Unauthorized or accidental modifications can result in misconfigured routing, unintended internet exposure, or connectivity disruptions between resources. Real-time alerts enable security teams to detect, investigate, and respond to VPC changes promptly, preventing security risks and ensuring consistent network accessibility and isolation.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1804,7 +1907,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Real-time monitoring of AWS API calls can be achieved by forwarding CloudTrail logs to Amazon CloudWatch Logs or an external Security Information and Event Management (SIEM) system. AWS Organizations allows centralized management of multiple AWS accounts, and modifications to its configuration can significantly impact access control, account governance, and security policies. It is recommended to establish a metric filter and alarm for changes made to AWS Organizations in the master AWS account to detect unauthorized or unintended modifications.", "AdditionalInformation": "Monitoring AWS Organizations configuration changes helps prevent unwanted, accidental, or malicious modifications that could lead to unauthorized access, policy misconfigurations, or security breaches. Detecting changes in real time ensures that unexpected modifications can be investigated and remediated quickly, reducing the risk of compromised governance structures and ensuring compliance with organizational security policies.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1821,7 +1925,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "AWS Security Hub centralizes security data from multiple AWS services and third-party security tools, allowing for real-time threat detection, risk assessment, and compliance monitoring. When enabled, Security Hub aggregates, organizes, and prioritizes security findings from services such as Amazon GuardDuty, Amazon Inspector, and Amazon Macie, as well as integrated third-party security products. This provides organizations with a unified security management platform to enhance threat visibility.", "AdditionalInformation": "Enabling AWS Security Hub provides a comprehensive view of your security posture, helping to identify vulnerabilities, detect threats, and enforce security best practices. It allows organizations to monitor security trends, benchmark environments against industry standards, and quickly respond to high-priority security issues, strengthening overall AWS security governance and compliance.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1838,7 +1943,8 @@ "SubSection": "3.2 Retention", "AttributeDescription": "AWS CloudWatch Log Groups store logs from various AWS services and applications, enabling monitoring, debugging, and security auditing. By default, CloudWatch logs are retained indefinitely, which can lead to unnecessary data storage costs and compliance risks. To manage log lifecycle effectively, it is recommended to set a retention policy for CloudWatch Log Groups, ensuring logs are retained only for a specific number of days based on operational and compliance requirements.", "AdditionalInformation": "Setting a retention policy for CloudWatch logs helps balance cost management, compliance, and security. Retaining logs for too long increases storage costs and potential exposure to sensitive data, while keeping them for too short a duration can limit forensic investigations and compliance reporting. By defining a specific retention period, organizations can ensure logs are available for troubleshooting and audits while adhering to data retention best practices and regulatory requirements.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] } diff --git a/prowler/compliance/azure/nis2_azure.json b/prowler/compliance/azure/nis2_azure.json new file mode 100644 index 0000000000..d3a350bfc7 --- /dev/null +++ b/prowler/compliance/azure/nis2_azure.json @@ -0,0 +1,1898 @@ +{ + "Framework": "NIS2", + "Version": "", + "Provider": "Azure", + "Description": "ANNEX to the Commission Implementing Regulation laying down rules for the application of Directive (EU) 2022/2555 as regards technical and methodological requirements of cybersecurity risk-management measures and further specification of the cases in which an incident is considered to be significant with regard to DNS service providers, TLD name registries, cloud computing service providers, data centre service providers, content delivery network providers, managed service providers, managed security service providers, providers of online market places, of online search engines and of social networking services platforms, and trust service providers", + "Requirements": [ + { + "Id": "1.1.1.a", + "Description": "set out the relevant entities approach to managing the security of their network and information systems;", + "Checks": [ + "storage_ensure_private_endpoints_in_storage_accounts" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "generic" + } + ] + }, + { + "Id": "1.1.1.c", + "Description": "set out network and information security objectives;", + "Checks": [ + "network_flow_log_more_than_90_days", + "network_http_internet_access_restricted", + "storage_ensure_private_endpoints_in_storage_accounts" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "generic" + } + ] + }, + { + "Id": "1.2.1", + "Description": "As part of their policy on the security of network and information systems referred to in point 1.1., the relevant entities shall lay down responsibilities and authorities for network and information system security and assign them to roles, allocate them according to the relevant entities needs, and communicate them to the management bodies.", + "Checks": [ + "aks_network_policy_enabled", + "network_http_internet_access_restricted", + "storage_default_network_access_rule_is_denied" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.2 Roles, responsibilities and authorities", + "Service": "generic" + } + ] + }, + { + "Id": "1.2.4", + "Description": "Depending on the size of the relevant entities, network and information system security shall be covered by dedicated roles or duties carried out in addition to existing roles.", + "Checks": [ + "sqlserver_azuread_administrator_enabled", + "sqlserver_va_emails_notifications_admins_enabled" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.2 Roles, responsibilities and authorities", + "Service": "sqlserver" + } + ] + }, + { + "Id": "2.1.2.f", + "Description": "evaluate the identified risks based on the risk criteria;", + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_rbac_key_expiration_set" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "keyvault" + } + ] + }, + { + "Id": "2.1.2.g", + "Description": "identify and prioritise appropriate risk treatment options and measures;", + "Checks": [ + "aisearch_service_not_publicly_accessible", + "aks_cluster_rbac_enabled", + "aks_clusters_created_with_private_nodes", + "aks_clusters_public_access_disabled", + "aks_network_policy_enabled", + "app_ensure_auth_is_set_up", + "app_ensure_http_is_redirected_to_https", + "app_function_access_keys_configured", + "app_function_application_insights_enabled", + "app_function_identity_is_configured", + "app_function_identity_without_admin_privileges", + "app_function_latest_runtime_version", + "app_function_not_publicly_accessible", + "app_function_vnet_integration_enabled", + "app_http_logs_enabled", + "app_minimum_tls_version_12", + "app_register_with_identity", + "containerregistry_admin_user_disabled", + "containerregistry_not_publicly_accessible", + "cosmosdb_account_firewall_use_selected_networks", + "cosmosdb_account_use_aad_and_rbac", + "cosmosdb_account_use_private_endpoints" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.h", + "Description": "continuously monitor the implementation of the risk treatment measures;", + "Checks": [ + "app_http_logs_enabled", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "keyvault_logging_enabled", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private", + "mysql_flexible_server_audit_log_connection_activated", + "mysql_flexible_server_audit_log_enabled", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_checkpoints_on", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on", + "postgresql_flexible_server_log_retention_days_greater_3" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.i", + "Description": "identify who is responsible for implementing the risk treatment measures and when they should be implemented;", + "Checks": [ + "app_function_identity_is_configured", + "app_function_identity_without_admin_privileges", + "app_register_with_identity" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "app" + } + ] + }, + { + "Id": "2.2.1", + "Description": "The relevant entities shall regularly review the compliance with their policies on network and information system security, topic-specific policies, rules, and standards. The management bodies shall be informed of the status of network and information security on the basis of the compliance reviews by means of regular reporting.", + "Checks": [ + "sqlserver_va_scan_reports_configured", + "defender_container_images_scan_enabled", + "sqlserver_va_periodic_recurring_scans_enabled" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.2 Compliance monitoring", + "Service": "generic" + } + ] + }, + { + "Id": "2.2.3", + "Description": "The relevant entities shall perform the compliance monitoring at planned intervals and when significant incidents or significant changes to operations or risks occur.", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.2 Compliance monitoring", + "Service": "monitor" + } + ] + }, + { + "Id": "2.3.1", + "Description": "The relevant entities shall review independently their approach to managing network and information system security and its implementation including people, processes and technologies.", + "Checks": [ + "entra_global_admin_in_less_than_five_users", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "entra_privileged_user_has_mfa", + "entra_security_defaults_enabled", + "entra_trusted_named_locations_exists", + "entra_user_with_vm_access_has_mfa", + "entra_users_cannot_create_microsoft_365_groups", + "aks_network_policy_enabled", + "network_bastion_host_exists", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "network_http_internet_access_restricted", + "network_public_ip_shodan", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted", + "network_watcher_enabled", + "storage_default_network_access_rule_is_denied" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.3 Independent review of information and network security", + "Service": "generic" + } + ] + }, + { + "Id": "3.1.1", + "Description": "For the purpose of Article 21(2), point (b) of Directive (EU) 2022/2555, the relevant entities shall establish and implement an incident handling policy laying down the roles, responsibilities, and procedures for detecting, analysing, containing or responding to, recovering from, documenting and reporting of incidents in a timely manner.", + "Checks": [ + "sqlserver_va_scan_reports_configured" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "sqlserver" + } + ] + }, + { + "Id": "3.1.2.c", + "Description": "assignment of roles to detect and appropriately respond to incidents to competent employees;", + "Checks": [ + "defender_ensure_notify_emails_to_owners", + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_alerts_severity_is_high" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "defender" + } + ] + }, + { + "Id": "3.1.2.d", + "Description": "documents to be used in the course of incident detection and response such as incident response manuals, escalation charts, contact lists and templates.", + "Checks": [ + "defender_auto_provisioning_log_analytics_agent_vms_on", + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "defender" + } + ] + }, + { + "Id": "3.1.3", + "Description": "The roles, responsibilities and procedures laid down in the policy shall be tested and reviewed and, where appropriate, updated at planned intervals and after significant incidents or significant changes to operations or risks.", + "Checks": [ + "defender_ensure_notify_emails_to_owners", + "defender_ensure_notify_alerts_severity_is_high", + "entra_global_admin_in_less_than_five_users", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "entra_privileged_user_has_mfa", + "entra_security_defaults_enabled", + "entra_trusted_named_locations_exists", + "entra_user_with_vm_access_has_mfa", + "entra_users_cannot_create_microsoft_365_groups" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.1", + "Description": "The relevant entities shall lay down procedures and use tools to monitor and log activities on their network and information systems to detect events that could be considered as incidents and respond accordingly to mitigate the impact.", + "Checks": [ + "app_http_logs_enabled", + "keyvault_logging_enabled", + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.2", + "Description": "To the extent feasible, monitoring shall be automated and carried out either continuously or in periodic intervals, subject to business capabilities. The relevant entities shall implement their monitoring activities in a way which minimises false positives and false negatives.", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "monitor" + } + ] + }, + { + "Id": "3.2.3.a", + "Description": "relevant outbound and inbound network traffic;", + "Checks": [ + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.b", + "Description": "creation, modification or deletion of users of the relevant entities network and information systems and extension of the permissions;", + "Checks": [ + "entra_global_admin_in_less_than_five_users", + "monitor_alert_create_policy_assignment", + "monitor_alert_delete_policy_assignment", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private", + "monitor_diagnostic_settings_exists" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.c", + "Description": "access to systems and applications;", + "Checks": [ + "app_http_logs_enabled", + "keyvault_logging_enabled", + "monitor_diagnostic_settings_exists", + "monitor_diagnostic_setting_with_appropriate_categories", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "mysql_flexible_server_audit_log_connection_activated", + "mysql_flexible_server_audit_log_enabled", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.d", + "Description": "authentication-related events;", + "Checks": [ + "network_flow_log_captured_sent", + "mysql_flexible_server_audit_log_connection_activated", + "keyvault_logging_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.e", + "Description": "all privileged access to systems and applications, and activities performed by administrative accounts;", + "Checks": [ + "app_http_logs_enabled", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "keyvault_logging_enabled", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.f", + "Description": "access or changes to critical configuration and backup files;", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr", + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "monitor" + } + ] + }, + { + "Id": "3.2.3.g", + "Description": "event logs and logs from security tools, such as antivirus, intrusion detection systems or firewalls;", + "Checks": [ + "app_http_logs_enabled", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "keyvault_logging_enabled", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private", + "mysql_flexible_server_audit_log_connection_activated", + "mysql_flexible_server_audit_log_enabled", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_checkpoints_on", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on", + "postgresql_flexible_server_log_retention_days_greater_3" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.h", + "Description": "use of system resources, as well as their performance;", + "Checks": [ + "appinsights_ensure_is_configured", + "monitor_diagnostic_settings_exists" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.4", + "Description": "The logs shall be regularly reviewed for any unusual or unwanted trends. Where appropriate, the relevant entities shall lay down appropriate values for alarm thresholds. If the laid down values for alarm threshold are exceeded, an alarm shall be triggered, where appropriate, automatically. The relevant entities shall ensure that, in case of an alarm, a qualified and appropriate response is initiated in a timely manner.", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "monitor" + } + ] + }, + { + "Id": "3.2.5", + "Description": "The relevant entities shall maintain and back up logs for a predefined period and shall protect them from unauthorised access or changes.", + "Checks": [ + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_retention_days_greater_3", + "sqlserver_auditing_retention_90_days" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.4.1", + "Description": "The relevant entities shall assess suspicious events to determine whether they constitute incidents and, if so, determine their nature and severity.", + "Checks": [ + "monitor_diagnostic_setting_with_appropriate_categories", + "monitor_diagnostic_settings_exists", + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.4 Event assessment and classification", + "Service": "monitor" + } + ] + }, + { + "Id": "3.5.1", + "Description": "The relevant entities shall respond to incidents in accordance with documented procedures and in a timely manner.", + "Checks": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_emails_to_owners", + "sqlserver_va_emails_notifications_admins_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.5 Incident response", + "Service": "generic" + } + ] + }, + { + "Id": "3.5.3.a", + "Description": "with the Computer Security Incident Response Teams (CSIRTs) or, where applicable, the competent authorities, related to incident notification;", + "Checks": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_emails_to_owners", + "sqlserver_va_emails_notifications_admins_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.5 Incident response", + "Service": "generic" + } + ] + }, + { + "Id": "3.5.4", + "Description": "The relevant entities shall log incident response activities in accordance with the procedures referred to in point 3.2.1., and record evidence.", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.5 Incident response", + "Service": "monitor" + } + ] + }, + { + "Id": "3.6.2", + "Description": "The relevant entities shall ensure that post-incident reviews contribute to improving their approach to network and information security, to risk treatment measures, and to incident handling, detection and response procedures.", + "Checks": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_assessments_vm_endpoint_protection_installed", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled", + "defender_ensure_defender_for_app_services_is_on", + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_cosmosdb_is_on", + "defender_ensure_defender_for_databases_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_os_relational_databases_is_on", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_sql_servers_is_on", + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_iot_hub_defender_is_on", + "defender_ensure_mcas_is_enabled", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners", + "defender_ensure_system_updates_are_applied", + "defender_ensure_wdatp_is_enabled", + "sqlserver_microsoft_defender_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.6 Post-incident reviews", + "Service": "defender" + } + ] + }, + { + "Id": "4.1.1", + "Description": "For the purpose of Article 21(2), point (c) of Directive (EU) 2022/2555, the relevant entities shall lay down and maintain a business continuity and disaster recovery plan to apply in the case of incidents.", + "Checks": [ + "keyvault_recoverable" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "keyvault" + } + ] + }, + { + "Id": "4.1.2.f", + "Description": "recovery plans for specific operations, including recovery objectives;", + "Checks": [ + "keyvault_recoverable" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "keyvault" + } + ] + }, + { + "Id": "4.1.2.g", + "Description": "required resources, including backups and redundancies;", + "Checks": [ + "keyvault_recoverable", + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_retention_days_greater_3", + "sqlserver_auditing_retention_90_days" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "generic" + } + ] + }, + { + "Id": "4.1.4", + "Description": "The business continuity plan and disaster recovery plan shall be tested, reviewed and, where appropriate, updated at planned intervals and following significant incidents or significant changes to operations or risks. The relevant entities shall ensure that the plans incorporate lessons learnt from such tests.", + "Checks": [ + "keyvault_recoverable", + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_retention_days_greater_3", + "sqlserver_auditing_retention_90_days" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "generic" + } + ] + }, + { + "Id": "4.2.2.b", + "Description": "assurance that backup copies are complete and accurate, including configuration data and data stored in cloud computing service environment;", + "Checks": [ + "keyvault_recoverable" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.2 Backup and redundancy management", + "Service": "keyvault" + } + ] + }, + { + "Id": "4.2.2.e", + "Description": "restoring data from backup copies;", + "Checks": [ + "keyvault_recoverable" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.2 Backup and redundancy management", + "Service": "generic" + } + ] + }, + { + "Id": "4.2.2.f", + "Description": "retention periods based on business and regulatory requirements.", + "Checks": [ + "sqlserver_auditing_retention_90_days", + "storage_ensure_soft_delete_is_enabled", + "network_flow_log_more_than_90_days" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.2 Backup and redundancy management", + "Service": "generic" + } + ] + }, + { + "Id": "4.3.2.a", + "Description": "roles and responsibilities for personnel and, where appropriate, suppliers and service providers, specifying the allocation of roles in crisis situations, including specific steps to follow;", + "Checks": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_emails_to_owners", + "sqlserver_va_emails_notifications_admins_enabled" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.3 Crisis management", + "Service": "generic" + } + ] + }, + { + "Id": "4.3.2.c", + "Description": "application of appropriate measures to ensure the maintenance of network and information system security in crisis situations.", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.3 Crisis management", + "Service": "monitor" + } + ] + }, + { + "Id": "5.1.2.a", + "Description": "the cybersecurity practices of the suppliers and service providers, including their secure development procedures;", + "Checks": [ + "aks_network_policy_enabled", + "app_client_certificates_on", + "app_ensure_auth_is_set_up", + "app_ensure_http_is_redirected_to_https", + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_ensure_using_http20", + "app_ftp_deployment_disabled", + "app_function_access_keys_configured", + "app_function_application_insights_enabled", + "app_function_ftps_deployment_disabled", + "app_function_identity_is_configured", + "app_function_identity_without_admin_privileges", + "app_function_latest_runtime_version", + "app_function_not_publicly_accessible", + "app_function_vnet_integration_enabled", + "app_http_logs_enabled", + "app_minimum_tls_version_12", + "app_register_with_identity", + "appinsights_ensure_is_configured" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "app" + } + ] + }, + { + "Id": "5.1.4.f", + "Description": "an obligation on suppliers and service providers to handle vulnerabilities that present a risk to the security of the network and information systems of the relevant entities;", + "Checks": [ + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled", + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "sqlserver_vulnerability_assessment_enabled" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "generic" + } + ] + }, + { + "Id": "5.1.7.b", + "Description": "review incidents related to ICT products and ICT services from suppliers and service providers;", + "Checks": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_ensure_notify_emails_to_owners", + "sqlserver_va_emails_notifications_admins_enabled" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "generic" + } + ] + }, + { + "Id": "6.1.2.b", + "Description": "requirements regarding security updates throughout the entire lifetime of the ICT services or ICT products, or replacement after the end of the support period;", + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_non_rbac_secret_expiration_set", + "postgresql_flexible_server_log_retention_days_greater_3", + "network_flow_log_more_than_90_days", + "sqlserver_auditing_retention_90_days" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.1 Security in acquisition of ICT services or ICT products", + "Service": "generic" + } + ] + }, + { + "Id": "6.2.1", + "Description": "Before developing a network and information system, including software, the relevant entities shall lay down rules for the secure development of network and information systems and apply them when developing network and information systems in-house, or when outsourcing the development of network and information systems. The rules shall cover all development phases, including specification, design, development, implementation and testing.", + "Checks": [ + "defender_ensure_system_updates_are_applied", + "app_client_certificates_on", + "app_ensure_auth_is_set_up", + "app_ensure_http_is_redirected_to_https", + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_ensure_using_http20", + "app_ftp_deployment_disabled", + "app_function_access_keys_configured", + "app_function_application_insights_enabled", + "app_function_ftps_deployment_disabled", + "app_function_identity_is_configured", + "app_function_identity_without_admin_privileges", + "app_function_latest_runtime_version", + "app_function_not_publicly_accessible", + "app_function_vnet_integration_enabled", + "app_http_logs_enabled", + "app_minimum_tls_version_12", + "app_register_with_identity" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "app" + } + ] + }, + { + "Id": "6.2.2.b", + "Description": "apply principles for engineering secure systems and secure coding principles to any information system development activities such as promoting cybersecurity-by-design, zero-trust architectures;", + "Checks": [ + "app_client_certificates_on", + "app_ensure_auth_is_set_up", + "app_ensure_http_is_redirected_to_https", + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_ensure_using_http20", + "app_ftp_deployment_disabled", + "app_function_access_keys_configured", + "app_function_application_insights_enabled", + "app_function_ftps_deployment_disabled", + "app_function_identity_is_configured", + "app_function_identity_without_admin_privileges", + "app_function_latest_runtime_version", + "app_function_not_publicly_accessible", + "app_function_vnet_integration_enabled", + "app_http_logs_enabled", + "app_minimum_tls_version_12", + "app_register_with_identity" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "app" + } + ] + }, + { + "Id": "6.2.2.c", + "Description": "lay down security requirements regarding development environments;", + "Checks": [ + "app_client_certificates_on", + "app_ensure_auth_is_set_up", + "app_ensure_http_is_redirected_to_https", + "app_ensure_java_version_is_latest", + "app_ensure_php_version_is_latest", + "app_ensure_python_version_is_latest", + "app_ensure_using_http20", + "app_ftp_deployment_disabled", + "app_function_access_keys_configured", + "app_function_application_insights_enabled", + "app_function_ftps_deployment_disabled", + "app_function_identity_is_configured", + "app_function_identity_without_admin_privileges", + "app_function_latest_runtime_version", + "app_function_not_publicly_accessible", + "app_function_vnet_integration_enabled", + "app_http_logs_enabled", + "app_minimum_tls_version_12", + "app_register_with_identity" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "app" + } + ] + }, + { + "Id": "6.4.1", + "Description": "The relevant entities shall apply change management procedures to control changes of network and information systems. Where applicable, the procedures shall be consistent with the relevant entities general policies concerning change management.", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.4 Change management, repairs and maintenance", + "Service": "monitor" + } + ] + }, + { + "Id": "6.6.1.a", + "Description": "security patches are applied within a reasonable time after they become available;", + "Checks": [ + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "sqlserver_vulnerability_assessment_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.6 Security patch management", + "Service": "generic" + } + ] + }, + { + "Id": "6.7.2.b", + "Description": "determine and apply controls to protect the relevant entities internal network domains from unauthorised access;", + "Checks": [ + "aisearch_service_not_publicly_accessible", + "aks_network_policy_enabled", + "containerregistry_not_publicly_accessible", + "cosmosdb_account_firewall_use_selected_networks", + "network_bastion_host_exists", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "network_http_internet_access_restricted", + "network_public_ip_shodan", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted", + "network_watcher_enabled", + "storage_default_network_access_rule_is_denied" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "generic" + } + ] + }, + { + "Id": "6.7.2.e", + "Description": "not use systems used for administration of the security policy implementation for other purposes;", + "Checks": [ + "app_function_identity_without_admin_privileges", + "containerregistry_admin_user_disabled", + "entra_global_admin_in_less_than_five_users", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles", + "iam_custom_role_has_permissions_to_administer_resource_locks", + "sqlserver_azuread_administrator_enabled", + "sqlserver_va_emails_notifications_admins_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "generic" + } + ] + }, + { + "Id": "6.7.2.g", + "Description": "where appropriate, exclusively allow access to the relevant entities network and information systems by devices authorised by those entities;", + "Checks": [ + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "app_function_ftps_deployment_disabled", + "app_ensure_auth_is_set_up", + "app_function_identity_is_configured" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "generic" + } + ] + }, + { + "Id": "6.7.2.i", + "Description": "establish communication between distinct systems only through trusted channels that are isolated using logical, cryptographic or physical separation from other communication channels and provide assured identification of their end points and protection of the channel data from modification or disclosure;", + "Checks": [ + "storage_ensure_private_endpoints_in_storage_accounts", + "storage_secure_transfer_required_is_enabled", + "app_minimum_tls_version_12", + "mysql_flexible_server_minimum_tls_version_12", + "sqlserver_recommended_minimal_tls_version", + "storage_ensure_minimum_tls_version_12", + "app_ensure_http_is_redirected_to_https", + "defender_ensure_defender_for_dns_is_on", + "sqlserver_tde_encryption_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "generic" + } + ] + }, + { + "Id": "6.7.2.l", + "Description": "apply best practices for the security of the DNS, and for Internet routing security and routing hygiene of traffic originating from and destined to the network.", + "Checks": [ + "storage_ensure_private_endpoints_in_storage_accounts", + "storage_secure_transfer_required_is_enabled", + "app_minimum_tls_version_12", + "mysql_flexible_server_minimum_tls_version_12", + "sqlserver_recommended_minimal_tls_version", + "storage_ensure_minimum_tls_version_12", + "app_ensure_http_is_redirected_to_https", + "defender_ensure_defender_for_dns_is_on", + "sqlserver_tde_encryption_enabled", + "network_bastion_host_exists", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "network_http_internet_access_restricted", + "network_public_ip_shodan", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted", + "network_watcher_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "generic" + } + ] + }, + { + "Id": "6.8.2.a", + "Description": "consider the functional, logical and physical relationship, including location, between trustworthy systems and services;", + "Checks": [ + "entra_trusted_named_locations_exists", + "network_watcher_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.8 Network segmentation", + "Service": "generic" + } + ] + }, + { + "Id": "6.9.2", + "Description": "For that purpose, the relevant entities shall in particular implement measures that detect or prevent the use of malicious or unauthorised software. The relevant entities shall, where appropriate, ensure that their network and information systems are equipped with detection and response software, which is updated regularly in accordance with the risk assessment carried out pursuant to point 2.1 and the contractual agreements with the providers.", + "Checks": [ + "defender_additional_email_configured_with_a_security_contact", + "defender_assessments_vm_endpoint_protection_installed", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "defender_auto_provisioning_vulnerabilty_assessments_machines_on", + "defender_container_images_resolved_vulnerabilities", + "defender_container_images_scan_enabled", + "defender_ensure_defender_for_app_services_is_on", + "defender_ensure_defender_for_arm_is_on", + "defender_ensure_defender_for_azure_sql_databases_is_on", + "defender_ensure_defender_for_containers_is_on", + "defender_ensure_defender_for_cosmosdb_is_on", + "defender_ensure_defender_for_databases_is_on", + "defender_ensure_defender_for_dns_is_on", + "defender_ensure_defender_for_keyvault_is_on", + "defender_ensure_defender_for_os_relational_databases_is_on", + "defender_ensure_defender_for_server_is_on", + "defender_ensure_defender_for_sql_servers_is_on", + "defender_ensure_defender_for_storage_is_on", + "defender_ensure_iot_hub_defender_is_on", + "defender_ensure_mcas_is_enabled", + "defender_ensure_notify_alerts_severity_is_high", + "defender_ensure_notify_emails_to_owners", + "defender_ensure_system_updates_are_applied", + "defender_ensure_wdatp_is_enabled", + "sqlserver_microsoft_defender_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.9 Protection against malicious and unauthorised software", + "Service": "generic" + } + ] + }, + { + "Id": "7.2.b", + "Description": "the methods for monitoring, measurement, analysis and evaluation, as applicable, to ensure valid results;", + "Checks": [ + "defender_ensure_notify_alerts_severity_is_high", + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "7 POLICIES AND PROCEDURES TO ASSESS THE EFFECTIVENESS OF CYBERSECURITY RISK-MANAGEMENT MEASURES (ARTICLE 21(2), POINT (F), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "7.2 The policy and procedures referred to in point 7.1. shall take into account results of the risk assessment pursuant to point 2.1. and past significant incidents. The relevant entities shall determine:", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.a", + "Description": "in accordance with the relevant entities classification of assets, the type, strength and quality of the cryptographic measures required to protect the relevant entities assets, including data at rest and data in transit;", + "Checks": [ + "app_minimum_tls_version_12", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "sqlserver_tde_encrypted_with_cmk", + "sqlserver_tde_encryption_enabled", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_infrastructure_encryption_is_enabled", + "storage_secure_transfer_required_is_enabled", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c", + "Description": "the relevant entities approach to key management, including, where appropriate, methods for the following:", + "Checks": [ + "defender_ensure_defender_for_keyvault_is_on", + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_key_rotation_enabled", + "keyvault_logging_enabled", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_private_endpoints", + "keyvault_rbac_enabled", + "keyvault_rbac_key_expiration_set", + "keyvault_rbac_secret_expiration_set", + "keyvault_recoverable", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "sqlserver_tde_encrypted_with_cmk", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.i", + "Description": "generating different keys for cryptographic systems and applications;", + "Checks": [ + "keyvault_rbac_key_expiration_set", + "keyvault_rbac_secret_expiration_set", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "sqlserver_tde_encrypted_with_cmk", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.ii", + "Description": "issuing and obtaining public key certificates;", + "Checks": [ + "app_client_certificates_on" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "app" + } + ] + }, + { + "Id": "9.2.c.iii", + "Description": "distributing keys to intended entities, including how to activate keys when received;", + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_rbac_key_expiration_set", + "storage_key_rotation_90_days" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.iv", + "Description": "storing keys, including how authorised users obtain access to keys;", + "Checks": [ + "app_function_access_keys_configured", + "defender_ensure_defender_for_keyvault_is_on", + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_key_rotation_enabled", + "keyvault_logging_enabled", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_private_endpoints", + "keyvault_rbac_enabled", + "keyvault_rbac_key_expiration_set", + "keyvault_rbac_secret_expiration_set", + "keyvault_recoverable", + "sqlserver_tde_encrypted_with_cmk", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_key_rotation_90_days" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.v", + "Description": "changing or updating keys, including rules on when and how to change keys;", + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_key_rotation_enabled", + "keyvault_logging_enabled", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_private_endpoints", + "keyvault_rbac_enabled", + "keyvault_rbac_key_expiration_set", + "keyvault_rbac_secret_expiration_set", + "keyvault_recoverable" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "keyvault" + } + ] + }, + { + "Id": "9.2.c.vi", + "Description": "backing up or archiving keys;", + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_recoverable", + "keyvault_rbac_key_expiration_set" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "keyvault" + } + ] + }, + { + "Id": "9.2.c.vii", + "Description": "logging and auditing of key management-related activities;", + "Checks": [ + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "keyvault_logging_enabled", + "sqlserver_auditing_enabled" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.xii", + "Description": "setting activation and deactivation dates for keys ensuring that the keys can only be used for the specified period of time according to the organization's rules on key management.", + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_rbac_key_expiration_set", + "keyvault_rbac_secret_expiration_set", + "keyvault_non_rbac_secret_expiration_set" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "keyvault" + } + ] + }, + { + "Id": "11.1.1", + "Description": "For the purpose of Article 21(2), point (i) of Directive (EU) 2022/2555, the relevant entities shall establish, document and implement logical and physical access control policies for the access to their network and information systems, based on business requirements as well as network and information system security requirements.", + "Checks": [ + "app_ensure_auth_is_set_up", + "app_function_ftps_deployment_disabled", + "aisearch_service_not_publicly_accessible", + "aks_network_policy_enabled", + "containerregistry_not_publicly_accessible", + "cosmosdb_account_firewall_use_selected_networks", + "network_bastion_host_exists", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "network_http_internet_access_restricted", + "network_public_ip_shodan", + "network_rdp_internet_access_restricted", + "network_ssh_internet_access_restricted", + "network_udp_internet_access_restricted", + "network_watcher_enabled", + "storage_default_network_access_rule_is_denied" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.1 Access control policy", + "Service": "generic" + } + ] + }, + { + "Id": "11.1.2.c", + "Description": "ensure that access is only granted to users that have been adequately authenticated.", + "Checks": [ + "app_ensure_auth_is_set_up", + "app_function_ftps_deployment_disabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.1 Access control policy", + "Service": "app" + } + ] + }, + { + "Id": "11.2.1", + "Description": "The relevant entities shall provide, modify, remove and document access rights to network and information systems in accordance with the access control policy referred to in point 11.1.", + "Checks": [ + "monitor_storage_account_with_activity_logs_is_private", + "storage_default_network_access_rule_is_denied" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "generic" + } + ] + }, + { + "Id": "11.2.2.a", + "Description": "assign and revoke access rights based on the principles of need-to-know, least privilege and separation of duties;", + "Checks": [ + "entra_global_admin_in_less_than_five_users", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "entra_users_cannot_create_microsoft_365_groups" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "entra" + } + ] + }, + { + "Id": "11.2.2.d", + "Description": "ensure that access rights appropriately address third-party access, such as visitors, suppliers and service providers, in particular by limiting access rights in scope and in duration;", + "Checks": [ + "monitor_storage_account_with_activity_logs_is_private", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "entra_users_cannot_create_microsoft_365_groups", + "aks_clusters_created_with_private_nodes", + "containerregistry_uses_private_link", + "cosmosdb_account_use_private_endpoints", + "keyvault_private_endpoints", + "monitor_storage_account_with_activity_logs_is_private", + "storage_ensure_private_endpoints_in_storage_accounts" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "generic" + } + ] + }, + { + "Id": "11.2.2.f", + "Description": "apply logging to the management of access rights.", + "Checks": [ + "app_http_logs_enabled", + "defender_auto_provisioning_log_analytics_agent_vms_on", + "keyvault_logging_enabled", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "monitor_storage_account_with_activity_logs_is_private", + "mysql_flexible_server_audit_log_connection_activated", + "mysql_flexible_server_audit_log_enabled", + "network_flow_log_captured_sent", + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_checkpoints_on", + "postgresql_flexible_server_log_connections_on", + "postgresql_flexible_server_log_disconnections_on", + "postgresql_flexible_server_log_retention_days_greater_3" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "generic" + } + ] + }, + { + "Id": "11.3.1", + "Description": "The relevant entities shall maintain policies for management of privileged accounts and system administration accounts as part of the access control policy referred to in point 11.1.", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "monitor" + } + ] + }, + { + "Id": "11.3.2.a", + "Description": "establish strong identification, authentication such as multi-factor authentication, and authorisation procedures for privileged accounts and system administration accounts;", + "Checks": [ + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa", + "entra_user_with_vm_access_has_mfa" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "entra" + } + ] + }, + { + "Id": "11.3.2.b", + "Description": "set up specific accounts to be used for system administration operations exclusively, such as installation, configuration, management or maintenance;", + "Checks": [ + "entra_global_admin_in_less_than_five_users" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "entra" + } + ] + }, + { + "Id": "11.3.2.c", + "Description": "individualise and restrict system administration privileges to the highest extent possible,", + "Checks": [ + "entra_global_admin_in_less_than_five_users", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles", + "entra_policy_guest_users_access_restrictions", + "entra_policy_restricts_user_consent_for_apps", + "entra_policy_user_consent_for_verified_apps", + "entra_users_cannot_create_microsoft_365_groups" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "entra" + } + ] + }, + { + "Id": "11.3.2.d", + "Description": "provide that system administration accounts are only used to connect to system administration systems.", + "Checks": [ + "entra_global_admin_in_less_than_five_users", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "entra" + } + ] + }, + { + "Id": "11.4.2.b", + "Description": "separate logically such systems from application software not used for system administrative purposes,", + "Checks": [ + "entra_global_admin_in_less_than_five_users", + "entra_policy_default_users_cannot_create_security_groups", + "entra_policy_ensure_default_user_cannot_create_apps", + "entra_policy_ensure_default_user_cannot_create_tenants" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.4 Administration systems", + "Service": "entra" + } + ] + }, + { + "Id": "11.4.2.c", + "Description": "protect access to system administration systems through authentication and encryption.", + "Checks": [ + "entra_trusted_named_locations_exists", + "entra_user_with_vm_access_has_mfa", + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_privileged_user_has_mfa" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.4 Administration systems", + "Service": "entra" + } + ] + }, + { + "Id": "11.5.2.a", + "Description": "set up unique identities for network and information systems and their users;", + "Checks": [ + "app_function_identity_is_configured" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.5 Identification", + "Service": "app" + } + ] + }, + { + "Id": "11.5.2.d", + "Description": "apply logging to the management of identities.", + "Checks": [ + "monitor_alert_create_policy_assignment", + "monitor_alert_create_update_nsg", + "monitor_alert_create_update_public_ip_address_rule", + "monitor_alert_create_update_security_solution", + "monitor_alert_create_update_sqlserver_fr", + "monitor_alert_delete_nsg", + "monitor_alert_delete_policy_assignment", + "monitor_alert_delete_public_ip_address_rule", + "monitor_alert_delete_security_solution", + "monitor_alert_delete_sqlserver_fr" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.5 Identification", + "Service": "monitor" + } + ] + }, + { + "Id": "11.6.1", + "Description": "The relevant entities shall implement secure authentication procedures and technologies based on access restrictions and the policy on access control.", + "Checks": [ + "app_ensure_auth_is_set_up", + "app_function_ftps_deployment_disabled", + "aisearch_service_not_publicly_accessible", + "aks_network_policy_enabled", + "containerregistry_not_publicly_accessible" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "generic" + } + ] + }, + { + "Id": "11.6.2.a", + "Description": "ensure the strength of authentication is appropriate to the classification of the asset to be accessed;", + "Checks": [ + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa", + "entra_user_with_vm_access_has_mfa" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "entra" + } + ] + }, + { + "Id": "11.6.2.c", + "Description": "require the change of authentication credentials initially, at predefined intervals and upon suspicion that the credentials were compromised;", + "Checks": [ + "keyvault_key_rotation_enabled", + "storage_key_rotation_90_days" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "generic" + } + ] + }, + { + "Id": "11.7.2", + "Description": "The relevant entities shall ensure that the strength of authentication is appropriate for the classification of the asset to be accessed.", + "Checks": [ + "entra_conditional_access_policy_require_mfa_for_management_api", + "entra_non_privileged_user_has_mfa", + "entra_privileged_user_has_mfa", + "entra_user_with_vm_access_has_mfa" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.7 Multi-factor authentication", + "Service": "generic" + } + ] + }, + { + "Id": "12.1.2.c", + "Description": "align the availability requirements of the assets with the delivery and recovery objectives set out in their business continuity and disaster recovery plans.", + "Checks": [ + "keyvault_recoverable" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.1 Asset classification", + "Service": "keyvault" + } + ] + }, + { + "Id": "12.2.2.a", + "Description": "cover the entire life cycle of the assets, including acquisition, use, storage, transportation and disposal;", + "Checks": [ + "keyvault_key_expiration_set_in_non_rbac", + "keyvault_non_rbac_secret_expiration_set", + "keyvault_rbac_key_expiration_set", + "keyvault_rbac_secret_expiration_set", + "network_flow_log_more_than_90_days", + "postgresql_flexible_server_log_retention_days_greater_3", + "sqlserver_auditing_retention_90_days", + "storage_blob_public_access_level_is_disabled", + "storage_default_network_access_rule_is_denied", + "storage_ensure_azure_services_are_trusted_to_access_is_enabled", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_ensure_minimum_tls_version_12", + "storage_ensure_private_endpoints_in_storage_accounts", + "storage_ensure_soft_delete_is_enabled", + "storage_infrastructure_encryption_is_enabled", + "storage_key_rotation_90_days", + "storage_secure_transfer_required_is_enabled" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.2 Handling of assets", + "Service": "generic" + } + ] + }, + { + "Id": "12.2.2.b", + "Description": "provide rules on the safe use, safe storage, safe transport, and the irretrievable deletion and destruction of the assets;", + "Checks": [ + "storage_ensure_soft_delete_is_enabled", + "keyvault_recoverable", + "storage_secure_transfer_required_is_enabled", + "app_minimum_tls_version_12", + "monitor_storage_account_with_activity_logs_cmk_encrypted", + "sqlserver_tde_encrypted_with_cmk", + "sqlserver_tde_encryption_enabled", + "storage_ensure_encryption_with_customer_managed_keys", + "storage_infrastructure_encryption_is_enabled", + "vm_ensure_attached_disks_encrypted_with_cmk", + "vm_ensure_unattached_disks_encrypted_with_cmk" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.2 Handling of assets", + "Service": "generic" + } + ] + }, + { + "Id": "12.2.2.c", + "Description": "provide that the transfer shall take place in a secure manner, in accordance with the type of asset to be transferred.", + "Checks": [ + "storage_secure_transfer_required_is_enabled", + "app_minimum_tls_version_12", + "mysql_flexible_server_minimum_tls_version_12", + "sqlserver_recommended_minimal_tls_version", + "storage_ensure_minimum_tls_version_12", + "sqlserver_tde_encrypted_with_cmk", + "sqlserver_tde_encryption_enabled" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.2 Handling of assets", + "Service": "generic" + } + ] + } + ] +} diff --git a/prowler/compliance/azure/prowler_threatscore_azure.json b/prowler/compliance/azure/prowler_threatscore_azure.json index 12ad818a27..c6bc06b7e4 100644 --- a/prowler/compliance/azure/prowler_threatscore_azure.json +++ b/prowler/compliance/azure/prowler_threatscore_azure.json @@ -17,7 +17,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "Microsoft Entra ID Security Defaults offer preconfigured security settings designed to protect organizations from common identity attacks at no additional cost. These settings enforce basic security measures such as MFA registration, risk-based authentication prompts, and blocking legacy authentication clients that do not support MFA. Security defaults are available to all organizations and can be enabled via the Azure portal to strengthen authentication security.", "AdditionalInformation": "Security defaults provide built-in protections to reduce the risk of unauthorized access until organizations configure their own identity security policies. By requiring MFA, blocking weak authentication methods, and adapting authentication challenges based on risk factors, these settings create a stronger security foundation without additional licensing requirements.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -34,7 +35,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "[IMPORTANT] If your organization has Microsoft Entra ID licensing (included in Microsoft 365 E3, E5, F5, or EM&S E3/E5) and can use Conditional Access, you may skip this section and proceed to Conditional Access. Enable multi-factor authentication (MFA) for all users, roles, and groups with write access to Azure resources. This includes both custom-created roles and built-in roles such as: Service Co-Administrators, Subscription Owners, Contributors", "AdditionalInformation": "MFA enhances security by requiring two or more authentication factors, ensuring that only authorized users gain access. It significantly reduces the risk of unauthorized access, credential theft, and privilege escalation. By implementing MFA, attackers must compromise multiple authentication mechanisms, making breaches far more difficult and improving overall Azure resource security.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -51,7 +53,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "[IMPORTANT] If your organization has Microsoft Entra ID licensing (included in Microsoft 365 E3, E5, F5, or EM&S E3/E5) and can use Conditional Access, you may skip this section and proceed to Conditional Access. Enable multi-factor authentication (MFA) for all non-privileged users to enhance security and prevent unauthorized access.", "AdditionalInformation": "MFA strengthens authentication by requiring at least two verification factors, making it significantly harder for attackers to gain access using stolen credentials. Even if one authentication factor is compromised, an additional layer of security reduces the risk of unauthorized account access, enhancing overall identity and access management security.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -68,7 +71,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "This recommendation ensures that users accessing the Windows Azure Service Management API (e.g., Azure PowerShell, Azure CLI, Azure Resource Manager API) are required to authenticate using multi-factor authentication (MFA) before accessing resources.", "AdditionalInformation": "Administrative access to the Azure Service Management API should be secured with enhanced authentication measures to prevent unauthorized changes. Enforcing MFA helps mitigate the risk of credential compromise, privilege abuse, and unauthorized modifications to administrative settings. IMPORTANT: While exceptions for specific users or groups may be configured, they should be carefully tracked and periodically reviewed through an Access Review process. The policy should apply to all users by default, ensuring that only explicitly exempted accounts bypass MFA.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -85,7 +89,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "This recommendation ensures that users accessing Microsoft Admin Portals (such as Microsoft 365 Admin, Microsoft 365 Defender, Exchange Admin Center, and Azure Portal) must authenticate using multi-factor authentication (MFA) before logging in.", "AdditionalInformation": "Microsoft Admin Portals provide privileged access to critical settings and resources, requiring enhanced security measures. Enforcing MFA reduces the risk of unauthorized access, credential compromise, and administrative abuse, preventing intruders from making unauthorized changes to administrative settings.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -102,7 +107,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "Ensure that privileged virtual machines do not allow logins from identities without multi-factor authentication (MFA) and that access is restricted to necessary permissions only. Unauthorized access can enable attackers to move laterally and misuse the virtual machine’s managed identity for further privilege escalation or unauthorized operations.", "AdditionalInformation": "Requiring MFA for privileged VM access reduces the risk of credential compromise, lateral movement, and unauthorized access to cloud resources. Attackers can exploit valid accounts to access virtual machines and escalate privileges using the managed identity. Enforcing MFA and least privilege access helps mitigate these risks by preventing unauthorized logins and restricting administrative permissions to only what is necessary.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -119,7 +125,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "Restrict the ability to create new Microsoft Entra ID or Azure AD B2C tenants to administrators or appropriately delegated users.", "AdditionalInformation": "Limiting tenant creation to authorized administrators prevents unauthorized users from creating new tenants, reducing the risk of uncontrolled environments, misconfigurations, and potential security gaps. This ensures that only approved users can establish and manage tenant resources.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -136,7 +143,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "To maintain security and operational efficiency, it is recommended to have a minimum of two and a maximum of four users assigned the Global Administrator role in Microsoft Entra ID. This ensures redundancy while minimizing the risk of excessive privileged access.", "AdditionalInformation": "The Global Administrator role holds broad privileges across Microsoft Entra ID services and should not be used for daily tasks. Administrators should have separate accounts for regular activities and privileged actions. Limiting the number of Global Administrators reduces the risk of unauthorized access, human error, and privilege misuse, while ensuring at least two administrators are available to prevent disruptions in case of unavailability. This approach aligns with the principle of least privilege and strengthens the security posture of an Azure tenant.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -153,7 +161,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "Restrict guest user permissions to prevent unauthorized access to directory resources and administrative roles.", "AdditionalInformation": "Limiting guest access ensures that guest accounts cannot enumerate users, groups, or directory objects and prevents them from being assigned administrative roles. Guest access has three levels of restriction, with the most secure option being: “Guest user access is restricted to their own directory object” (most restrictive). This setting minimizes the risk of unauthorized access and ensures guests only interact with their assigned resources.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -170,7 +179,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "Require administrators to provide consent before applications can access Microsoft Entra ID resources, preventing unauthorized or malicious app integrations.", "AdditionalInformation": "Allowing users to grant application permissions without restrictions increases the risk of data exfiltration and privilege abuse by malicious applications. Restricting app consent to administrators ensures that only verified and approved applications gain access, protecting sensitive data and privileged accounts from unauthorized use.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -187,7 +197,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "Allow users to grant consent only for selected permissions when the request comes from a verified publisher, ensuring tighter control over third-party application access.", "AdditionalInformation": "Restricting app consent to verified publishers helps mitigate the risk of unauthorized data access and privilege abuse. Malicious applications may attempt to exploit user-granted permissions, so ensuring only trusted, verified applications can request access enhances security while maintaining flexibility for approved integrations.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -204,7 +215,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "Microsoft Entra ID Conditional Access allows organizations to define Named Locations and categorize them as trusted or untrusted. These locations can be based on geographical regions, specific IP addresses, or IP ranges to enhance access control policies.", "AdditionalInformation": "Defining trusted IP addresses or ranges enables organizations to enforce Conditional Access policies based on user location. Users authenticating from trusted locations may receive fewer access restrictions, while those from untrusted sources can be subject to stricter security controls, reducing the risk of unauthorized access.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -221,7 +233,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "Limit the ability to create security groups to administrators only, preventing unauthorized users from managing group memberships.", "AdditionalInformation": "Allowing all users to create and manage security groups can lead to uncontrolled access, misconfigurations, and potential security risks. Unless business requirements justify broader delegation, restricting security group creation to administrators ensures that group permissions and memberships are properly managed, reducing the risk of unauthorized access.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -238,7 +251,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "Restrict the ability to register third-party applications to administrators or appropriately delegated users, ensuring better security control over app integrations.", "AdditionalInformation": "Allowing unrestricted application registration can expose Microsoft Entra ID data to security risks. Requiring administrator approval ensures that custom-developed applications undergo a formal security review before being granted access. Organizations may delegate permissions to developers or high-request users as needed, but policies should be reviewed to align with security best practices and operational needs.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -255,7 +269,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "Limit the ability to send invitations to users with specific administrative roles, preventing unauthorized guest access to cloud resources.", "AdditionalInformation": "Restricting guest invitations to designated administrators helps enforce “Need to Know” permissions, reducing the risk of unintended data exposure. By default, anyone in the organization—including guests and non-admins—can invite external users, which can lead to unauthorized access. Restricting this capability ensures that only approved accounts can extend access to the tenant, strengthening security and access control.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -272,7 +287,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "Limit Microsoft 365 group creation to administrators only, ensuring that group management remains under centralized control.", "AdditionalInformation": "Restricting group creation to administrators prevents unauthorized or unnecessary group creation, ensuring that only appropriate groups are created and managed. This reduces the risk of misconfigurations, access issues, and uncontrolled resource sharing within the organization.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -289,7 +305,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "Regularly review Network Security Groups (NSGs) for misconfigured or overly permissive rules, especially those exposing ports and protocols to the public internet. Any unnecessary exposure should be restricted to reduce security risks.", "AdditionalInformation": "Exposing Remote Desktop Protocol (RDP) or other critical services to the internet increases the risk of brute-force attacks, which could lead to unauthorized access to Azure Virtual Machines. Compromised VMs can be used to launch attacks on other networked resources, both inside and outside Azure. Periodic NSG evaluations help minimize attack surfaces and ensure that only explicitly required access is permitted.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -306,7 +323,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "Regularly review Network Security Groups (NSGs) for misconfigured or overly permissive rules, particularly those exposing SSH (port 22) or other critical services to the internet. Any unnecessary exposure should be restricted to reduce security risks.", "AdditionalInformation": "Exposing SSH over the internet increases the risk of brute-force attacks, allowing attackers to gain unauthorized access to Azure Virtual Machines. Once compromised, a VM can be used as a pivot point to attack other machines within the Azure Virtual Network or even external systems. Periodic NSG evaluations help minimize attack surfaces and ensure that only explicitly required access is permitted.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -323,7 +341,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "Regularly review Network Security Groups (NSGs) for misconfigured or overly permissive UDP rules, especially those exposing UDP-based services to the internet. Any unnecessary exposure should be restricted to prevent security risks.", "AdditionalInformation": "Exposing UDP services to the internet increases the risk of Distributed Denial-of-Service (DDoS) amplification attacks, where attackers can reflect and amplify spoofed traffic from Azure Virtual Machines. Commonly exploited UDP services include DNS, NTP, SSDP, SNMP, and CLDAP, which can be used to disrupt services or launch attacks against other networked systems inside and outside Azure. Regular NSG evaluations help minimize attack surfaces and prevent VMs from being leveraged in DDoS attacks.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -340,7 +359,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "Regularly review Network Security Groups (NSGs) for misconfigured or overly permissive HTTP(S) rules, ensuring that exposure to the internet is necessary and narrowly configured. Restrict access where it is not explicitly required.", "AdditionalInformation": "Exposing HTTP(S) services to the internet increases the risk of brute-force attacks, credential stuffing, and exploitation of vulnerabilities in web applications or services. If compromised, an attacker can use the affected resource as a pivot point to escalate privileges, move laterally, and compromise other Azure resources within the tenant. Periodic NSG evaluations help reduce attack surfaces and enforce least privilege access.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -357,7 +377,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "Enable Azure Bastion to securely access Azure Virtual Machines (VMs) over the internet without exposing RDP (3389/TCP) or SSH (22/TCP) ports directly to the public network. Azure Bastion provides remote access through TLS (443/TCP) while integrating with Azure Active Directory (AAD) security policies.", "AdditionalInformation": "Using Azure Bastion eliminates the need to assign public IP addresses to VMs, reducing exposure to brute-force attacks and unauthorized access. It enhances security by enabling browser-based RDP/SSH access, leveraging Azure Active Directory controls, and supporting Multi-Factor Authentication (MFA), Conditional Access Policies, and other hardening measures for a centralized and secure access solution.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -374,7 +395,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Use Microsoft Entra authentication for SQL Database to centralize identity and credential management, enhancing security and simplifying access control. By leveraging Microsoft Entra ID, organizations can manage database users, permissions, and authentication policies in a single location, reducing complexity and improving security.", "AdditionalInformation": "Microsoft Entra authentication eliminates the need for separate SQL authentication, preventing identity sprawl and simplifying password management. It enables centralized permission management, supports token-based authentication, integrates with Active Directory Federation Services (ADFS), and enhances security with Multi-Factor Authentication (MFA). Using Entra ID authentication also eliminates the need to store passwords, enabling secure, modern authentication methods while ensuring seamless access control for users and applications.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -391,7 +413,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Disable access from Azure services to PostgreSQL flexible servers to restrict inbound connections and enhance security.", "AdditionalInformation": "Allowing access from all Azure services bypasses firewall restrictions and permits connections from any Azure resource, including those outside your subscription. This can expose the database to unauthorized access and security risks. Instead, configure firewall rules or Virtual Network (VNet) rules to allow only specific, trusted network ranges, ensuring tighter access control.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -408,7 +431,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Restrict Cosmos DB access to whitelisted networks to minimize exposure and reduce potential attack vectors.", "AdditionalInformation": "Limiting Cosmos DB communication to specific trusted networks prevents unauthorized access from unapproved sources, including the public internet. This enhances security by reducing the attack surface and ensuring only designated networks can interact with the database.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -425,7 +449,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Use private endpoints for Cosmos DB to restrict network traffic to approved sources, ensuring secure and private communication.", "AdditionalInformation": "For sensitive data, private endpoints provide granular control over which services can access Cosmos DB, preventing exposure to unauthorized networks. This setup ensures that all traffic remains within a private network, reducing security risks and enhancing data protection.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -442,7 +467,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Use Microsoft Entra ID for Cosmos DB client authentication, leveraging Azure RBAC for enhanced security, centralized management, and MFA support.", "AdditionalInformation": "Entra ID authentication is significantly more secure than token-based authentication, as tokens must be stored persistently on the client, increasing the risk of compromise. Entra ID eliminates this risk by securely handling credentials, integrating seamlessly with Azure RBAC, and supporting multi-factor authentication (MFA) for stronger access control.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -459,7 +485,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Disable public network access for Azure Storage accounts, ensuring that access settings for individual containers cannot override this restriction. This applies to Azure Resource Manager deployment model storage accounts, as classic deployment model accounts will be retired by August 31, 2024.", "AdditionalInformation": "By default, Azure Storage accounts allow users with appropriate permissions to enable public access to containers and blobs, granting read-only access without requiring authentication. To minimize the risk of unauthorized data exposure, public access should be restricted unless explicitly required. Instead, use Azure AD RBAC or shared access signatures (SAS) to grant controlled and time-limited access to storage resources.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -476,7 +503,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Restricting default network access enhances security by preventing unrestricted connections to Azure Storage accounts. By default, storage accounts accept connections from any network, so access should be limited to selected networks by modifying the default configuration.", "AdditionalInformation": "Storage accounts should deny access from all networks by default, except for explicitly allowed Azure Virtual Networks or specific IP address ranges. This approach creates a secure network boundary, ensuring only authorized applications can connect. Even when network rules allow access, proper authentication (via access keys or SAS tokens) is still required, adding an extra layer of security.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -493,7 +521,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "This recommendation assumes that public network access is set to “Enabled from selected virtual networks and IP addresses” and that the default network access rule for storage accounts is set to deny. Some Azure services require access to storage accounts from networks that cannot be explicitly granted permissions through network rules. To allow these services to function properly, enable the trusted Azure services exception, which allows selected Azure services to bypass network restrictions while still enforcing strong authentication.", "AdditionalInformation": "Enabling firewall rules on a storage account blocks access to all incoming requests, including those from other Azure services. Allowing access to trusted Azure services ensures that essential Azure functions, such as Azure Backup, Event Grid, Monitor, and Site Recovery, can interact with storage accounts securely without exposing them to broader public access.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -510,7 +539,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Use private endpoints for Azure Storage accounts to enable secure, encrypted access over a Private Link. Private endpoints assign an IP address from the Virtual Network (VNet) for each service, ensuring that network traffic remains isolated and encrypted within the VNet. This configuration helps segment network traffic, preventing external access while allowing secure communication between services. Additionally, VNets can extend addressing spaces or act as secure tunnels over public networks, connecting remote infrastructures securely.", "AdditionalInformation": "Encrypting traffic between services protects sensitive data from interception and unauthorized access. Using private endpoints ensures that data remains within a controlled network environment, reducing exposure to external threats and enhancing overall security posture.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -527,7 +557,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Ensure that Azure SQL Databases do not allow ingress from 0.0.0.0/0 (ANY IP), as this would expose them to the public internet. Azure SQL Server includes a firewall that blocks unauthorized connections by default, but it allows defining more granular IP address rules to restrict access. Allowing 0.0.0.0/0 effectively grants open access, increasing security risks.", "AdditionalInformation": "Leaving Azure SQL firewall rules open to ANY IP significantly increases the attack surface, making databases vulnerable to brute-force attacks and unauthorized access. Instead, firewall rules should be restricted to specific, trusted IP ranges from known datacenters or internal resources. Additionally, enabling Allow Azure services and resources to access this server could allow access from outside your organization’s subscription or region, potentially bypassing SQL Server’s network ACLs and exposing the database to malicious activity.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -544,7 +575,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Enable Azure App Service Authentication to restrict anonymous HTTP requests and enforce authentication using identity providers before requests reach the application.", "AdditionalInformation": "Enabling App Service Authentication ensures that all incoming HTTP requests are validated and authenticated before being processed by the application. It integrates with Microsoft Entra ID, Google, Facebook, Microsoft Accounts, and Twitter to manage authentication, token validation, and session handling. Additionally, disabling HTTP Basic Authentication helps mitigate risks from legacy authentication methods, strengthening application security.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -561,7 +593,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Ensure that FTP access is disabled for Azure App Services, or if required, enforce FTPS (FTP over SSL/TLS) for secure authentication and data transmission.", "AdditionalInformation": "FTP transmits data, including credentials, in plaintext, making it vulnerable to interception, credential theft, and data exfiltration. Requiring FTPS ensures that all FTP connections are encrypted, reducing the risk of unauthorized access, persistence, and lateral movement within the environment. If FTP is not needed, disabling it entirely enhances security by eliminating an unnecessary attack vector.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -578,7 +611,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "Enable log_checkpoints on PostgreSQL flexible servers to log checkpoint activity, generating query and error logs that aid in monitoring and troubleshooting database performance.", "AdditionalInformation": "Logging checkpoints helps track database activity, errors, and performance issues, providing valuable insights for troubleshooting and optimization. While transaction logs remain inaccessible, query and error logs allow identification and resolution of configuration errors and inefficiencies, improving database reliability and performance.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -595,7 +629,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "Enable connection throttling on PostgreSQL flexible servers to regulate concurrent connections and generate logs for monitoring and troubleshooting.", "AdditionalInformation": "Connection throttling helps prevent Denial of Service (DoS) attacks by limiting excessive connections that could exhaust resources. It also protects against system failures or degraded performance caused by high user loads. Query and error logs provide insights into connection issues, enabling faster troubleshooting and performance optimization.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -612,7 +647,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "Enable log_connections on PostgreSQL Single Servers to record connection attempts and authentication events for security monitoring and auditing. (Note: This recommendation applies only to Single Server, as Azure PostgreSQL Single Server is planned for retirement.)", "AdditionalInformation": "Logging connection attempts helps detect unauthorized access, failed authentication attempts, and potential security threats. These logs provide valuable insights for troubleshooting, auditing, and identifying suspicious activities, enhancing overall database security and monitoring.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -629,7 +665,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "Enable log_disconnections on PostgreSQL Servers to record session termination events, including session duration. (Note: This recommendation applies only to Single Server, as Azure PostgreSQL Single Server is planned for retirement.)", "AdditionalInformation": "Logging disconnections provides visibility into session activity and duration, helping to analyze user behavior, detect anomalies, and troubleshoot performance issues. These logs contribute to audit trails, security monitoring, and database performance optimization.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -646,7 +683,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "Enable audit_log_enabled on MySQL flexible servers to capture logs for connection attempts, DDL/DML activity, and other database events for security and monitoring purposes.", "AdditionalInformation": "Logging database activity helps detect unauthorized access, troubleshoot issues, and analyze performance bottlenecks. Enabling audit logs enhances security visibility, compliance monitoring, and incident response by providing valuable insights into database operations.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -663,7 +701,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "Configure audit_log_events on MySQL flexible servers to include CONNECTION events, ensuring that successful and failed connection attempts are logged.", "AdditionalInformation": "Logging CONNECTION events provides visibility into authentication attempts, helping to detect unauthorized access, troubleshoot issues, and optimize database performance. These logs are essential for security monitoring, compliance, and identifying potential misconfigurations or attack attempts.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -680,7 +719,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "Enable AuditEvent logging for Azure Key Vault to track and log interactions with keys, certificates, and other sensitive information.", "AdditionalInformation": "Logging Key Vault access events provides an audit trail that helps monitor who accessed the vault, when, and how. This enhances security, compliance, and incident response by ensuring logs are stored in a designated Azure Storage account or Log Analytics workspace, allowing centralized monitoring across multiple Key Vaults.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -697,7 +737,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "Ensure that network flow logs are collected and sent to a central Log Analytics workspace for monitoring and analysis.", "AdditionalInformation": "Capturing network flow logs provides visibility into traffic patterns across your network, helping detect anomalies, potential lateral movement, and security threats. These logs integrate with Azure Monitor and Azure Sentinel, enabling advanced analytics and visualization for improved network security and incident response.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -714,7 +755,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "Enable AppServiceHTTPLogs for Azure App Service instances to capture and centrally log all HTTP requests.", "AdditionalInformation": "Logging web requests provides critical data for security monitoring and incident response. Captured logs can be ingested into a SIEM or central log aggregation system, helping security analysts detect anomalies, investigate threats, and enhance application security.", - "LevelOfRisk": 1 + "LevelOfRisk": 1, + "Weight": 1 } ] }, @@ -731,7 +773,8 @@ "SubSection": "3.2 Retention", "AttributeDescription": "Configure SQL Server Audit Retention to retain logs for more than 90 days to ensure long-term visibility into database activity and security events.", "AdditionalInformation": "Maintaining audit logs for over 90 days helps detect anomalies, security breaches, and unauthorized access. Longer retention periods allow organizations to analyze historical data, support compliance requirements, and strengthen forensic investigations.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -748,7 +791,8 @@ "SubSection": "3.2 Retention", "AttributeDescription": "Enable Network Security Group (NSG) Flow Logs and configure the retention period to at least 90 days to capture and store IP traffic data for security monitoring and analysis.", "AdditionalInformation": "NSG Flow Logs provide visibility into network traffic, helping detect anomalies, unauthorized access, and potential security breaches. Retaining logs for at least 90 days ensures that historical data is available for incident investigation, compliance, and forensic analysis, strengthening overall network security monitoring.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -765,7 +809,8 @@ "SubSection": "3.2 Retention", "AttributeDescription": "Ensure that logfiles.retention_days on PostgreSQL flexible servers is set to an appropriate value to maintain access to historical logs for monitoring and troubleshooting.", "AdditionalInformation": "Setting an appropriate log retention period ensures that query and error logs are available for diagnosing configuration issues, troubleshooting errors, and optimizing performance. Retaining logs for a sufficient duration supports security analysis, compliance requirements, and operational debugging while preventing unnecessary storage consumption.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -782,7 +827,8 @@ "SubSection": "3.2 Retention", "AttributeDescription": "Enable Diagnostic Settings to export activity logs for all appropriate resources within a subscription, ensuring long-term visibility into security and operational events.", "AdditionalInformation": "By default, logs are retained for only 90 days. Configuring diagnostic settings allows logs to be exported and stored for extended periods, enabling security analysis, compliance monitoring, and forensic investigations within an Azure subscription.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -799,7 +845,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Enable auditing on Azure SQL Servers to track and log database events for security and compliance purposes.", "AdditionalInformation": "Auditing at the server level ensures that all existing and newly created databases inherit auditing policies, providing consistent monitoring across the SQL environment. It does not override database-level auditing policies but complements them. Audit logs are stored in Azure Storage, helping organizations maintain regulatory compliance, monitor database activity, and detect anomalies that may indicate security threats or operational concerns.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -816,7 +863,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Create an Activity Log Alert for the Delete SQL Server Firewall Rule event to monitor and track firewall rule removals in SQL Server.", "AdditionalInformation": "Monitoring firewall rule deletions helps detect unauthorized or accidental changes that could expose the database to security risks. Timely alerts ensure quick detection and response to prevent unauthorized network access and maintain a secure SQL environment.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -833,7 +881,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Create an Activity Log Alert for the Create or Update Public IP Address event to monitor changes in public IP configurations.", "AdditionalInformation": "Tracking public IP address modifications provides visibility into network access changes, helping detect unauthorized or misconfigured public exposure. Timely alerts enable faster detection and response to potential security risks, ensuring a controlled and secure network environment.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -850,7 +899,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Create an Activity Log Alert for the Delete Public IP Address event to monitor and track the removal of public IP addresses.", "AdditionalInformation": "Monitoring public IP deletions helps detect unauthorized or accidental changes that could impact network accessibility or security. Timely alerts enable quick investigation and response, ensuring that critical network configurations remain intact and secure.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -867,7 +917,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Application Insights in Azure serves as an Application Performance Monitoring (APM) solution, providing valuable insights into application performance, telemetry data, and trace logs. It helps organizations monitor application health and troubleshoot incidents efficiently.", "AdditionalInformation": "Enabling Application Insights enhances security monitoring and performance optimization by collecting metrics, telemetry, and trace logs. These logs aid in proactive performance tuning to reduce costs and support incident response by helping identify the root cause of potential security or operational issues. Integrating Application Insights into a broader logging and monitoring strategy strengthens an organization’s overall observability and security posture.", - "LevelOfRisk": 1 + "LevelOfRisk": 1, + "Weight": 1 } ] }, @@ -884,7 +935,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Enable Network Watcher for all Azure regions within your subscription to monitor and diagnose network activity.", "AdditionalInformation": "Network Watcher provides network diagnostic and visualization tools that help organizations analyze traffic, troubleshoot connectivity issues, and detect anomalies. Enabling it enhances network visibility, security monitoring, and operational insights across Azure environments.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -901,7 +953,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Ensure endpoint protection is installed on all Azure Virtual Machines (VMs) to provide real-time security monitoring and threat detection.", "AdditionalInformation": "Endpoint protection helps detect and remove malware, viruses, and other security threats, protecting VMs from compromise and unauthorized access. It also provides configurable alerts for suspicious activity, enhancing security monitoring and incident response across Azure environments.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -918,7 +971,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Ensure that all Windows and Linux Virtual Machines (VMs) have the latest OS patches and security updates applied to mitigate vulnerabilities and improve system stability.", "AdditionalInformation": "Keeping VMs updated helps address security vulnerabilities, bug fixes, and stability improvements. Microsoft Defender for Cloud continuously checks for missing critical and security updates using Windows Update, WSUS, or Linux package managers. Applying recommended updates reduces the risk of exploits, malware, and performance issues, ensuring a secure and resilient cloud environment.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -935,7 +989,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Ensure that all Microsoft Cloud Security Benchmark (MCSB) policies are enabled to effectively evaluate resource configurations against best practice security recommendations.", "AdditionalInformation": "The MCSB Policy Initiative enforces security best practices and helps ensure compliance with organizational and regulatory requirements. If a policy’s Effect is set to Disabled, it will not be evaluated, potentially preventing administrators from detecting security misconfigurations. Keeping all MCSB policies enabled allows Microsoft Defender for Cloud to assess relevant resources and provide actionable security insights to strengthen cloud security.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -952,7 +1007,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Ensure that security alert emails are enabled for subscription owners to receive notifications about potential security threats and vulnerabilities.", "AdditionalInformation": "Enabling security alert emails ensures that subscription owners are promptly informed of security incidents, threats, or misconfigurations detected by Microsoft Defender for Cloud. Timely notifications allow administrators to take immediate action, mitigate risks, and maintain a strong security posture within the Azure environment.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -969,7 +1025,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Configure Microsoft Defender for Cloud to send high-severity security alerts to a designated security contact email in addition to the subscription owner.", "AdditionalInformation": "By default, Microsoft Defender for Cloud notifies only subscription owners of critical security alerts. Adding a security contact email ensures that the security team is promptly informed of potential threats, allowing for faster response and risk mitigation to maintain a secure Azure environment.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -986,7 +1043,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Ensure that Diagnostic Settings are configured to log relevant control and management plane activities for enhanced visibility and security monitoring. (Note: A Diagnostic Setting must exist for this configuration to be available.)", "AdditionalInformation": "Diagnostic settings determine how logs are exported and stored. Capturing logs from the control and management plane enables proper alerting, monitoring, and analysis of administrative actions, helping to detect unauthorized changes and security threats.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1003,7 +1061,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Enable security alert emails to notify the subscription owner or designated security contacts about potential security threats.", "AdditionalInformation": "Ensuring security alerts are sent to the appropriate personnel allows for timely detection and response to security incidents. This helps mitigate risks by ensuring that critical threats and vulnerabilities are addressed promptly, improving the overall security posture of the Azure environment.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1020,7 +1079,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Enable Microsoft Defender for DNS to monitor and scan outgoing DNS traffic within a subscription for potential security threats. (Note: As of August 1, 2023, new subscribers receive DNS threat alerts as part of Defender for Servers P2.)", "AdditionalInformation": "Defender for DNS analyzes DNS queries against a dynamic threat intelligence list to detect potential malicious activity, compromised services, or security breaches. Monitoring DNS lookups helps identify and prevent threats such as data exfiltration, command and control (C2) communications, and phishing attacks, improving overall network security.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1037,7 +1097,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Create an activity log alert for the Create Policy Assignment event to track changes in Azure Policy assignments.", "AdditionalInformation": "Monitoring policy assignment events helps detect unauthorized or unintended changes in Azure policies, providing greater visibility and reducing the time required to identify and respond to policy modifications.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1054,7 +1115,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Create an activity log alert for the Delete Policy Assignment event to monitor and track policy removal activities in Azure Policy assignments.", "AdditionalInformation": "Monitoring policy deletions helps detect unauthorized or unintended changes, ensuring policy integrity and reducing the time required to identify and respond to security or compliance violations.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1071,7 +1133,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Create an Activity Log Alert for the Create or Update Network Security Group (NSG) event to track changes in network security configurations.", "AdditionalInformation": "Monitoring NSG creation and updates provides visibility into network access changes, helping to detect unauthorized modifications and identify potential security risks in a timely manner.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1088,7 +1151,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Create an Activity Log Alert for the Delete Network Security Group (NSG) event to monitor and track network security group removals.", "AdditionalInformation": "Monitoring NSG deletions provides visibility into network access changes, helping to detect unauthorized modifications and identify potential security threats before they impact the environment.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1105,7 +1169,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Create an Activity Log Alert for the Create or Update Security Solution event to track modifications to security solutions in your environment.", "AdditionalInformation": "Monitoring security solution changes provides visibility into updates, additions, or modifications to active security configurations. This helps detect unauthorized changes or suspicious activity and ensures that security controls remain properly configured.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1122,7 +1187,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Create an Activity Log Alert for the Delete Security Solution event to monitor and track the removal of security solutions in your environment.", "AdditionalInformation": "Monitoring security solution deletions helps detect unauthorized removals or suspicious activity, ensuring that critical security controls are not accidentally or maliciously disabled. This improves security visibility and helps maintain a strong security posture.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1139,7 +1205,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Create an Activity Log Alert for the Create or Update SQL Server Firewall Rule event to track changes in SQL Server firewall configurations.", "AdditionalInformation": "Monitoring firewall rule modifications provides visibility into network access changes, helping detect unauthorized updates that could expose the database to security risks. Timely alerts can reduce the time needed to identify and respond to suspicious activity, ensuring a secure SQL environment.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1156,7 +1223,8 @@ "SubSection": "4.1 In-Transit", "AttributeDescription": "Enable require_secure_transport on PostgreSQL flexible servers to enforce SSL connections between the database server and client applications, ensuring encrypted communication.", "AdditionalInformation": "Requiring SSL encryption helps protect data in transit from man-in-the-middle attacks by securing the connection between the database server and client applications. Enforcing secure transport prevents unauthorized access and strengthens data integrity and confidentiality.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1173,7 +1241,8 @@ "SubSection": "4.1 In-Transit", "AttributeDescription": "Enable require_secure_transport on MySQL flexible servers to enforce SSL encryption between the database server and client applications, ensuring secure communication.", "AdditionalInformation": "Requiring SSL connectivity protects data in transit from man-in-the-middle attacks by encrypting communication between client applications and the database server. Enforcing secure transport helps prevent unauthorized access and data interception, strengthening the overall security posture of the database.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1190,7 +1259,8 @@ "SubSection": "4.1 In-Transit", "AttributeDescription": "Ensure that the minimum TLS version for Azure Storage is set to TLS 1.2 or higher to mitigate security vulnerabilities associated with older TLS versions.", "AdditionalInformation": "TLS 1.0 is outdated and has known security vulnerabilities that can expose data in transit to attacks and interception. Upgrading to TLS 1.2 or later enhances encryption security, ensuring secure communication between clients and Azure Storage while reducing the risk of data compromise.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -1207,7 +1277,8 @@ "SubSection": "4.1 In-Transit", "AttributeDescription": "Configure Azure App Service to enforce HTTPS-only traffic, ensuring that all HTTP requests are automatically redirected to HTTPS for secure communication.", "AdditionalInformation": "Enforcing HTTPS protects data in transit by using TLS/SSL encryption, preventing man-in-the-middle attacks and ensuring secure authentication. Redirecting non-secure HTTP traffic to HTTPS enhances the confidentiality and integrity of application communications, reducing exposure to security vulnerabilities.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1224,7 +1295,8 @@ "SubSection": "4.1 In-Transit", "AttributeDescription": "Ensure that Azure App Service is configured to use TLS 1.2 or higher to secure data transmission and align with industry security standards.", "AdditionalInformation": "TLS 1.0 and 1.1 are outdated protocols with known vulnerabilities that expose web applications to security threats, including data interception and man-in-the-middle attacks. TLS 1.2 is the recommended encryption standard by industry frameworks such as PCI DSS, providing stronger encryption and improved security for web app connections.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1241,7 +1313,8 @@ "SubSection": "4.1 In-Transit", "AttributeDescription": "Ensure that Azure App Service is configured to use the latest supported HTTP version to benefit from security improvements, performance enhancements, and new functionalities.", "AdditionalInformation": "Newer HTTP versions provide security enhancements, performance optimizations, and better resource management. HTTP/2, for example, improves efficiency by addressing issues such as head-of-line blocking, header compression, and request prioritization. Keeping apps updated to the latest HTTP version ensures they leverage modern security protocols and enhanced data transmission capabilities while maintaining compatibility and performance standards.", - "LevelOfRisk": 1 + "LevelOfRisk": 1, + "Weight": 1 } ] }, @@ -1258,7 +1331,8 @@ "SubSection": "4.2 At-Rest", "AttributeDescription": "Enable encryption at rest using Customer Managed Keys (CMK) instead of Microsoft Managed Keys to maintain greater control over sensitive data encryption in Azure Storage accounts.", "AdditionalInformation": "By default, Azure encrypts all storage resources (blobs, disks, files, queues, and tables) using Microsoft Managed Keys. However, using Customer Managed Keys (CMK) allows organizations to control, manage, and rotate their encryption keys through Azure Key Vault, ensuring compliance with security policies. CMKs also support automatic key version updates for enhanced security. Since this recommendation applies specifically to storage accounts holding critical data, its assessment remains manual rather than automated.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1275,7 +1349,8 @@ "SubSection": "4.2 At-Rest", "AttributeDescription": "Enable Transparent Data Encryption (TDE) with Customer-Managed Keys (CMK) to enhance control, security, and separation of duties over the TDE Protector. TDE encrypts data at rest using a database encryption key (DEK). Traditionally, DEKs were protected by Azure SQL Service-managed certificates, but with Customer-Managed Key support, the DEK can now be secured using an asymmetric key stored in Azure Key Vault. Azure Key Vault provides centralized key management, FIPS 140-2 Level 2 validated HSMs, and additional security through key and data separation. Based on business needs and data sensitivity, organizations should use Customer-Managed Keys for TDE encryption.", "AdditionalInformation": "Using Customer-Managed Keys for TDE gives organizations full control over encryption keys, including who can access them and when. Azure Key Vault serves as a secure external key management system, ensuring that TDE encryption keys remain protected from unauthorized access. When configured, the asymmetric key is set at the server level and inherited by all databases under that server, providing consistent encryption management across the environment.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1292,7 +1367,8 @@ "SubSection": "4.2 At-Rest", "AttributeDescription": "Enable Transparent Data Encryption (TDE) on all Azure SQL Servers to ensure real-time encryption and decryption of databases, backups, and transaction logs at rest. TDE operates seamlessly without requiring modifications to applications.", "AdditionalInformation": "TDE helps protect Azure SQL Databases from unauthorized access and malicious activity by encrypting data at rest. This ensures that database files, backups, and logs remain secure, reducing the risk of data breaches while maintaining operational transparency.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -1309,7 +1385,8 @@ "SubSection": "4.2 At-Rest", "AttributeDescription": "Configure storage accounts used for activity log exports to use Customer Managed Keys (CMK) for enhanced security and access control.", "AdditionalInformation": "Using CMKs for log storage provides additional confidentiality controls, ensuring that only users with read access to the storage account and explicit decrypt permissions can access the logs. This enhances data security by restricting unauthorized access and strengthening compliance with encryption and access management policies.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] } diff --git a/prowler/compliance/gcp/cis_4.0_gcp.json b/prowler/compliance/gcp/cis_4.0_gcp.json new file mode 100644 index 0000000000..827d894789 --- /dev/null +++ b/prowler/compliance/gcp/cis_4.0_gcp.json @@ -0,0 +1,1846 @@ +{ + "Framework": "CIS", + "Version": "4.0", + "Provider": "GCP", + "Description": "The CIS Google Cloud Platform Foundations Benchmark provides prescriptive guidance for configuring security options for a subset of GCP with an emphasis on foundational, testable, and architecture agnostic settings.", + "Requirements": [ + { + "Id": "1.1", + "Description": "Ensure that Corporate Login Credentials are Used", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Use corporate login credentials instead of consumer accounts, such as Gmail accounts.", + "RationaleStatement": "It is recommended fully-managed corporate Google accounts be used for increased visibility, auditing, and controlling access to Cloud Platform resources. Email accounts based outside of the user's organization, such as consumer accounts, should not be used for business purposes.", + "ImpactStatement": "There will be increased overhead as maintaining accounts will now be required. For smaller organizations, this will not be an issue, but will balloon with size.", + "RemediationProcedure": "Remove all consumer Google accounts from IAM policies. Follow the documentation and setup corporate login accounts. **Prevention:** To ensure that no email addresses outside the organization can be granted IAM permissions to its Google Cloud projects, folders or organization, turn on the Organization Policy for `Domain Restricted Sharing`. Learn more at: [https://cloud.google.com/resource-manager/docs/organization-policy/restricting-domains](https://cloud.google.com/resource-manager/docs/organization-policy/restricting-domains)", + "AuditProcedure": "For each Google Cloud Platform project, list the accounts that have been granted access to that project: **From Google Cloud CLI** gcloud projects get-iam-policy PROJECT_ID Also list the accounts added on each folder: gcloud resource-manager folders get-iam-policy FOLDER_ID And list your organization's IAM policy: gcloud organizations get-iam-policy ORGANIZATION_ID No email accounts outside the organization domain should be granted permissions in the IAM policies. This excludes Google-owned service accounts.", + "AdditionalInformation": "", + "References": "https://support.google.com/work/android/answer/6371476:https://cloud.google.com/sdk/gcloud/reference/projects/get-iam-policy:https://cloud.google.com/sdk/gcloud/reference/resource-manager/folders/get-iam-policy:https://cloud.google.com/sdk/gcloud/reference/organizations/get-iam-policy:https://cloud.google.com/resource-manager/docs/organization-policy/restricting-domains:https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints", + "DefaultValue": "By default, no email addresses outside the organization's domain have access to its Google Cloud deployments, but any user email account can be added to the IAM policy for Google Cloud Platform projects, folders, or organizations." + } + ] + }, + { + "Id": "1.2", + "Description": "Ensure that Multi-Factor Authentication is 'Enabled' for All Non-Service Accounts", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Setup multi-factor authentication for Google Cloud Platform accounts.", + "RationaleStatement": "Multi-factor authentication requires more than one mechanism to authenticate a user. This secures user logins from attackers exploiting stolen or weak credentials.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** For each Google Cloud Platform project: 1. Identify non-service accounts. 1. Setup multi-factor authentication for each account.", + "AuditProcedure": "**From Google Cloud Console** For each Google Cloud Platform project, folder, or organization: 1. Identify non-service accounts. 1. Manually verify that multi-factor authentication for each account is set.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/solutions/securing-gcp-account-u2f:https://support.google.com/accounts/answer/185839", + "DefaultValue": "By default, multi-factor authentication is not set." + } + ] + }, + { + "Id": "1.3", + "Description": "Ensure that Security Key Enforcement is Enabled for All Admin Accounts", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Setup Security Key Enforcement for Google Cloud Platform admin accounts.", + "RationaleStatement": "Google Cloud Platform users with Organization Administrator roles have the highest level of privilege in the organization. These accounts should be protected with the strongest form of two-factor authentication: Security Key Enforcement. Ensure that admins use Security Keys to log in instead of weaker second factors like SMS or one-time passwords (OTP). Security Keys are actual physical keys used to access Google Organization Administrator Accounts. They send an encrypted signature rather than a code, ensuring that logins cannot be phished.", + "ImpactStatement": "If an organization administrator loses access to their security key, the user could lose access to their account. For this reason, it is important to set up backup security keys.", + "RemediationProcedure": "1. Identify users with the Organization Administrator role. 2. Setup Security Key Enforcement for each account. Learn more at: [https://cloud.google.com/security-key/](https://cloud.google.com/security-key/)", + "AuditProcedure": "1. Identify users with Organization Administrator privileges: gcloud organizations get-iam-policy ORGANIZATION_ID Look for members granted the role roles/resourcemanager.organizationAdmin. 2. Manually verify that Security Key Enforcement has been enabled for each account.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/security-key/:https://gsuite.google.com/learn-more/key_for_working_smarter_faster_and_more_securely.html", + "DefaultValue": "By default, Security Key Enforcement is not enabled for Organization Administrators." + } + ] + }, + { + "Id": "1.4", + "Description": "Ensure That There Are Only GCP-Managed Service Account Keys for Each Service Account", + "Checks": [ + "iam_sa_no_user_managed_keys" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "User-managed service accounts should not have user-managed keys.", + "RationaleStatement": "Anyone who has access to the keys will be able to access resources through the service account. GCP-managed keys are used by Cloud Platform services such as App Engine and Compute Engine. These keys cannot be downloaded. Google will keep the keys and automatically rotate them on an approximately weekly basis. User-managed keys are created, downloadable, and managed by users. They expire 10 years from creation. For user-managed keys, the user has to take ownership of key management activities which include: - Key storage - Key distribution - Key revocation - Key rotation - Protecting the keys from unauthorized users - Key recovery Even with key owner precautions, keys can be easily leaked by common development malpractices like checking keys into the source code or leaving them in the Downloads directory, or accidentally leaving them on support blogs/channels. It is recommended to prevent user-managed service account keys.", + "ImpactStatement": "Deleting user-managed service account keys may break communication with the applications using the corresponding keys.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the IAM page in the GCP Console using `https://console.cloud.google.com/iam-admin/iam` 2. In the left navigation pane, click `Service accounts`. All service accounts and their corresponding keys are listed. 3. Click the service account. 4. Click the `edit` and delete the keys. **From Google Cloud CLI** To delete a user managed Service Account Key, gcloud iam service-accounts keys delete --iam-account= **Prevention:** You can disable service account key creation through the `Disable service account key creation` Organization policy by visiting [https://console.cloud.google.com/iam-admin/orgpolicies/iam-disableServiceAccountKeyCreation](https://console.cloud.google.com/iam-admin/orgpolicies/iam-disableServiceAccountKeyCreation). Learn more at: [https://cloud.google.com/resource-manager/docs/organization-policy/restricting-service-accounts](https://cloud.google.com/resource-manager/docs/organization-policy/restricting-service-accounts) In addition, if you do not need to have service accounts in your project, you can also prevent the creation of service accounts through the `Disable service account creation` Organization policy: [https://console.cloud.google.com/iam-admin/orgpolicies/iam-disableServiceAccountCreation](https://console.cloud.google.com/iam-admin/orgpolicies/iam-disableServiceAccountCreation).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the IAM page in the GCP Console using `https://console.cloud.google.com/iam-admin/iam` 2. In the left navigation pane, click `Service accounts`. All service accounts and their corresponding keys are listed. 3. Click the service accounts and check if keys exist. **From Google Cloud CLI** List All the service accounts: gcloud iam service-accounts list Identify user-managed service accounts which have an account `EMAIL` ending with `iam.gserviceaccount.com` For each user-managed service account, list the keys managed by the user: gcloud iam service-accounts keys list --iam-account= --managed-by=user No keys should be listed.", + "AdditionalInformation": "A user-managed key cannot be created on GCP-Managed Service Accounts.", + "References": "https://cloud.google.com/iam/docs/understanding-service-accounts#managing_service_account_keys:https://cloud.google.com/resource-manager/docs/organization-policy/restricting-service-accounts", + "DefaultValue": "By default, there are no user-managed keys created for user-managed service accounts." + } + ] + }, + { + "Id": "1.5", + "Description": "Ensure That Service Account Has No Admin Privileges", + "Checks": [ + "iam_sa_no_administrative_privileges" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "A service account is a special Google account that belongs to an application or a VM, instead of to an individual end-user. The application uses the service account to call the service's Google API so that users aren't directly involved. It's recommended not to use admin access for ServiceAccount.", + "RationaleStatement": "Service accounts represent service-level security of the Resources (application or a VM) which can be determined by the roles assigned to it. Enrolling ServiceAccount with Admin rights gives full access to an assigned application or a VM. A ServiceAccount Access holder can perform critical actions like delete, update change settings, etc. without user intervention. For this reason, it's recommended that service accounts not have Admin rights.", + "ImpactStatement": "Removing `*Admin` or `*admin` or `Editor` or `Owner` role assignments from service accounts may break functionality that uses impacted service accounts. Required role(s) should be assigned to impacted service accounts in order to restore broken functionalities.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `IAM & admin/IAM` using `https://console.cloud.google.com/iam-admin/iam` 2. Under the `IAM` Tab look for `VIEW BY PRINCIPALS` 3. Filter `PRINCIPALS` using `type : Service account` 4. Look for the Service Account with the Principal nomenclature: `SERVICE_ACCOUNT_NAME@PROJECT_ID.iam.gserviceaccount.com` 5. Identify `User-Managed user created` service account with roles containing `*Admin` or `*admin` or role matching `Editor` or role matching `Owner` under `Role` Column. 6. Click on `Edit (Pencil Icon)` for the Service Account, it will open all the roles which are assigned to the Service Account. 7. Click the `Delete bin` icon to remove the role from the Principal (service account in this case) **From Google Cloud CLI** gcloud projects get-iam-policy PROJECT_ID --format json > iam.json 1. Using a text editor, Remove `Role` which contains `roles/*Admin` or `roles/*admin` or matched `roles/editor` or matches 'roles/owner`. Add a role to the bindings array that defines the group members and the role for those members. For example, to grant the role roles/appengine.appViewer to the `ServiceAccount` which is roles/editor, you would change the example shown below as follows: { bindings: [ { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, ], role: roles/appengine.appViewer }, { members: [ user:email1@gmail.com ], role: roles/owner }, { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, serviceAccount:123456789012-compute@developer.gserviceaccount.com ], role: roles/editor } ], etag: BwUjMhCsNvY= } 2. Update the project's IAM policy: gcloud projects set-iam-policy PROJECT_ID iam.json ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `IAM & admin/IAM` using `https://console.cloud.google.com/iam-admin/iam` 2. Under the `IAM` Tab look for `VIEW BY PRINCIPALS` 3. Filter `PRINCIPALS` using `type : Service account` 4. Look for the Service Account with the nomenclature: `SERVICE_ACCOUNT_NAME@PROJECT_ID.iam.gserviceaccount.com` 5. Ensure that there are no such Service Accounts with roles containing `*Admin` or `*admin` or role matching `Editor` or role matching `Owner` under `Role` column. **From Google Cloud CLI** 1. Get the policy that you want to modify, and write it to a JSON file: gcloud projects get-iam-policy PROJECT_ID --format json > iam.json 2. The contents of the JSON file will look similar to the following. Note that `role` of members group associated with each `serviceaccount` does not contain `*Admin` or `*admin` or does not match `roles/editor` or does not match `roles/owner`. This recommendation is only applicable to `User-Managed user-created` service accounts. These accounts have the nomenclature: `SERVICE_ACCOUNT_NAME@PROJECT_ID.iam.gserviceaccount.com`. Note that some Google-managed, Google-created service accounts have the same naming format, and should be excluded (e.g., `appsdev-apps-dev-script-auth@system.gserviceaccount.com` which needs the Owner role). **Sample Json output:** { bindings: [ { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, ], role: roles/appengine.appAdmin }, { members: [ user:email1@gmail.com ], role: roles/owner }, { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, serviceAccount:123456789012-compute@developer.gserviceaccount.com ], role: roles/editor } ], etag: BwUjMhCsNvY=, version: 1 }", + "AdditionalInformation": "Default (user-managed but not user-created) service accounts have the `Editor (roles/editor)` role assigned to them to support GCP services they offer. Such Service accounts are: `PROJECT_NUMBER-compute@developer.gserviceaccount.com`, `PROJECT_ID@appspot.gserviceaccount.com`.", + "References": "https://cloud.google.com/sdk/gcloud/reference/iam/service-accounts/:https://cloud.google.com/iam/docs/understanding-roles:https://cloud.google.com/iam/docs/understanding-service-accounts", + "DefaultValue": "User Managed (and not user-created) default service accounts have the `Editor (roles/editor)` role assigned to them to support GCP services they offer. By default, there are no roles assigned to `User Managed User created` service accounts." + } + ] + }, + { + "Id": "1.6", + "Description": "Ensure That IAM Users Are Not Assigned the Service Account User or Service Account Token Creator Roles at Project Level", + "Checks": [ + "iam_no_service_roles_at_project_level" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to assign the `Service Account User (iam.serviceAccountUser)` and `Service Account Token Creator (iam.serviceAccountTokenCreator)` roles to a user for a specific service account rather than assigning the role to a user at project level.", + "RationaleStatement": "A service account is a special Google account that belongs to an application or a virtual machine (VM), instead of to an individual end-user. Application/VM-Instance uses the service account to call the service's Google API so that users aren't directly involved. In addition to being an identity, a service account is a resource that has IAM policies attached to it. These policies determine who can use the service account. Users with IAM roles to update the App Engine and Compute Engine instances (such as App Engine Deployer or Compute Instance Admin) can effectively run code as the service accounts used to run these instances, and indirectly gain access to all the resources for which the service accounts have access. Similarly, SSH access to a Compute Engine instance may also provide the ability to execute code as that instance/Service account. Based on business needs, there could be multiple user-managed service accounts configured for a project. Granting the `iam.serviceAccountUser` or `iam.serviceAccountTokenCreator` roles to a user for a project gives the user access to all service accounts in the project, including service accounts that may be created in the future. This can result in elevation of privileges by using service accounts and corresponding `Compute Engine instances`. In order to implement `least privileges` best practices, IAM users should not be assigned the `Service Account User` or `Service Account Token Creator` roles at the project level. Instead, these roles should be assigned to a user for a specific service account, giving that user access to the service account. The `Service Account User` allows a user to bind a service account to a long-running job service, whereas the `Service Account Token Creator` role allows a user to directly impersonate (or assert) the identity of a service account.", + "ImpactStatement": "After revoking `Service Account User` or `Service Account Token Creator` roles at the project level from all impacted user account(s), these roles should be assigned to a user(s) for specific service account(s) according to business needs.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the IAM page in the GCP Console by visiting: [https://console.cloud.google.com/iam-admin/iam](https://console.cloud.google.com/iam-admin/iam). 2. Click on the filter table text bar. Type `Role: Service Account User` 3. Click the `Delete Bin` icon in front of the role `Service Account User` for every user listed as a result of a filter. 4. Click on the filter table text bar. Type `Role: Service Account Token Creator` 5. Click the `Delete Bin` icon in front of the role `Service Account Token Creator` for every user listed as a result of a filter. **From Google Cloud CLI** 1. Using a text editor, remove the bindings with the `roles/iam.serviceAccountUser` or `roles/iam.serviceAccountTokenCreator`. For example, you can use the iam.json file shown below as follows: { bindings: [ { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, ], role: roles/appengine.appViewer }, { members: [ user:email1@gmail.com ], role: roles/owner }, { members: [ serviceAccount:our-project-123@appspot.gserviceaccount.com, serviceAccount:123456789012-compute@developer.gserviceaccount.com ], role: roles/editor } ], etag: BwUjMhCsNvY= } 2. Update the project's IAM policy: gcloud projects set-iam-policy PROJECT_ID iam.json ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the IAM page in the GCP Console by visiting [https://console.cloud.google.com/iam-admin/iam](https://console.cloud.google.com/iam-admin/iam) 2. Click on the filter table text bar, Type `Role: Service Account User`. 3. Ensure no user is listed as a result of the filter. 4. Click on the filter table text bar, Type `Role: Service Account Token Creator`. 3. Ensure no user is listed as a result of the filter. **From Google Cloud CLI** To ensure IAM users are not assigned Service Account User role at the project level: gcloud projects get-iam-policy PROJECT_ID --format json | jq '.bindings[].role' | grep roles/iam.serviceAccountUser gcloud projects get-iam-policy PROJECT_ID --format json | jq '.bindings[].role' | grep roles/iam.serviceAccountTokenCreator These commands should not return any output.", + "AdditionalInformation": "To assign the role `roles/iam.serviceAccountUser` or `roles/iam.serviceAccountTokenCreator` to a user role on a service account instead of a project: 1. Go to [https://console.cloud.google.com/projectselector/iam-admin/serviceaccounts](https://console.cloud.google.com/projectselector/iam-admin/serviceaccounts) 2. Select ` Target Project` 3. Select `target service account`. Click `Permissions` on the top bar. It will open permission pane on right side of the page 4. Add desired members with `Service Account User` or `Service Account Token Creator` role.", + "References": "https://cloud.google.com/iam/docs/service-accounts:https://cloud.google.com/iam/docs/granting-roles-to-service-accounts:https://cloud.google.com/iam/docs/understanding-roles:https://cloud.google.com/iam/docs/granting-changing-revoking-access:https://console.cloud.google.com/iam-admin/iam", + "DefaultValue": "By default, users do not have the Service Account User or Service Account Token Creator role assigned at project level." + } + ] + }, + { + "Id": "1.7", + "Description": "Ensure User-Managed/External Keys for Service Accounts Are Rotated Every 90 Days or Fewer", + "Checks": [ + "iam_sa_user_managed_key_rotate_90_days" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Service Account keys consist of a key ID (Private_key_Id) and Private key, which are used to sign programmatic requests users make to Google cloud services accessible to that particular service account. It is recommended that all Service Account keys are regularly rotated.", + "RationaleStatement": "Rotating Service Account keys will reduce the window of opportunity for an access key that is associated with a compromised or terminated account to be used. Service Account keys should be rotated to ensure that data cannot be accessed with an old key that might have been lost, cracked, or stolen. Each service account is associated with a key pair managed by Google Cloud Platform (GCP). It is used for service-to-service authentication within GCP. Google rotates the keys daily. GCP provides the option to create one or more user-managed (also called external key pairs) key pairs for use from outside GCP (for example, for use with Application Default Credentials). When a new key pair is created, the user is required to download the private key (which is not retained by Google). With external keys, users are responsible for keeping the private key secure and other management operations such as key rotation. External keys can be managed by the IAM API, gcloud command-line tool, or the Service Accounts page in the Google Cloud Platform Console. GCP facilitates up to 10 external service account keys per service account to facilitate key rotation.", + "ImpactStatement": "Rotating service account keys will break communication for dependent applications. Dependent applications need to be configured manually with the new key `ID` displayed in the `Service account keys` section and the `private key` downloaded by the user.", + "RemediationProcedure": "**From Google Cloud Console** **Delete any external (user-managed) Service Account Key older than 90 days:** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the Section `Service Account Keys`, for every external (user-managed) service account key where `creation date` is greater than or equal to the past 90 days, click `Delete Bin Icon` to `Delete Service Account key` **Create a new external (user-managed) Service Account Key for a Service Account:** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. Click `Create Credentials` and Select `Service Account Key`. 3. Choose the service account in the drop-down list for which an External (user-managed) Service Account key needs to be created. 4. Select the desired key type format among `JSON` or `P12`. 5. Click `Create`. It will download the `private key`. Keep it safe. 6. Click `Close` if prompted. 7. The site will redirect to the `APIs & ServicesCredentials` page. Make a note of the new `ID` displayed in the `Service account keys` section.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `Service Account Keys`, for every External (user-managed) service account key listed ensure the `creation date` is within the past 90 days. **From Google Cloud CLI** 1. List all Service accounts from a project. gcloud iam service-accounts list 2. For every service account list service account keys. gcloud iam service-accounts keys list --iam-account [Service_Account_Email_Id] --format=json 3. Ensure every service account key for a service account has a `validAfterTime` value within the past 90 days.", + "AdditionalInformation": "For user-managed Service Account key(s), key management is entirely the user's responsibility.", + "References": "https://cloud.google.com/iam/docs/understanding-service-accounts#managing_service_account_keys:https://cloud.google.com/sdk/gcloud/reference/iam/service-accounts/keys/list:https://cloud.google.com/iam/docs/service-accounts", + "DefaultValue": "GCP does not provide an automation option for External (user-managed) Service key rotation." + } + ] + }, + { + "Id": "1.8", + "Description": "Ensure That Separation of Duties Is Enforced While Assigning Service Account Related Roles to Users", + "Checks": [ + "iam_role_kms_enforce_separation_of_duties" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that the principle of 'Separation of Duties' is enforced while assigning service-account related roles to users.", + "RationaleStatement": "The built-in/predefined IAM role `Service Account admin` allows the user/identity to create, delete, and manage service account(s). The built-in/predefined IAM role `Service Account User` allows the user/identity (with adequate privileges on Compute and App Engine) to assign service account(s) to Apps/Compute Instances. Separation of duties is the concept of ensuring that one individual does not have all necessary permissions to be able to complete a malicious action. In Cloud IAM - service accounts, this could be an action such as using a service account to access resources that user should not normally have access to. Separation of duties is a business control typically used in larger organizations, meant to help avoid security or privacy incidents and errors. It is considered best practice. No user should have `Service Account Admin` and `Service Account User` roles assigned at the same time.", + "ImpactStatement": "The removed role should be assigned to a different user based on business needs.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `IAM & Admin/IAM` using `https://console.cloud.google.com/iam-admin/iam`. 2. For any member having both `Service Account Admin` and `Service account User` roles granted/assigned, click the `Delete Bin` icon to remove either role from the member. Removal of a role should be done based on the business requirements.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `IAM & Admin/IAM` using `https://console.cloud.google.com/iam-admin/iam`. 2. Ensure no member has the roles `Service Account Admin` and `Service account User` assigned together. **From Google Cloud CLI** 1. List all users and role assignments: gcloud projects get-iam-policy [Project_ID] --format json | jq -r '[ ([Service_Account_Admin_and_User] | (., map(length*-))), ( [ .bindings[] | select(.role == roles/iam.serviceAccountAdmin or .role == roles/iam.serviceAccountUser).members[] ] | group_by(.) | map({User: ., Count: length}) | .[] | select(.Count == 2).User | unique ) ] | .[] | @tsv' 2. All common users listed under `Service_Account_Admin_and_User` are assigned both the `roles/iam.serviceAccountAdmin` and `roles/iam.serviceAccountUser` roles.", + "AdditionalInformation": "Users granted with Owner (roles/owner) and Editor (roles/editor) have privileges equivalent to `Service Account Admin` and `Service Account User`. To avoid the misuse, Owner and Editor roles should be granted to very limited users and Use of these primitive privileges should be minimal. These requirements are addressed in separate recommendations.", + "References": "https://cloud.google.com/iam/docs/service-accounts:https://cloud.google.com/iam/docs/understanding-roles:https://cloud.google.com/iam/docs/granting-roles-to-service-accounts", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.9", + "Description": "Ensure That Cloud KMS Cryptokeys Are Not Anonymously or Publicly Accessible", + "Checks": [ + "kms_key_not_publicly_accessible" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that the IAM policy on Cloud KMS `cryptokeys` should restrict anonymous and/or public access.", + "RationaleStatement": "Granting permissions to `allUsers` or `allAuthenticatedUsers` allows anyone to access the dataset. Such access might not be desirable if sensitive data is stored at the location. In this case, ensure that anonymous and/or public access to a Cloud KMS `cryptokey` is not allowed.", + "ImpactStatement": "Removing the binding for `allUsers` and `allAuthenticatedUsers` members denies accessing `cryptokeys` to anonymous or public users.", + "RemediationProcedure": "**From Google Cloud CLI** 1. List all Cloud KMS `Cryptokeys`. gcloud kms keys list --keyring=[key_ring_name] --location=global --format=json | jq '.[].name' 2. Remove IAM policy binding for a KMS key to remove access to `allUsers` and `allAuthenticatedUsers` using the below command. gcloud kms keys remove-iam-policy-binding [key_name] --keyring=[key_ring_name] --location=global --member='allAuthenticatedUsers' --role='[role]' gcloud kms keys remove-iam-policy-binding [key_name] --keyring=[key_ring_name] --location=global --member='allUsers' --role='[role]' ", + "AuditProcedure": "**From Google Cloud CLI** 1. List all Cloud KMS `Cryptokeys`. gcloud kms keys list --keyring=[key_ring_name] --location=global --format=json | jq '.[].name' 2. Ensure the below command's output does not contain `allUsers` or `allAuthenticatedUsers`. gcloud kms keys get-iam-policy [key_name] --keyring=[key_ring_name] --location=global --format=json | jq '.bindings[].members[]' ", + "AdditionalInformation": "[key_ring_name] : Is the resource ID of the key ring, which is the fully-qualified Key ring name. This value is case-sensitive and in the form: projects/PROJECT_ID/locations/LOCATION/keyRings/KEY_RING You can retrieve the key ring resource ID using the Cloud Console: 1. Open the `Cryptographic Keys` page in the Cloud Console. 2. For the key ring whose resource ID you are retrieving, click the `More icon (3 vertical dots)`. 3. Click `Copy Resource ID`. The resource ID for the key ring is copied to your clipboard. [key_name] : Is the resource ID of the key, which is the fully-qualified CryptoKey name. This value is case-sensitive and in the form: projects/PROJECT_ID/locations/LOCATION/keyRings/KEY_RING/cryptoKeys/KEY You can retrieve the key resource ID using the Cloud Console: 1. Open the `Cryptographic Keys` page in the Cloud Console. 2. Click the name of the key ring that contains the key. 3. For the key whose resource ID you are retrieving, click the `More icon (3 vertical dots)`. 4. Click `Copy Resource ID`. The resource ID for the key is copied to your clipboard. [role] : The role to remove the member from.", + "References": "https://cloud.google.com/sdk/gcloud/reference/kms/keys/remove-iam-policy-binding:https://cloud.google.com/sdk/gcloud/reference/kms/keys/set-iam-policy:https://cloud.google.com/sdk/gcloud/reference/kms/keys/get-iam-policy:https://cloud.google.com/kms/docs/object-hierarchy#key_resource_id", + "DefaultValue": "By default Cloud KMS does not allow access to `allUsers` or `allAuthenticatedUsers`." + } + ] + }, + { + "Id": "1.10", + "Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days", + "Checks": [ + "kms_key_rotation_enabled" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Google Cloud Key Management Service stores cryptographic keys in a hierarchical structure designed for useful and elegant access control management. The format for the rotation schedule depends on the client library that is used. For the gcloud command-line tool, the next rotation time must be in `ISO` or `RFC3339` format, and the rotation period must be in the form `INTEGER[UNIT]`, where units can be one of seconds (s), minutes (m), hours (h) or days (d).", + "RationaleStatement": "Set a key rotation period and starting time. A key can be created with a specified `rotation period`, which is the time between when new key versions are generated automatically. A key can also be created with a specified next rotation time. A key is a named object representing a `cryptographic key` used for a specific purpose. The key material, the actual bits used for `encryption`, can change over time as new key versions are created. A key is used to protect some `corpus of data`. A collection of files could be encrypted with the same key and people with `decrypt` permissions on that key would be able to decrypt those files. Therefore, it's necessary to make sure the `rotation period` is set to a specific time.", + "ImpactStatement": "After a successful key rotation, the older key version is required in order to decrypt the data encrypted by that previous key version.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Cryptographic Keys` by visiting: [https://console.cloud.google.com/security/kms](https://console.cloud.google.com/security/kms). 2. Click on the specific key ring 3. From the list of keys, choose the specific key and Click on `Right side pop up the blade (3 dots)`. 4. Click on `Edit rotation period`. 5. On the pop-up window, `Select a new rotation period` in days which should be less than 90 and then choose `Starting on` date (date from which the rotation period begins). **From Google Cloud CLI** 1. Update and schedule rotation by `ROTATION_PERIOD` and `NEXT_ROTATION_TIME` for each key: gcloud kms keys update new --keyring=KEY_RING --location=LOCATION --next-rotation-time=NEXT_ROTATION_TIME --rotation-period=ROTATION_PERIOD ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Cryptographic Keys` by visiting: [https://console.cloud.google.com/security/kms](https://console.cloud.google.com/security/kms). 2. Click on each key ring, then ensure each key in the keyring has `Next Rotation` set for less than 90 days from the current date. **From Google Cloud CLI** 1. Ensure rotation is scheduled by `ROTATION_PERIOD` and `NEXT_ROTATION_TIME` for each key : gcloud kms keys list --keyring= --location= --format=json'(rotationPeriod)' Ensure outcome values for `rotationPeriod` and `nextRotationTime` satisfy the below criteria: `rotationPeriod is <= 129600m` `rotationPeriod is <= 7776000s` `rotationPeriod is <= 2160h` `rotationPeriod is <= 90d` `nextRotationTime is <= 90days` from current DATE", + "AdditionalInformation": "- Key rotation does NOT re-encrypt already encrypted data with the newly generated key version. If you suspect unauthorized use of a key, you should re-encrypt the data protected by that key and then disable or schedule destruction of the prior key version. - It is not recommended to rely solely on irregular rotation, but rather to use irregular rotation if needed in conjunction with a regular rotation schedule.", + "References": "https://cloud.google.com/kms/docs/key-rotation#frequency_of_key_rotation:https://cloud.google.com/kms/docs/re-encrypt-data", + "DefaultValue": "By default, KMS encryption keys are rotated every 90 days." + } + ] + }, + { + "Id": "1.11", + "Description": "Ensure That Separation of Duties Is Enforced While Assigning KMS Related Roles to Users", + "Checks": [ + "iam_role_kms_enforce_separation_of_duties" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that the principle of 'Separation of Duties' is enforced while assigning KMS related roles to users.", + "RationaleStatement": "The built-in/predefined IAM role `Cloud KMS Admin` allows the user/identity to create, delete, and manage service account(s). The built-in/predefined IAM role `Cloud KMS CryptoKey Encrypter/Decrypter` allows the user/identity (with adequate privileges on concerned resources) to encrypt and decrypt data at rest using an encryption key(s). The built-in/predefined IAM role `Cloud KMS CryptoKey Encrypter` allows the user/identity (with adequate privileges on concerned resources) to encrypt data at rest using an encryption key(s). The built-in/predefined IAM role `Cloud KMS CryptoKey Decrypter` allows the user/identity (with adequate privileges on concerned resources) to decrypt data at rest using an encryption key(s). Separation of duties is the concept of ensuring that one individual does not have all necessary permissions to be able to complete a malicious action. In Cloud KMS, this could be an action such as using a key to access and decrypt data a user should not normally have access to. Separation of duties is a business control typically used in larger organizations, meant to help avoid security or privacy incidents and errors. It is considered best practice. No user(s) should have `Cloud KMS Admin` and any of the `Cloud KMS CryptoKey Encrypter/Decrypter`, `Cloud KMS CryptoKey Encrypter`, `Cloud KMS CryptoKey Decrypter` roles assigned at the same time.", + "ImpactStatement": "Removed roles should be assigned to another user based on business needs.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `IAM & Admin/IAM` using `https://console.cloud.google.com/iam-admin/iam` 2. For any member having `Cloud KMS Admin` and any of the `Cloud KMS CryptoKey Encrypter/Decrypter`, `Cloud KMS CryptoKey Encrypter`, `Cloud KMS CryptoKey Decrypter` roles granted/assigned, click the `Delete Bin` icon to remove the role from the member. Note: Removing a role should be done based on the business requirement.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `IAM & Admin/IAM` by visiting: [https://console.cloud.google.com/iam-admin/iam](https://console.cloud.google.com/iam-admin/iam) 2. Ensure no member has the roles `Cloud KMS Admin` and any of the `Cloud KMS CryptoKey Encrypter/Decrypter`, `Cloud KMS CryptoKey Encrypter`, `Cloud KMS CryptoKey Decrypter` assigned. **From Google Cloud CLI** 1. List all users and role assignments: gcloud projects get-iam-policy PROJECT_ID 2. Ensure that there are no common users found in the member section for roles `cloudkms.admin` and any one of `Cloud KMS CryptoKey Encrypter/Decrypter`, `Cloud KMS CryptoKey Encrypter`, `Cloud KMS CryptoKey Decrypter`", + "AdditionalInformation": "Users granted with Owner (roles/owner) and Editor (roles/editor) have privileges equivalent to `Cloud KMS Admin` and `Cloud KMS CryptoKey Encrypter/Decrypter`. To avoid misuse, Owner and Editor roles should be granted to a very limited group of users. Use of these primitive privileges should be minimal.", + "References": "https://cloud.google.com/kms/docs/separation-of-duties", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.12", + "Description": "Ensure API Keys Only Exist for Active Services", + "Checks": [ + "apikeys_key_exists" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "API Keys should only be used for services in cases where other authentication methods are unavailable. Unused keys with their permissions in tact may still exist within a project. Keys are insecure because they can be viewed publicly, such as from within a browser, or they can be accessed on a device where the key resides. It is recommended to use standard authentication flow instead.", + "RationaleStatement": "To avoid the security risk in using API keys, it is recommended to use standard authentication flow instead. Security risks involved in using API-Keys appear below: - API keys are simple encrypted strings - API keys do not identify the user or the application making the API request - API keys are typically accessible to clients, making it easy to discover and steal an API key", + "ImpactStatement": "Deleting an API key will break dependent applications (if any).", + "RemediationProcedure": "**From Console:** 1. Go to `APIs & ServicesCredentials` using https://console.cloud.google.com/apis/credentials. 1. In the section `API Keys`, to delete API Keys: Click the `Delete Bin Icon` in front of every `API Key Name`. **From Google Cloud Command Line** 1. Run the following from within the project you wish to audit gcloud services api-keys list --filter 1. Run the following command, providing the ID of the key or fully qualified identifier for the key for : gcloud services api-keys delete ", + "AuditProcedure": "**From Console:** 1. From within the Project you wish to audit Go to `APIs & ServicesCredentials`. 1. In the section `API Keys`, no API key should be listed. **From Google Cloud Command Line** 1. Run the following from within the project you wish to audit gcloud services api-keys list --filter 1. There should be no keys listed at the project level.", + "AdditionalInformation": "Google recommends using the standard authentication flow instead of using API keys. However, there are limited cases where API keys are more appropriate. For example, if there is a mobile application that needs to use the Google Cloud Translation API, but doesn't otherwise need a backend server, API keys are the simplest way to authenticate to that API. If a business requires API keys to be used, then the API keys should be secured properly.", + "References": "https://cloud.google.com/docs/authentication/api-keys:https://cloud.google.com/sdk/gcloud/reference/services/api-keys/list:https://cloud.google.com/docs/authentication:https://cloud.google.com/sdk/gcloud/reference/services/api-keys/delete", + "DefaultValue": "By default, API keys are not created for a project." + } + ] + }, + { + "Id": "1.13", + "Description": "Ensure API Keys Are Restricted To Use by Only Specified Hosts and Apps", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "API Keys should only be used for services in cases where other authentication methods are unavailable. In this case, unrestricted keys are insecure because they can be viewed publicly, such as from within a browser, or they can be accessed on a device where the key resides. It is recommended to restrict API key usage to trusted hosts, HTTP referrers and apps. It is recommended to use the more secure standard authentication flow instead.", + "RationaleStatement": "Security risks involved in using API-Keys appear below: - API keys are simple encrypted strings - API keys do not identify the user or the application making the API request - API keys are typically accessible to clients, making it easy to discover and steal an API key In light of these potential risks, Google recommends using the standard authentication flow instead of API keys. However, there are limited cases where API keys are more appropriate. For example, if there is a mobile application that needs to use the Google Cloud Translation API, but doesn't otherwise need a backend server, API keys are the simplest way to authenticate to that API. In order to reduce attack vectors, API-Keys can be restricted only to trusted hosts, HTTP referrers and applications.", + "ImpactStatement": "Setting `Application Restrictions` may break existing application functioning, if not done carefully.", + "RemediationProcedure": "**From Google Cloud Console** ***Leaving Keys in Place*** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, Click the `API Key Name`. The API Key properties display on a new page. 3. In the `Key restrictions` section, set the application restrictions to any of `HTTP referrers, IP addresses, Android apps, iOS apps`. 4. Click `Save`. 1. Repeat steps 2,3,4 for every unrestricted API key. **Note:** Do not set `HTTP referrers` to wild-cards (* or *.[TLD] or *.[TLD]/*) allowing access to any/wide HTTP referrer(s) Do not set `IP addresses` and referrer to `any host (0.0.0.0 or 0.0.0.0/0 or ::0)` ***Removing Keys*** Another option is to remove the keys entirely. 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, select the checkbox next to each key you wish to remove 3. Select `Delete` and confirm.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 1. In the section `API Keys`, Click the `API Key Name`. The API Key properties display on a new page. 1. For every API Key, ensure the section `Key restrictions` parameter `Application restrictions` is not set to `None`. Or, 1. Ensure `Application restrictions` is set to `HTTP referrers` and the referrer is not set to wild-cards `(* or *.[TLD] or *.[TLD]/*) allowing access to any/wide HTTP referrer(s)` Or, 1. Ensure `Application restrictions` is set to `IP addresses` and referrer is not set to `any host (0.0.0.0 or 0.0.0.0/0 or ::0)` **From Google Cloud Command Line** 1. Run the following from within the project you wish to audit gcloud services api-keys list --filter=-restrictions:* --format=table[box](displayName:label='Key With No Restrictions') ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/docs/authentication/api-keys:https://cloud.google.com/sdk/gcloud/reference/services/api-keys/list:https://cloud.google.com/sdk/gcloud/reference/services/api-keys/update", + "DefaultValue": "By default, `Application Restrictions` are set to `None`." + } + ] + }, + { + "Id": "1.14", + "Description": "Ensure API Keys Are Restricted to Only APIs That Application Needs Access", + "Checks": [ + "apikeys_api_restrictions_configured" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "API Keys should only be used for services in cases where other authentication methods are unavailable. API keys are always at risk because they can be viewed publicly, such as from within a browser, or they can be accessed on a device where the key resides. It is recommended to restrict API keys to use (call) only APIs required by an application.", + "RationaleStatement": "Security risks involved in using API-Keys are below: - API keys are simple encrypted strings - API keys do not identify the user or the application making the API request - API keys are typically accessible to clients, making it easy to discover and steal an API key In light of these potential risks, Google recommends using the standard authentication flow instead of API-Keys. However, there are limited cases where API keys are more appropriate. For example, if there is a mobile application that needs to use the Google Cloud Translation API, but doesn't otherwise need a backend server, API keys are the simplest way to authenticate to that API. In order to reduce attack surfaces by providing `least privileges`, API-Keys can be restricted to use (call) only APIs required by an application.", + "ImpactStatement": "Setting `API restrictions` may break existing application functioning, if not done carefully.", + "RemediationProcedure": "**From Console:** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, Click the `API Key Name`. The API Key properties display on a new page. 3. In the `Key restrictions` section go to `API restrictions`. 4. Click the `Select API` drop-down to choose an API. 5. Click `Save`. 6. Repeat steps 2,3,4,5 for every unrestricted API key **Note:** Do not set `API restrictions` to `Google Cloud APIs`, as this option allows access to all services offered by Google cloud. **From Google Cloud CLI** 1. List all API keys. gcloud services api-keys list 2. Note the `UID` of the key to add restrictions to. 3. Run the update command with the appropriate API target service or flags file with API target services and methods to add the required restrictions. Command with appropriate API target service: gcloud services api-keys update --api-target=service= Command with flags file: gcloud services api-keys update --flags-file=.yaml Content of flags file: - --api-target: service: foo.service.com - --api-target: service: bar.service.com methods: - foomethod - barmethod Note: Flags can be found by running: gcloud services api-keys update --help Note: Services can be found by running: gcloud services list or in this documentation https://cloud.google.com/sdk/gcloud/reference/services/api-keys/update", + "AuditProcedure": "**From Console:** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, Click the `API Key Name`. The API Key properties display on a new page. 3. For every API Key, ensure the section `Key restrictions` parameter `API restrictions` is not set to `None`. Or, Ensure `API restrictions` is not set to `Google Cloud APIs` **Note:** `Google Cloud APIs` represents the API collection of all cloud services/APIs offered by Google cloud. **From Google Cloud CLI** 1. List all API Keys. gcloud services api-keys list Each key should have a line that says `restrictions:` followed by varying parameters and NOT have a line saying `- service: cloudapis.googleapis.com` as shown here restrictions: apiTargets: - service: cloudapis.googleapis.com ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/docs/authentication/api-keys:https://cloud.google.com/apis/docs/overview", + "DefaultValue": "By default, `API restrictions` are set to `None`." + } + ] + }, + { + "Id": "1.15", + "Description": "Ensure API Keys Are Rotated Every 90 Days", + "Checks": [ + "apikeys_key_rotated_in_90_days" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "API Keys should only be used for services in cases where other authentication methods are unavailable. If they are in use it is recommended to rotate API keys every 90 days.", + "RationaleStatement": "Security risks involved in using API-Keys are listed below: - API keys are simple encrypted strings - API keys do not identify the user or the application making the API request - API keys are typically accessible to clients, making it easy to discover and steal an API key Because of these potential risks, Google recommends using the standard authentication flow instead of API Keys. However, there are limited cases where API keys are more appropriate. For example, if there is a mobile application that needs to use the Google Cloud Translation API, but doesn't otherwise need a backend server, API keys are the simplest way to authenticate to that API. Once a key is stolen, it has no expiration, meaning it may be used indefinitely unless the project owner revokes or regenerates the key. Rotating API keys will reduce the window of opportunity for an access key that is associated with a compromised or terminated account to be used. API keys should be rotated to ensure that data cannot be accessed with an old key that might have been lost, cracked, or stolen.", + "ImpactStatement": "`Regenerating Key` may break existing client connectivity as the client will try to connect with older API keys they have stored on devices.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, Click the `API Key Name`. The API Key properties display on a new page. 3. Click `REGENERATE KEY` to rotate API key. 4. Click `Save`. 5. Repeat steps 2,3,4 for every API key that has not been rotated in the last 90 days. **Note:** Do not set `HTTP referrers` to wild-cards (* or *.[TLD] or *.[TLD]/*) allowing access to any/wide HTTP referrer(s) Do not set `IP addresses` and referrer to `any host (0.0.0.0 or 0.0.0.0/0 or ::0)` **From Google Cloud CLI** There is not currently a way to regenerate and API key using gcloud commands. To 'regenerate' a key you will need to create a new one, duplicate the restrictions from the key being rotated, and delete the old key. 1. List existing keys. gcloud services api-keys list 2. Note the `UID` and restrictions of the key to regenerate. 3. Run this command to create a new API key. is the display name of the new key. ` gcloud services api-keys create --display-name= ` Note the `UID` of the newly created key 4. Run the update command to add required restrictions. Note - the restriction may vary for each key. Refer to this documentation for the appropriate flags. https://cloud.google.com/sdk/gcloud/reference/services/api-keys/update gcloud services api-keys update 5. Delete the old key. gcloud services api-keys delete ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `APIs & ServicesCredentials` using `https://console.cloud.google.com/apis/credentials` 2. In the section `API Keys`, for every key ensure the `creation date` is less than 90 days. **From Google Cloud CLI** To list keys, use the command gcloud services api-keys list Ensure the date in `createTime` is within 90 days.", + "AdditionalInformation": "There is no option to automatically regenerate (rotate) API keys periodically.", + "References": "https://developers.google.com/maps/api-security-best-practices#regenerate-apikey:https://cloud.google.com/sdk/gcloud/reference/services/api-keys/update", + "DefaultValue": "" + } + ] + }, + { + "Id": "1.16", + "Description": "Ensure Essential Contacts is Configured for Organization", + "Checks": [ + "iam_organization_essential_contacts_configured" + ], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that Essential Contacts is configured to designate email addresses for Google Cloud services to notify of important technical or security information.", + "RationaleStatement": "Many Google Cloud services, such as Cloud Billing, send out notifications to share important information with Google Cloud users. By default, these notifications are sent to members with certain Identity and Access Management (IAM) roles. With Essential Contacts, you can customize who receives notifications by providing your own list of contacts.", + "ImpactStatement": "There is no charge for Essential Contacts except for the 'Technical Incidents' category that is only available to premium support customers.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Essential Contacts` by visiting https://console.cloud.google.com/iam-admin/essential-contacts 2. Make sure the organization appears in the resource selector at the top of the page. The resource selector tells you what project, folder, or organization you are currently managing contacts for. 3. Click `+Add contact` 4. In the `Email` and `Confirm Email` fields, enter the email address of the contact. 5. From the `Notification categories` drop-down menu, select the notification categories that you want the contact to receive communications for. 6. Click `Save` **From Google Cloud CLI** 1. To add an organization Essential Contacts run a command: gcloud essential-contacts create --email= --notification-categories= --organization= ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Essential Contacts` by visiting https://console.cloud.google.com/iam-admin/essential-contacts 2. Make sure the organization appears in the resource selector at the top of the page. The resource selector tells you what project, folder, or organization you are currently managing contacts for. 3. Ensure that appropriate email addresses are configured for each of the following notification categories: - `Legal` - `Security` - `Suspension` - `Technical` Alternatively, appropriate email addresses can be configured for the `All` notification category to receive all possible important notifications. **From Google Cloud CLI** 1. To list all configured organization Essential Contacts run a command: gcloud essential-contacts list --organization= 2. Ensure at least one appropriate email address is configured for each of the following notification categories: - `LEGAL` - `SECURITY` - `SUSPENSION` - `TECHNICAL` Alternatively, appropriate email addresses can be configured for the `ALL` notification category to receive all possible important notifications.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/resource-manager/docs/managing-notification-contacts", + "DefaultValue": "By default, there are no Essential Contacts configured. In the absence of an Essential Contact, the following IAM roles are used to identify users to notify for the following categories: - **Legal**: `roles/billing.admin` - **Security**: `roles/resourcemanager.organizationAdmin` - **Suspension**: `roles/owner` - **Technical**: `roles/owner` - **Technical Incidents**: `roles/owner`" + } + ] + }, + { + "Id": "1.17", + "Description": "Ensure Secrets are Not Stored in Cloud Functions Environment Variables by Using Secret Manager", + "Checks": [], + "Attributes": [ + { + "Section": "1 Identity and Access Management", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Google Cloud Functions allow you to host serverless code that is executed when an event is triggered, without the requiring the management a host operating system. These functions can also store environment variables to be used by the code that may contain authentication or other information that needs to remain confidential.", + "RationaleStatement": "It is recommended to use the Secret Manager, because environment variables are stored unencrypted, and accessible for all users who have access to the code.", + "ImpactStatement": "There should be no impact on the Cloud Function. There are minor costs after 10,000 requests a month to the Secret Manager API as well for a high use of other functions. Modifying the Cloud Function to use the Secret Manager may prevent it running to completion.", + "RemediationProcedure": "Enable Secret Manager API for your Project **From Google Cloud Console** 1. Within the project you wish to enable, select the Navigation hamburger menu in the top left. Hover over 'APIs & Services' to under the heading 'Serverless', then select 'Enabled APIs & Services' in the menu that opens up. 2. Click the button '+ Enable APIS and Services' 3. In the Search bar, search for 'Secret Manager API' and select it. 4. Click the blue box that says 'Enable'. **From Google Cloud CLI** 1. Within the project you wish to enable the API in, run the following command. gcloud services enable Secret Manager API Reviewing Environment Variables That Should Be Migrated to Secret Manager **From Google Cloud Console** 1. Log in to the Google Cloud Web Portal (https://console.cloud.google.com/) 1. Go to Cloud Functions 1. Click on a function name from the list 1. Click on Edit and review the Runtime environment for variables that should be secrets. Leave this list open for the next step. **From Google Cloud CLI** 1. To view a list of your cloud functions run gcloud functions list 2. For each cloud function run the following command. gcloud functions describe 3. Review the settings of the buildEnvironmentVariables and environmentVariables. Keep this information for the next step. Migrating Environment Variables to Secrets within the Secret Manager **From Google Cloud Console** 1. Go to the Secret Manager page in the Cloud Console. 1. On the Secret Manager page, click Create Secret. 1. On the Create secret page, under Name, enter the name of the Environment Variable you are replacing. This will then be the Secret Variable you will reference in your code. 1. You will also need to add a version. This is the actual value of the variable that will be referenced from the code. To add a secret version when creating the initial secret, in the Secret value field, enter the value from the Environment Variable you are replacing. 1. Leave the Regions section unchanged. 1. Click the Create secret button. 1. Repeat for all Environment Variables **From Google Cloud CLI** 1. Run the following command with the Environment Variable name you are replacing in the ``. It is most secure to point this command to a file with the Environment Variable value located in it, as if you entered it via command line it would show up in your shell’s command history. gcloud secrets create --data-file=/path/to/file.txt Granting your Runtime's Service Account Access to Secrets **From Google Cloud Console** 1. Within the project containing your runtime login with account that has the 'roles/secretmanager.secretAccessor' permission. 2. Select the Navigation hamburger menu in the top left. Hover over 'Security' to under the then select 'Secret Manager' in the menu that opens up. 3. Click the name of a secret listed in this screen. 4. If it is not already open, click Show Info Panel in this screen to open the panel. 5.In the info panel, click Add principal. 6.In the New principals field, enter the service account your function uses for its identity. (If you need help locating or updating your runtime's service account, please see the 'docs/securing/function-identity#runtime_service_account' reference.) 7. In the Select a role dropdown, choose Secret Manager and then Secret Manager Secret Accessor. **From Google Cloud CLI** As of the time of writing, using Google CLI to list Runtime variables is only in beta. Because this is likely to change we are not including it here. Modifying the Code to use the Secrets in Secret Manager **From Google Cloud Console** This depends heavily on which language your runtime is in. For the sake of the brevity of this recommendation, please see the '/docs/creating-and-accessing-secrets#access' reference for language specific instructions. **From Google Cloud CLI** This depends heavily on which language your runtime is in. For the sake of the brevity of this recommendation, please see the' /docs/creating-and-accessing-secrets#access' reference for language specific instructions. Deleting the Insecure Environment Variables **Be certain to do this step last.** Removing variables from code actively referencing them will prevent it from completing successfully. **From Google Cloud Console** 1. Select the Navigation hamburger menu in the top left. Hover over 'Security' then select 'Secret Manager' in the menu that opens up. 1. Click the name of a function. Click Edit. 1. Click Runtime, build and connections settings to expand the advanced configuration options. 1. Click 'Security’. Hover over the secret you want to remove, then click 'Delete'. 1. Click Next. Click Deploy. The latest version of the runtime will now reference the secrets in Secret Manager. **From Google Cloud CLI** gcloud functions deploy --remove-env-vars If you need to find the env vars to remove, they are from the step where ‘gcloud functions describe ``’ was run.", + "AuditProcedure": "Determine if Confidential Information is Stored in your Functions in Cleartext **From Google Cloud Console** 1. Within the project you wish to audit, select the Navigation hamburger menu in the top left. Scroll down to under the heading 'Serverless', then select 'Cloud Functions' 1. Click on a function name from the list 1. Open the Variables tab and you will see both buildEnvironmentVariables and environmentVariables 1. Review the variables whether they are secrets 1. Repeat step 3-5 until all functions are reviewed **From Google Cloud CLI** 1. To view a list of your cloud functions run gcloud functions list 2. For each cloud function in the list run the following command. gcloud functions describe 3. Review the settings of the buildEnvironmentVariables and environmentVariables. Determine if this is data that should not be publicly accessible. Determine if Secret Manager API is 'Enabled' for your Project **From Google Cloud Console** 1. Within the project you wish to audit, select the Navigation hamburger menu in the top left. Hover over 'APIs & Services' to under the heading 'Serverless', then select 'Enabled APIs & Services' in the menu that opens up. 1. Click the button '+ Enable APIS and Services' 1. In the Search bar, search for 'Secret Manager API' and select it. 1. If it is enabled, the blue box that normally says 'Enable' will instead say 'Manage'. **From Google Cloud CLI** 1. Within the project you wish to audit, run the following command. gcloud services list 2. If 'Secret Manager API' is in the list, it is enabled.", + "AdditionalInformation": "There are slight additional costs to using the Secret Manager API. Review the documentation to determine your organizations' needs.", + "References": "https://cloud.google.com/functions/docs/configuring/env-var#managing_secrets:https://cloud.google.com/secret-manager/docs/overview", + "DefaultValue": "By default Secret Manager is not enabled." + } + ] + }, + { + "Id": "2.1", + "Description": "Ensure That Cloud Audit Logging Is Configured Properly", + "Checks": [ + "iam_audit_logs_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that Cloud Audit Logging is configured to track all admin activities and read, write access to user data.", + "RationaleStatement": "Cloud Audit Logging maintains two audit logs for each project, folder, and organization: Admin Activity and Data Access. 1. Admin Activity logs contain log entries for API calls or other administrative actions that modify the configuration or metadata of resources. Admin Activity audit logs are enabled for all services and cannot be configured. 2. Data Access audit logs record API calls that create, modify, or read user-provided data. These are disabled by default and should be enabled. There are three kinds of Data Access audit log information: - Admin read: Records operations that read metadata or configuration information. Admin Activity audit logs record writes of metadata and configuration information that cannot be disabled. - Data read: Records operations that read user-provided data. - Data write: Records operations that write user-provided data. It is recommended to have an effective default audit config configured in such a way that: 1. logtype is set to DATA_READ (to log user activity tracking) and DATA_WRITES (to log changes/tampering to user data). 2. audit config is enabled for all the services supported by the Data Access audit logs feature. 3. Logs should be captured for all users, i.e., there are no exempted users in any of the audit config sections. This will ensure overriding the audit config will not contradict the requirement.", + "ImpactStatement": "There is no charge for Admin Activity audit logs. Enabling the Data Access audit logs might result in your project being charged for the additional logs usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Audit Logs` by visiting [https://console.cloud.google.com/iam-admin/audit](https://console.cloud.google.com/iam-admin/audit). 2. Follow the steps at [https://cloud.google.com/logging/docs/audit/configure-data-access](https://cloud.google.com/logging/docs/audit/configure-data-access) to enable audit logs for all Google Cloud services. Ensure that no exemptions are allowed. **From Google Cloud CLI** 1. To read the project's IAM policy and store it in a file run a command: gcloud projects get-iam-policy PROJECT_ID > /tmp/project_policy.yaml Alternatively, the policy can be set at the organization or folder level. If setting the policy at the organization level, it is not necessary to also set it for each folder or project. gcloud organizations get-iam-policy ORGANIZATION_ID > /tmp/org_policy.yaml gcloud resource-manager folders get-iam-policy FOLDER_ID > /tmp/folder_policy.yaml 2. Edit policy in /tmp/policy.yaml, adding or changing only the audit logs configuration to: **Note: Admin Activity Logs are enabled by default, and cannot be disabled. So they are not listed in these configuration changes.** auditConfigs: - auditLogConfigs: - logType: DATA_WRITE - logType: DATA_READ service: allServices **Note:** `exemptedMembers:` is not set as audit logging should be enabled for all the users 3. To write new IAM policy run command: gcloud organizations set-iam-policy ORGANIZATION_ID /tmp/org_policy.yaml gcloud resource-manager folders set-iam-policy FOLDER_ID /tmp/folder_policy.yaml gcloud projects set-iam-policy PROJECT_ID /tmp/project_policy.yaml If the preceding command reports a conflict with another change, then repeat these steps, starting with the first step.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Audit Logs` by visiting [https://console.cloud.google.com/iam-admin/audit](https://console.cloud.google.com/iam-admin/audit). 2. Ensure that Admin Read, Data Write, and Data Read are enabled for all Google Cloud services and that no exemptions are allowed. **From Google Cloud CLI** 1. List the Identity and Access Management (IAM) policies for the project, folder, or organization: gcloud organizations get-iam-policy ORGANIZATION_ID gcloud resource-manager folders get-iam-policy FOLDER_ID gcloud projects get-iam-policy PROJECT_ID 2. Policy should have a default auditConfigs section which has the logtype set to DATA_WRITES and DATA_READ for all services. Note that projects inherit settings from folders, which in turn inherit settings from the organization. When called, projects get-iam-policy, the result shows only the policies set in the project, not the policies inherited from the parent folder or organization. Nevertheless, if the parent folder has Cloud Audit Logging enabled, the project does as well. Sample output for default audit configs may look like this: auditConfigs: - auditLogConfigs: - logType: ADMIN_READ - logType: DATA_WRITE - logType: DATA_READ service: allServices 3. Any of the auditConfigs sections should not have parameter exemptedMembers: set, which will ensure that Logging is enabled for all users and no user is exempted.", + "AdditionalInformation": "- Log type `DATA_READ` is equally important to that of `DATA_WRITE` to track detailed user activities. - BigQuery Data Access logs are handled differently from other data access logs. BigQuery logs are enabled by default and cannot be disabled. They do not count against logs allotment and cannot result in extra logs charges.", + "References": "https://cloud.google.com/logging/docs/audit/:https://cloud.google.com/logging/docs/audit/configure-data-access", + "DefaultValue": "Admin Activity logs are always enabled. They cannot be disabled. Data Access audit logs are disabled by default because they can be quite large." + } + ] + }, + { + "Id": "2.2", + "Description": "Ensure That Sinks Are Configured for All Log Entries", + "Checks": [ + "cloudstorage_bucket_log_retention_policy_lock" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to create a sink that will export copies of all the log entries. This can help aggregate logs from multiple projects and export them to a Security Information and Event Management (SIEM).", + "RationaleStatement": "Log entries are held in Cloud Logging. To aggregate logs, export them to a SIEM. To keep them longer, it is recommended to set up a log sink. Exporting involves writing a filter that selects the log entries to export, and choosing a destination in Cloud Storage, BigQuery, or Cloud Pub/Sub. The filter and destination are held in an object called a sink. To ensure all log entries are exported to sinks, ensure that there is no filter configured for a sink. Sinks can be created in projects, organizations, folders, and billing accounts.", + "ImpactStatement": "There are no costs or limitations in Cloud Logging for exporting logs, but the export destinations charge for storing or transmitting the log data.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Logs Router` by visiting [https://console.cloud.google.com/logs/router](https://console.cloud.google.com/logs/router). 2. Click on the arrow symbol with `CREATE SINK` text. 3. Fill out the fields for `Sink details`. 4. Choose Cloud Logging bucket in the Select sink destination drop down menu. 5. Choose a log bucket in the next drop down menu. 6. If an inclusion filter is not provided for this sink, all ingested logs will be routed to the destination provided above. This may result in higher than expected resource usage. 7. Click `Create Sink`. For more information, see [https://cloud.google.com/logging/docs/export/configure_export_v2#dest-create](https://cloud.google.com/logging/docs/export/configure_export_v2#dest-create). **From Google Cloud CLI** To create a sink to export all log entries in a Google Cloud Storage bucket: gcloud logging sinks create storage.googleapis.com/DESTINATION_BUCKET_NAME Sinks can be created for a folder or organization, which will include all projects. gcloud logging sinks create storage.googleapis.com/DESTINATION_BUCKET_NAME --include-children --folder=FOLDER_ID | --organization=ORGANIZATION_ID **Note:** 1. A sink created by the command-line above will export logs in storage buckets. However, sinks can be configured to export logs into BigQuery, or Cloud Pub/Sub, or `Custom Destination`. 2. While creating a sink, the sink option `--log-filter` is not used to ensure the sink exports all log entries. 3. A sink can be created at a folder or organization level that collects the logs of all the projects underneath bypassing the option `--include-children` in the gcloud command.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Logs Router` by visiting [https://console.cloud.google.com/logs/router](https://console.cloud.google.com/logs/router). 2. For every sink, click the 3-dot button for Menu options and select `View sink details`. 3. Ensure there is at least one sink with an `empty` Inclusion filter. 4. Additionally, ensure that the resource configured as `Destination` exists. **From Google Cloud CLI** 1. Ensure that a sink with an `empty filter` exists. List the sinks for the project, folder or organization. If sinks are configured at a folder or organization level, they do not need to be configured for each project: gcloud logging sinks list --folder=FOLDER_ID | --organization=ORGANIZATION_ID | --project=PROJECT_ID The output should list at least one sink with an `empty filter`. 2. Additionally, ensure that the resource configured as `Destination` exists. See [https://cloud.google.com/sdk/gcloud/reference/beta/logging/sinks/list](https://cloud.google.com/sdk/gcloud/reference/beta/logging/sinks/list) for more information.", + "AdditionalInformation": "For Command-Line Audit and Remediation, the sink destination of type `Cloud Storage Bucket` is considered. However, the destination could be configured to `Cloud Storage Bucket` or `BigQuery` or `Cloud PubSub` or `Custom Destination`. Command Line Interface commands would change accordingly.", + "References": "https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/logging/quotas:https://cloud.google.com/logging/docs/routing/overview:https://cloud.google.com/logging/docs/export/using_exported_logs:https://cloud.google.com/logging/docs/export/configure_export_v2:https://cloud.google.com/logging/docs/export/aggregated_exports:https://cloud.google.com/sdk/gcloud/reference/beta/logging/sinks/list", + "DefaultValue": "By default, there are no sinks configured." + } + ] + }, + { + "Id": "2.3", + "Description": "Ensure That Retention Policies on Cloud Storage Buckets Used for Exporting Logs Are Configured Using Bucket Lock", + "Checks": [ + "cloudstorage_bucket_log_retention_policy_lock" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Enabling retention policies on log buckets will protect logs stored in cloud storage buckets from being overwritten or accidentally deleted. It is recommended to set up retention policies and configure Bucket Lock on all storage buckets that are used as log sinks.", + "RationaleStatement": "Logs can be exported by creating one or more sinks that include a log filter and a destination. As Cloud Logging receives new log entries, they are compared against each sink. If a log entry matches a sink's filter, then a copy of the log entry is written to the destination. Sinks can be configured to export logs in storage buckets. It is recommended to configure a data retention policy for these cloud storage buckets and to lock the data retention policy; thus permanently preventing the policy from being reduced or removed. This way, if the system is ever compromised by an attacker or a malicious insider who wants to cover their tracks, the activity logs are definitely preserved for forensics and security investigations.", + "ImpactStatement": "Locking a bucket is an irreversible action. Once you lock a bucket, you cannot remove the retention policy from the bucket or decrease the retention period for the policy. You will then have to wait for the retention period for all items within the bucket before you can delete them, and then the bucket.", + "RemediationProcedure": "**From Google Cloud Console** 1. If sinks are **not** configured, first follow the instructions in the recommendation: `Ensure that sinks are configured for all Log entries`. 2. For each storage bucket configured as a sink, go to the Cloud Storage browser at `https://console.cloud.google.com/storage/browser/`. 3. Select the Bucket Lock tab near the top of the page. 4. In the Retention policy entry, click the Add Duration link. The `Set a retention policy` dialog box appears. 5. Enter the desired length of time for the retention period and click `Save policy`. 6. Set the `Lock status` for this retention policy to `Locked`. **From Google Cloud CLI** 1. To list all sinks destined to storage buckets: gcloud logging sinks list --folder=FOLDER_ID | --organization=ORGANIZATION_ID | --project=PROJECT_ID 2. For each storage bucket listed above, set a retention policy and lock it: gsutil retention set [TIME_DURATION] gs://[BUCKET_NAME] gsutil retention lock gs://[BUCKET_NAME] For more information, visit [https://cloud.google.com/storage/docs/using-bucket-lock#set-policy](https://cloud.google.com/storage/docs/using-bucket-lock#set-policy).", + "AuditProcedure": "**From Google Cloud Console** 1. Open the Cloud Storage browser in the Google Cloud Console by visiting [https://console.cloud.google.com/storage/browser](https://console.cloud.google.com/storage/browser). 2. In the Column display options menu, make sure `Retention policy` is checked. 3. In the list of buckets, the retention period of each bucket is found in the `Retention policy` column. If the retention policy is locked, an image of a lock appears directly to the left of the retention period. **From Google Cloud CLI** 1. To list all sinks destined to storage buckets: gcloud logging sinks list --folder=FOLDER_ID | --organization=ORGANIZATION_ID | --project=PROJECT_ID 2. For every storage bucket listed above, verify that retention policies and Bucket Lock are enabled: gsutil retention get gs://BUCKET_NAME For more information, see [https://cloud.google.com/storage/docs/using-bucket-lock#view-policy](https://cloud.google.com/storage/docs/using-bucket-lock#view-policy).", + "AdditionalInformation": "Caution: Locking a retention policy is an irreversible action. Once locked, you must delete the entire bucket in order to remove the bucket's retention policy. However, before you can delete the bucket, you must be able to delete all the objects in the bucket, which itself is only possible if all the objects have reached the retention period set by the retention policy.", + "References": "https://cloud.google.com/storage/docs/bucket-lock:https://cloud.google.com/storage/docs/using-bucket-lock:https://cloud.google.com/storage/docs/bucket-lock", + "DefaultValue": "By default, storage buckets used as log sinks do not have retention policies and Bucket Lock configured." + } + ] + }, + { + "Id": "2.4", + "Description": "Ensure Log Metric Filter and Alerts Exist for Project Ownership Assignments/Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "In order to prevent unnecessary project ownership assignments to users/service-accounts and further misuses of projects and resources, all `roles/Owner` assignments should be monitored. Members (users/Service-Accounts) with a role assignment to primitive role `roles/Owner` are project owners. The project owner has all the privileges on the project the role belongs to. These are summarized below: - All viewer permissions on all GCP Services within the project - Permissions for actions that modify the state of all GCP services within the project - Manage roles and permissions for a project and all resources within the project - Set up billing for a project Granting the owner role to a member (user/Service-Account) will allow that member to modify the Identity and Access Management (IAM) policy. Therefore, grant the owner role only if the member has a legitimate purpose to manage the IAM policy. This is because the project IAM policy contains sensitive access control data. Having a minimal set of users allowed to manage IAM policy will simplify any auditing that may be necessary.", + "RationaleStatement": "Project ownership has the highest level of privileges on a project. To avoid misuse of project resources, the project ownership assignment/change actions mentioned above should be monitored and alerted to concerned recipients. - Sending project ownership invites - Acceptance/Rejection of project ownership invite by user - Adding `roleOwner` to a user/service-account - Removing a user/Service account from `roleOwner`", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: (protoPayload.serviceName=cloudresourcemanager.googleapis.com) AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action=REMOVE AND protoPayload.serviceData.policyDelta.bindingDeltas.role=roles/owner) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action=ADD AND protoPayload.serviceData.policyDelta.bindingDeltas.role=roles/owner) 4. Click `Submit Filter`. The logs display based on the filter text entered by the user. 5. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and the `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the advanced logs query. 6. Click `Create Metric`. **Create the display prescribed Alert Policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the desired metric and select `Create alert from Metric`. A new page opens. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value will ensure that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notifications channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create a prescribed Log Metric: - Use the command: gcloud beta logging metrics create - Reference for Command Usage: https://cloud.google.com/sdk/gcloud/reference/beta/logging/metrics/create Create prescribed Alert Policy - Use the command: gcloud alpha monitoring policies create - Reference for Command Usage: https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create", + "AuditProcedure": "**From Google Cloud Console** **Ensure that the prescribed log metric is present:** 1. Go to `Logging/Log-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure that at least one metric `` is present with filter text: (protoPayload.serviceName=cloudresourcemanager.googleapis.com) AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action=REMOVE AND protoPayload.serviceData.policyDelta.bindingDeltas.role=roles/owner) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action=ADD AND protoPayload.serviceData.policyDelta.bindingDeltas.role=roles/owner) **Ensure that the prescribed Alerting Policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of zero(0) for greater than zero(0) seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for your organization. 5. Ensure that the appropriate notifications channels have been set up. **From Google Cloud CLI** **Ensure that the prescribed log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with filter set to: (protoPayload.serviceName=cloudresourcemanager.googleapis.com) AND (ProjectOwnership OR projectOwnerInvitee) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action=REMOVE AND protoPayload.serviceData.policyDelta.bindingDeltas.role=roles/owner) OR (protoPayload.serviceData.policyDelta.bindingDeltas.action=ADD AND protoPayload.serviceData.policyDelta.bindingDeltas.role=roles/owner) 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains an least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "1. Project ownership assignments for a user cannot be done using the gcloud utility as assigning project ownership to a user requires sending, and the user accepting, an invitation. 2. Project Ownership assignment to a service account does not send any invites. SetIAMPolicy to `role/owner`is directly performed on service accounts.", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.5", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for Audit Configuration Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Google Cloud Platform (GCP) services write audit log entries to the Admin Activity and Data Access logs to help answer the questions of, who did what, where, and when? within GCP projects. Cloud audit logging records information includes the identity of the API caller, the time of the API call, the source IP address of the API caller, the request parameters, and the response elements returned by GCP services. Cloud audit logging provides a history of GCP API calls for an account, including API calls made via the console, SDKs, command-line tools, and other GCP services.", + "RationaleStatement": "Admin activity and data access logs produced by cloud audit logging enable security analysis, resource change tracking, and compliance auditing. Configuring the metric filter and alerts for audit configuration changes ensures the recommended state of audit configuration is maintained so that all activities in the project are audit-able at any point in time.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: protoPayload.methodName=SetIamPolicy AND protoPayload.serviceData.policyDelta.auditConfigDeltas:* 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This will ensure that the log metric counts the number of log entries matching the user's advanced logs query. 6. Click `Create Metric`. **Create a prescribed Alert Policy:** 1. Identify the new metric the user just created, under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page opens. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the organization. For example, a threshold of zero(0) for the most recent value will ensure that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notifications channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create a prescribed Log Metric: - Use the command: gcloud beta logging metrics create - Reference for command usage: [https://cloud.google.com/sdk/gcloud/reference/beta/logging/metrics/create ](https://cloud.google.com/sdk/gcloud/reference/beta/logging/metrics/create) Create prescribed Alert Policy - Use the command: gcloud alpha monitoring policies create - Reference for command usage: [https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create](https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create)", + "AuditProcedure": "**From Google Cloud Console** **Ensure the prescribed log metric is present:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure that at least one metric `` is present with the filter text: protoPayload.methodName=SetIamPolicy AND protoPayload.serviceData.policyDelta.auditConfigDeltas:* **Ensure that the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of 0 for greater than zero(0) seconds`, means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that appropriate notifications channels have been set up. **From Google Cloud CLI** **Ensure that the prescribed log metric is present:** 1. List the log metrics: gcloud beta logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: protoPayload.methodName=SetIamPolicy AND protoPayload.serviceData.policyDelta.auditConfigDeltas:* 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains at least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/logging/docs/audit/configure-data-access#getiampolicy-setiampolicy", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.6", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for Custom Role Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for changes to Identity and Access Management (IAM) role creation, deletion and updating activities.", + "RationaleStatement": "Google Cloud IAM provides predefined roles that give granular access to specific Google Cloud Platform resources and prevent unwanted access to other resources. However, to cater to organization-specific needs, Cloud IAM also provides the ability to create custom roles. Project owners and administrators with the Organization Role Administrator role or the IAM Role Administrator role can create custom roles. Monitoring role creation, deletion and updating activities will help in identifying any over-privileged role at early stages.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage.", + "RemediationProcedure": "**From Console:** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 1. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 1. Clear any text and add: resource.type=iam_role AND (protoPayload.methodName = google.iam.admin.v1.CreateRole OR protoPayload.methodName=google.iam.admin.v1.DeleteRole OR protoPayload.methodName=google.iam.admin.v1.UpdateRole OR protoPayload.methodName=google.iam.admin.v1.UndeleteRole) 1. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 1. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the advanced logs query. 1. Click `Create Metric`. **Create a prescribed Alert Policy:** 1. Identify the new metric that was just created under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the metric and select `Create alert from Metric`. A new page displays. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value ensures that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 1. Configure the desired notification channels in the section `Notifications`. 1. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed Log Metric: - Use the command: gcloud logging metrics create Create the prescribed Alert Policy: - Use the command: gcloud alpha monitoring policies create ", + "AuditProcedure": "**From Console:** **Ensure that the prescribed log metric is present:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure that at least one metric `` is present with filter text: resource.type=iam_role AND (protoPayload.methodName=google.iam.admin.v1.CreateRole OR protoPayload.methodName=google.iam.admin.v1.DeleteRole OR protoPayload.methodName=google.iam.admin.v1.UpdateRole OR protoPayload.methodName=google.iam.admin.v1.UndeleteRole) **Ensure that the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of zero(0) for greater than zero(0) seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that the appropriate notifications channels have been set up. **From Google Cloud CLI** Ensure that the prescribed log metric is present: 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: resource.type=iam_role AND (protoPayload.methodName = google.iam.admin.v1.CreateRole OR protoPayload.methodName=google.iam.admin.v1.DeleteRole OR protoPayload.methodName=google.iam.admin.v1.UpdateRole OR protoPayload.methodName=google.iam.admin.v1.UndeleteRole) 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains an least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/iam/docs/understanding-custom-roles", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.7", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for VPC Network Firewall Rule Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for Virtual Private Cloud (VPC) Network Firewall rule changes.", + "RationaleStatement": "Monitoring for Create or Update Firewall rule events gives insight to network access changes and may reduce the time it takes to detect suspicious activity.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage. These charges could be significant depending on the size of the organization.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: resource.type=gce_firewall_rule AND (protoPayload.methodName:compute.firewalls.patch OR protoPayload.methodName:compute.firewalls.insert OR protoPayload.methodName:compute.firewalls.delete) 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the advanced logs query. 6. Click `Create Metric`. **Create the prescribed Alert Policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page displays. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value ensures that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notifications channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed Log Metric - Use the command: gcloud logging metrics create Create the prescribed alert policy: - Use the command: gcloud alpha monitoring policies create", + "AuditProcedure": "**From Google Cloud Console** **Ensure that the prescribed log metric is present:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure at least one metric `` is present with this filter text: resource.type=gce_firewall_rule AND (protoPayload.methodName:compute.firewalls.patch OR protoPayload.methodName:compute.firewalls.insert OR protoPayload.methodName:compute.firewalls.delete) **Ensure that the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of zero(0) for greater than zero(0) seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that appropriate notification channels have been set up. **From Google Cloud CLI** **Ensure that the prescribed log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: resource.type=gce_firewall_rule AND (protoPayload.methodName:compute.firewalls.patch OR protoPayload.methodName:compute.firewalls.insert OR protoPayload.methodName:compute.firewalls.delete) 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains an least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/vpc/docs/firewalls", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.8", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for VPC Network Route Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for Virtual Private Cloud (VPC) network route changes.", + "RationaleStatement": "Google Cloud Platform (GCP) routes define the paths network traffic takes from a VM instance to another destination. The other destination can be inside the organization VPC network (such as another VM) or outside of it. Every route consists of a destination and a next hop. Traffic whose destination IP is within the destination range is sent to the next hop for delivery. Monitoring changes to route tables will help ensure that all VPC traffic flows through an expected path.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage. These charges could be significant depending on the size of the organization.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed Log Metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter` 3. Clear any text and add: resource.type=gce_route AND (protoPayload.methodName:compute.routes.delete OR protoPayload.methodName:compute.routes.insert) 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the user's advanced logs query. 6. Click `Create Metric`. **Create the prescribed alert policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page displays. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value ensures that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notification channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed Log Metric: - Use the command: gcloud logging metrics create Create the prescribed the alert policy: - Use the command: gcloud alpha monitoring policies create", + "AuditProcedure": "**From Google Cloud Console** **Ensure that the prescribed Log metric is present:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure that at least one metric `` is present with the filter text: resource.type=gce_route AND (protoPayload.methodName:compute.routes.delete OR protoPayload.methodName:compute.routes.insert) **Ensure the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting: [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of 0 for greater than zero(0) seconds` means that the alert will trigger for any new owner change. Verify that the chosen alert thresholds make sense for the user's organization. 5. Ensure that the appropriate notification channels have been set up. **From Google Cloud CLI** **Ensure the prescribed log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: resource.type=gce_route AND (protoPayload.methodName:compute.routes.delete OR protoPayload.methodName:compute.routes.insert) 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains an least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/storage/docs/access-control/iam:https://cloud.google.com/sdk/gcloud/reference/beta/logging/metrics/create:https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.9", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for VPC Network Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for Virtual Private Cloud (VPC) network changes.", + "RationaleStatement": "It is possible to have more than one VPC within a project. In addition, it is also possible to create a peer connection between two VPCs enabling network traffic to route between VPCs. Monitoring changes to a VPC will help ensure VPC traffic flow is not getting impacted.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage. These charges could be significant depending on the size of the organization.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: resource.type=gce_network AND (protoPayload.methodName:compute.networks.insert OR protoPayload.methodName:compute.networks.patch OR protoPayload.methodName:compute.networks.delete OR protoPayload.methodName:compute.networks.removePeering OR protoPayload.methodName:compute.networks.addPeering) 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on the right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the user's advanced logs query. 6. Click `Create Metric`. **Create the prescribed alert policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page appears. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of 0 for the most recent value will ensure that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notification channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed Log Metric: - Use the command: gcloud logging metrics create Create the prescribed alert policy: - Use the command: gcloud alpha monitoring policies create", + "AuditProcedure": "**From Google Cloud Console** **Ensure the prescribed log metric is present:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure at least one metric `` is present with filter text: resource.type=gce_network AND (protoPayload.methodName:compute.networks.insert OR protoPayload.methodName:compute.networks.patch OR protoPayload.methodName:compute.networks.delete OR protoPayload.methodName:compute.networks.removePeering OR protoPayload.methodName:compute.networks.addPeering) **Ensure the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of 0 for greater than 0 seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that appropriate notification channels have been set up. **From Google Cloud CLI** **Ensure the log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with filter set to: resource.type=gce_network AND protoPayload.methodName=beta.compute.networks.insert OR protoPayload.methodName=beta.compute.networks.patch OR protoPayload.methodName=v1.compute.networks.delete OR protoPayload.methodName=v1.compute.networks.removePeering OR protoPayload.methodName=v1.compute.networks.addPeering 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains at least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/vpc/docs/overview", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.10", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for Cloud Storage IAM Permission Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for Cloud Storage Bucket IAM changes.", + "RationaleStatement": "Monitoring changes to cloud storage bucket permissions may reduce the time needed to detect and correct permissions on sensitive cloud storage buckets and objects inside the bucket.", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage. These charges could be significant depending on the size of the organization.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed log metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: resource.type=gcs_bucket AND protoPayload.methodName=storage.setIamPermissions 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the user's advanced logs query. 6. Click `Create Metric`. **Create the prescribed Alert Policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page appears. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value will ensure that a notification is triggered for every owner change in the project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notifications channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed Log Metric: - Use the command: gcloud beta logging metrics create Create the prescribed alert policy: - Use the command: gcloud alpha monitoring policies create", + "AuditProcedure": "**From Google Cloud Console** **Ensure the prescribed log metric is present:** 1. For each project that contains cloud storage buckets, go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure at least one metric `` is present with the filter text: resource.type=gcs_bucket AND protoPayload.methodName=storage.setIamPermissions **Ensure that the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of 0 for greater than 0 seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that the appropriate notifications channels have been set up. **From Google Cloud CLI** **Ensure that the prescribed log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to: resource.type=gcs_bucket AND protoPayload.methodName=storage.setIamPermissions 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains an least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/storage/docs/overview:https://cloud.google.com/storage/docs/access-control/iam-roles", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.11", + "Description": "Ensure That the Log Metric Filter and Alerts Exist for SQL Instance Configuration Changes", + "Checks": [ + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that a metric filter and alarm be established for SQL instance configuration changes.", + "RationaleStatement": "Monitoring changes to SQL instance configuration changes may reduce the time needed to detect and correct misconfigurations done on the SQL server. Below are a few of the configurable options which may the impact security posture of an SQL instance: - Enable auto backups and high availability: Misconfiguration may adversely impact business continuity, disaster recovery, and high availability - Authorize networks: Misconfiguration may increase exposure to untrusted networks", + "ImpactStatement": "Enabling of logging may result in your project being charged for the additional logs usage. These charges could be significant depending on the size of the organization.", + "RemediationProcedure": "**From Google Cloud Console** **Create the prescribed Log Metric:** 1. Go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics) and click CREATE METRIC. 2. Click the down arrow symbol on the `Filter Bar` at the rightmost corner and select `Convert to Advanced Filter`. 3. Clear any text and add: protoPayload.methodName=cloudsql.instances.update 4. Click `Submit Filter`. Display logs appear based on the filter text entered by the user. 5. In the `Metric Editor` menu on right, fill out the name field. Set `Units` to `1` (default) and `Type` to `Counter`. This ensures that the log metric counts the number of log entries matching the user's advanced logs query. 6. Click `Create Metric`. **Create the prescribed alert policy:** 1. Identify the newly created metric under the section `User-defined Metrics` at [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. Click the 3-dot icon in the rightmost column for the new metric and select `Create alert from Metric`. A new page appears. 3. Fill out the alert policy configuration and click `Save`. Choose the alerting threshold and configuration that makes sense for the user's organization. For example, a threshold of zero(0) for the most recent value will ensure that a notification is triggered for every owner change in the user's project: Set `Aggregator` to `Count` Set `Configuration`: - Condition: above - Threshold: 0 - For: most recent value 4. Configure the desired notification channels in the section `Notifications`. 5. Name the policy and click `Save`. **From Google Cloud CLI** Create the prescribed log metric: - Use the command: gcloud logging metrics create Create the prescribed alert policy: - Use the command: gcloud alpha monitoring policies create - Reference for command usage: [https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create](https://cloud.google.com/sdk/gcloud/reference/alpha/monitoring/policies/create)", + "AuditProcedure": "**From Google Cloud Console** **Ensure the prescribed log metric is present:** 1. For each project that contains Cloud SQL instances, go to `Logging/Logs-based Metrics` by visiting [https://console.cloud.google.com/logs/metrics](https://console.cloud.google.com/logs/metrics). 2. In the `User-defined Metrics` section, ensure that at least one metric `` is present with the filter text: protoPayload.methodName=cloudsql.instances.update **Ensure that the prescribed alerting policy is present:** 3. Go to `Alerting` by visiting [https://console.cloud.google.com/monitoring/alerting](https://console.cloud.google.com/monitoring/alerting). 4. Under the `Policies` section, ensure that at least one alert policy exists for the log metric above. Clicking on the policy should show that it is configured with a condition. For example, `Violates when: Any logging.googleapis.com/user/ stream` `is above a threshold of zero(0) for greater than zero(0) seconds` means that the alert will trigger for any new owner change. Verify that the chosen alerting thresholds make sense for the user's organization. 5. Ensure that the appropriate notifications channels have been set up. **From Google Cloud CLI** **Ensure that the prescribed log metric is present:** 1. List the log metrics: gcloud logging metrics list --format json 2. Ensure that the output contains at least one metric with the filter set to protoPayload.methodName=cloudsql.instances.update 3. Note the value of the property `metricDescriptor.type` for the identified metric, in the format `logging.googleapis.com/user/`. **Ensure that the prescribed alerting policy is present:** 4. List the alerting policies: gcloud alpha monitoring policies list --format json 5. Ensure that the output contains at least one alert policy where: - `conditions.conditionThreshold.filter` is set to `metric.type=logging.googleapis.com/user/` - AND `enabled` is set to `true`", + "AdditionalInformation": "", + "References": "https://cloud.google.com/logging/docs/logs-based-metrics/:https://cloud.google.com/monitoring/custom-metrics/:https://cloud.google.com/monitoring/alerts/:https://cloud.google.com/logging/docs/reference/tools/gcloud-logging:https://cloud.google.com/storage/docs/overview:https://cloud.google.com/sql/docs/:https://cloud.google.com/sql/docs/mysql/:https://cloud.google.com/sql/docs/postgres/", + "DefaultValue": "" + } + ] + }, + { + "Id": "2.12", + "Description": "Ensure That Cloud DNS Logging Is Enabled for All VPC Networks", + "Checks": [], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Cloud DNS logging records the queries from the name servers within your VPC to Stackdriver. Logged queries can come from Compute Engine VMs, GKE containers, or other GCP resources provisioned within the VPC.", + "RationaleStatement": "Security monitoring and forensics cannot depend solely on IP addresses from VPC flow logs, especially when considering the dynamic IP usage of cloud resources, HTTP virtual host routing, and other technology that can obscure the DNS name used by a client from the IP address. Monitoring of Cloud DNS logs provides visibility to DNS names requested by the clients within the VPC. These logs can be monitored for anomalous domain names, evaluated against threat intelligence, and Note: For full capture of DNS, firewall must block egress UDP/53 (DNS) and TCP/443 (DNS over HTTPS) to prevent client from using external DNS name server for resolution.", + "ImpactStatement": "Enabling of Cloud DNS logging might result in your project being charged for the additional logs usage.", + "RemediationProcedure": "**From Google Cloud CLI** **Add New DNS Policy With Logging Enabled** For each VPC network that needs a DNS policy with logging enabled: gcloud dns policies create enable-dns-logging --enable-logging --description=Enable DNS Logging --networks=VPC_NETWORK_NAME The VPC_NETWORK_NAME can be one or more networks in comma-separated list **Enable Logging for Existing DNS Policy** For each VPC network that has an existing DNS policy that needs logging enabled: gcloud dns policies update POLICY_NAME --enable-logging --networks=VPC_NETWORK_NAME The VPC_NETWORK_NAME can be one or more networks in comma-separated list", + "AuditProcedure": "**From Google Cloud CLI** 1. List all VPCs networks in a project: gcloud compute networks list --format=table[box,title='All VPC Networks'](name:label='VPC Network Name') 2. List all DNS policies, logging enablement, and associated VPC networks: gcloud dns policies list --flatten=networks[] --format=table[box,title='All DNS Policies By VPC Network'](name:label='Policy Name',enableLogging:label='Logging Enabled':align=center,networks.networkUrl.basename():label='VPC Network Name') Each VPC Network should be associated with a DNS policy with logging enabled.", + "AdditionalInformation": "Additional Info - Only queries that reach a name server are logged. Cloud DNS resolvers cache responses, queries answered from caches, or direct queries to an external DNS resolver outside the VPC are not logged.", + "References": "https://cloud.google.com/dns/docs/monitoring", + "DefaultValue": "Cloud DNS logging is disabled by default on each network." + } + ] + }, + { + "Id": "2.13", + "Description": "Ensure Cloud Asset Inventory Is Enabled", + "Checks": [ + "iam_cloud_asset_inventory_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "GCP Cloud Asset Inventory is services that provides a historical view of GCP resources and IAM policies through a time-series database. The information recorded includes metadata on Google Cloud resources, metadata on policies set on Google Cloud projects or resources, and runtime information gathered within a Google Cloud resource. Cloud Asset Inventory Service (CAIS) API enablement is not required for operation of the service, but rather enables the mechanism for searching/exporting CAIS asset data directly.", + "RationaleStatement": "The GCP resources and IAM policies captured by GCP Cloud Asset Inventory enables security analysis, resource change tracking, and compliance auditing. It is recommended GCP Cloud Asset Inventory be enabled for all GCP projects.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** Enable the Cloud Asset API: 1. Go to `API & Services/Library` by visiting [https://console.cloud.google.com/apis/library](https://console.cloud.google.com/apis/library) 2. Search for `Cloud Asset API` and select the result for _Cloud Asset API_ 3. Click the `ENABLE` button. **From Google Cloud CLI** Enable the Cloud Asset API: 1. Enable the Cloud Asset API through the services interface: gcloud services enable cloudasset.googleapis.com ", + "AuditProcedure": "**From Google Cloud Console** Ensure that the Cloud Asset API is enabled: 1. Go to `API & Services/Library` by visiting [https://console.cloud.google.com/apis/library](https://console.cloud.google.com/apis/library) 2. Search for `Cloud Asset API` and select the result for _Cloud Asset API_ 3. Ensure that `API Enabled` is displayed. **From Google Cloud CLI** Ensure that the Cloud Asset API is enabled: 1. Query enabled services: gcloud services list --enabled --filter=name:cloudasset.googleapis.com If the API is listed, then it is enabled. If the response is `Listed 0 items` the API is not enabled.", + "AdditionalInformation": "Additional info - Cloud Asset Inventory only keeps a five-week history of Google Cloud asset metadata. If a longer history is desired, automation to export the history to Cloud Storage or BigQuery should be evaluated. Users need not enable CAI API if they don't have any plans to export.", + "References": "https://cloud.google.com/asset-inventory/docs", + "DefaultValue": "The Cloud Asset Inventory API is disabled by default in each project." + } + ] + }, + { + "Id": "2.14", + "Description": "Ensure 'Access Transparency' is 'Enabled'", + "Checks": [], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "GCP Access Transparency provides audit logs for all actions that Google personnel take in your Google Cloud resources.", + "RationaleStatement": "Controlling access to your information is one of the foundations of information security. Given that Google Employees do have access to your organizations' projects for support reasons, you should have logging in place to view who, when, and why your information is being accessed.", + "ImpactStatement": "To use Access Transparency your organization will need to have at one of the following support level: Premium, Enterprise, Platinum, or Gold. There will be subscription costs associated with support, as well as increased storage costs for storing the logs. You will also not be able to turn Access Transparency off yourself, and you will need to submit a service request to Google Cloud Support.", + "RemediationProcedure": "**From Google Cloud Console** **Add privileges to enable Access Transparency** 1. From the Google Cloud Home, within the project you wish to check, click on the Navigation hamburger menu in the top left. Hover over the 'IAM and Admin'. Select `IAM` in the top of the column that opens. 2. Click the blue button the says `+add` at the top of the screen. 3. In the `principals` field, select a user or group by typing in their associated email address. 4. Click on the `role` field to expand it. In the filter field enter `Access Transparency Admin` and select it. 5. Click `save`. **Verify that the Google Cloud project is associated with a billing account** 1. From the Google Cloud Home, click on the Navigation hamburger menu in the top left. Select `Billing`. 2. If you see `This project is not associated with a billing account` you will need to enter billing information or switch to a project with a billing account. **Enable Access Transparency** 1. From the Google Cloud Home, click on the Navigation hamburger menu in the top left. Hover over the IAM & Admin Menu. Select `settings` in the middle of the column that opens. 2. Click the blue button labeled Enable `Access Transparency for Organization`", + "AuditProcedure": "**From Google Cloud Console** **Determine if Access Transparency is Enabled** 1. From the Google Cloud Home, click on the Navigation hamburger menu in the top left. Hover over the IAM & Admin Menu. Select `settings` in the middle of the column that opens. 2. The status will be under the heading `Access Transparency`. Status should be `Enabled`", + "AdditionalInformation": "To enable Access Transparency for your Google Cloud organization, your Google Cloud organization must have one of the following customer support levels: Premium, Enterprise, Platinum, or Gold.", + "References": "https://cloud.google.com/cloud-provider-access-management/access-transparency/docs/overview:https://cloud.google.com/cloud-provider-access-management/access-transparency/docs/enable:https://cloud.google.com/cloud-provider-access-management/access-transparency/docs/reading-logs:https://cloud.google.com/cloud-provider-access-management/access-transparency/docs/reading-logs#justification_reason_codes:https://cloud.google.com/cloud-provider-access-management/access-transparency/docs/supported-services", + "DefaultValue": "By default Access Transparency is not enabled." + } + ] + }, + { + "Id": "2.15", + "Description": "Ensure 'Access Approval' is 'Enabled'", + "Checks": [ + "iam_account_access_approval_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "GCP Access Approval enables you to require your organizations' explicit approval whenever Google support try to access your projects. You can then select users within your organization who can approve these requests through giving them a security role in IAM. All access requests display which Google Employee requested them in an email or Pub/Sub message that you can choose to Approve. This adds an additional control and logging of who in your organization approved/denied these requests.", + "RationaleStatement": "Controlling access to your information is one of the foundations of information security. Google Employees do have access to your organizations' projects for support reasons. With Access Approval, organizations can then be certain that their information is accessed by only approved Google Personnel.", + "ImpactStatement": "To use Access Approval your organization will need have enabled Access Transparency and have at one of the following support level: Enhanced or Premium. There will be subscription costs associated with these support levels, as well as increased storage costs for storing the logs. You will also not be able to turn the Access Transparency which Access Approval depends on, off yourself. To do so you will need to submit a service request to Google Cloud Support. There will also be additional overhead in managing user permissions. There may also be a potential delay in support times as Google Personnel will have to wait for their access to be approved.", + "RemediationProcedure": "**From Google Cloud Console** 1. From the Google Cloud Home, within the project you wish to enable, click on the Navigation hamburger menu in the top left. Hover over the `Security` Menu. Select `Access Approval` in the middle of the column that opens. 2. The status will be displayed here. On this screen, there is an option to click `Enroll`. If it is greyed out and you see an error bar at the top of the screen that says `Access Transparency is not enabled` please view the corresponding reference within this section to enable it. 3. In the second screen click `Enroll`. **Grant an IAM Group or User the role with permissions to Add Users to be Access Approval message Recipients** 1. From the Google Cloud Home, within the project you wish to enable, click on the Navigation hamburger menu in the top left. Hover over the `IAM and Admin`. Select `IAM` in the middle of the column that opens. 2. Click the blue button the says `+ ADD` at the top of the screen. 3. In the `principals` field, select a user or group by typing in their associated email address. 4. Click on the role field to expand it. In the filter field enter `Access Approval Approver` and select it. 5. Click `save`. **Add a Group or User as an Approver for Access Approval Requests** 1. As a user with the `Access Approval Approver` permission, within the project where you wish to add an email address to which request will be sent, click on the Navigation hamburger menu in the top left. Hover over the `Security` Menu. Select `Access Approval` in the middle of the column that opens. 2. Click `Manage Settings` 3. Under `Set up approval notifications`, enter the email address associated with a Google Cloud User or Group you wish to send Access Approval requests to. All future access approvals will be sent as emails to this address. **From Google Cloud CLI** 1. To update all services in an entire project, run the following command from an account that has permissions as an 'Approver for Access Approval Requests' gcloud access-approval settings update --project= --enrolled_services=all --notification_emails='@' ", + "AuditProcedure": "**From Google Cloud Console** **Determine if Access Transparency is Enabled as it is a Dependency** 1. From the Google Cloud Home inside the project you wish to audit, click on the Navigation hamburger menu in the top left. Hover over the `IAM & Admin` Menu. Select `settings` in the middle of the column that opens. 2. The status should be Enabled' under the heading `Access Transparency` **Determine if Access Approval is Enabled** 1. From the Google Cloud Home, within the project you wish to check, click on the Navigation hamburger menu in the top left. Hover over the `Security` Menu. Select `Access Approval` in the middle of the column that opens. 2. The status will be displayed here. If you see a screen saying you need to enroll in Access Approval, it is not enabled. **From Google Cloud CLI** **Determine if Access Approval is Enabled** 1. From within the project you wish to audit, run the following command. gcloud access-approval settings get 2. The status will be displayed in the output. IF Access Approval is not enabled you should get this output: API [accessapproval.googleapis.com] not enabled on project [-----]. Would you like to enable and retry (this will take a few minutes)? (y/N)? After entering `Y` if you get the following output, it means that `Access Transparency` is not enabled: ERROR: (gcloud.access-approval.settings.get) FAILED_PRECONDITION: Precondition check failed. ", + "AdditionalInformation": "The recipients of Access Requests will also need to be logged into a Google Cloud account associated with an email address in this list. To approve requests they can click approve within the email. Or they can view requests at the the Access Approval page within the Security submenu.", + "References": "https://cloud.google.com/cloud-provider-access-management/access-approval/docs:https://cloud.google.com/cloud-provider-access-management/access-approval/docs/overview:https://cloud.google.com/cloud-provider-access-management/access-approval/docs/quickstart-custom-key:https://cloud.google.com/cloud-provider-access-management/access-approval/docs/supported-services:https://cloud.google.com/cloud-provider-access-management/access-approval/docs/view-historical-requests", + "DefaultValue": "By default Access Approval and its dependency of Access Transparency are not enabled." + } + ] + }, + { + "Id": "2.16", + "Description": "Ensure Logging is enabled for HTTP(S) Load Balancer", + "Checks": [ + "compute_loadbalancer_logging_enabled" + ], + "Attributes": [ + { + "Section": "2 Logging and Monitoring", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Logging enabled on a HTTPS Load Balancer will show all network traffic and its destination.", + "RationaleStatement": "Logging will allow you to view HTTPS network traffic to your web applications.", + "ImpactStatement": "On high use systems with a high percentage sample rate, the logging file may grow to high capacity in a short amount of time. Ensure that the sample rate is set appropriately so that storage costs are not exorbitant.", + "RemediationProcedure": "**From Google Cloud Console** 1. From Google Cloud home open the Navigation Menu in the top left. 1. Under the `Networking` heading select `Network services`. 1. Select the HTTPS load-balancer you wish to audit. 1. Select `Edit` then `Backend Configuration`. 1. Select `Edit` on the corresponding backend service. 1. Click `Enable Logging`. 1. Set `Sample Rate` to a desired value. This is a percentage as a decimal point. 1.0 is 100%. **From Google Cloud CLI** 1. Run the following command gcloud compute backend-services update --region=REGION --enable-logging --logging-sample-rate= ", + "AuditProcedure": "**From Google Cloud Console** 1. From Google Cloud home open the Navigation Menu in the top left. 1. Under the `Networking` heading select `Network services`. 1. Select the HTTPS load-balancer you wish to audit. 1. Select `Edit` then `Backend Configuration`. 1. Select `Edit` on the corresponding backend service. 1. Ensure that `Enable Logging` is selected. Also ensure that `Sample Rate` is set to an appropriate level for your needs. **From Google Cloud CLI** 1. Run the following command gcloud compute backend-services describe 1. Ensure that enable-logging is enabled and sample rate is set to your desired level.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/load-balancing/:https://cloud.google.com/load-balancing/docs/https/https-logging-monitoring#gcloud:-global-mode:https://cloud.google.com/sdk/gcloud/reference/compute/backend-services/", + "DefaultValue": "By default logging for https load balancing is disabled. When logging is enabled it sets the default sample rate as 1.0 or 100%. Ensure this value fits the need of your organization to avoid high storage costs." + } + ] + }, + { + "Id": "3.1", + "Description": "Ensure That the Default Network Does Not Exist in a Project", + "Checks": [ + "compute_network_default_in_use" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "To prevent use of `default` network, a project should not have a `default` network.", + "RationaleStatement": "The `default` network has a preconfigured network configuration and automatically generates the following insecure firewall rules: - default-allow-internal: Allows ingress connections for all protocols and ports among instances in the network. - default-allow-ssh: Allows ingress connections on TCP port 22(SSH) from any source to any instance in the network. - default-allow-rdp: Allows ingress connections on TCP port 3389(RDP) from any source to any instance in the network. - default-allow-icmp: Allows ingress ICMP traffic from any source to any instance in the network. These automatically created firewall rules do not get audit logged by default. Furthermore, the default network is an auto mode network, which means that its subnets use the same predefined range of IP addresses, and as a result, it's not possible to use Cloud VPN or VPC Network Peering with the default network. Based on organization security and networking requirements, the organization should create a new network and delete the `default` network.", + "ImpactStatement": "When an organization deletes the default network, it will need to remove all asests from that network and migrate them to a new network.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the `VPC networks` page by visiting: [https://console.cloud.google.com/networking/networks/list](https://console.cloud.google.com/networking/networks/list). 2. Click the network named `default`. 2. On the network detail page, click `EDIT`. 3. Click `DELETE VPC NETWORK`. 4. If needed, create a new network to replace the default network. **From Google Cloud CLI** For each Google Cloud Platform project, 1. Delete the default network: gcloud compute networks delete default 2. If needed, create a new network to replace it: gcloud compute networks create NETWORK_NAME **Prevention:** The user can prevent the default network and its insecure default firewall rules from being created by setting up an Organization Policy to `Skip default network creation` at [https://console.cloud.google.com/iam-admin/orgpolicies/compute-skipDefaultNetworkCreation](https://console.cloud.google.com/iam-admin/orgpolicies/compute-skipDefaultNetworkCreation).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VPC networks` page by visiting: [https://console.cloud.google.com/networking/networks/list](https://console.cloud.google.com/networking/networks/list). 2. Ensure that a network with the name `default` is not present. **From Google Cloud CLI** 1. Set the project name in the Google Cloud Shell: gcloud config set project PROJECT_ID 2. List the networks configured in that project: gcloud compute networks list It should not list `default` as one of the available networks in that project.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/compute/docs/networking#firewall_rules:https://cloud.google.com/compute/docs/reference/latest/networks/insert:https://cloud.google.com/compute/docs/reference/latest/networks/delete:https://cloud.google.com/vpc/docs/firewall-rules-logging:https://cloud.google.com/vpc/docs/vpc#default-network:https://cloud.google.com/sdk/gcloud/reference/compute/networks/delete", + "DefaultValue": "By default, for each project, a `default` network is created." + } + ] + }, + { + "Id": "3.2", + "Description": "Ensure Legacy Networks Do Not Exist for Older Projects", + "Checks": [ + "compute_network_not_legacy" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "In order to prevent use of legacy networks, a project should not have a legacy network configured. As of now, Legacy Networks are gradually being phased out, and you can no longer create projects with them. This recommendation is to check older projects to ensure that they are not using Legacy Networks.", + "RationaleStatement": "Legacy networks have a single network IPv4 prefix range and a single gateway IP address for the whole network. The network is global in scope and spans all cloud regions. Subnetworks cannot be created in a legacy network and are unable to switch from legacy to auto or custom subnet networks. Legacy networks can have an impact for high network traffic projects and are subject to a single point of contention or failure.", + "ImpactStatement": "None.", + "RemediationProcedure": "**From Google Cloud CLI** For each Google Cloud Platform project, 1. Follow the documentation and create a non-legacy network suitable for the organization's requirements. 2. Follow the documentation and delete the networks in the `legacy` mode.", + "AuditProcedure": "**From Google Cloud CLI** For each Google Cloud Platform project, 1. Set the project name in the Google Cloud Shell: gcloud config set project 2. List the networks configured in that project: gcloud compute networks list None of the listed networks should be in the `legacy` mode.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/vpc/docs/using-legacy#creating_a_legacy_network:https://cloud.google.com/vpc/docs/using-legacy#deleting_a_legacy_network", + "DefaultValue": "By default, networks are not created in the `legacy` mode." + } + ] + }, + { + "Id": "3.3", + "Description": "Ensure That DNSSEC Is Enabled for Cloud DNS", + "Checks": [ + "dns_dnssec_disabled" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Cloud Domain Name System (DNS) is a fast, reliable and cost-effective domain name system that powers millions of domains on the internet. Domain Name System Security Extensions (DNSSEC) in Cloud DNS enables domain owners to take easy steps to protect their domains against DNS hijacking and man-in-the-middle and other attacks.", + "RationaleStatement": "Domain Name System Security Extensions (DNSSEC) adds security to the DNS protocol by enabling DNS responses to be validated. Having a trustworthy DNS that translates a domain name like www.example.com into its associated IP address is an increasingly important building block of today’s web-based applications. Attackers can hijack this process of domain/IP lookup and redirect users to a malicious site through DNS hijacking and man-in-the-middle attacks. DNSSEC helps mitigate the risk of such attacks by cryptographically signing DNS records. As a result, it prevents attackers from issuing fake DNS responses that may misdirect browsers to nefarious websites.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Cloud DNS` by visiting [https://console.cloud.google.com/net-services/dns/zones](https://console.cloud.google.com/net-services/dns/zones). 2. For each zone of `Type` `Public`, set `DNSSEC` to `On`. **From Google Cloud CLI** Use the below command to enable `DNSSEC` for Cloud DNS Zone Name. gcloud dns managed-zones update ZONE_NAME --dnssec-state on ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Cloud DNS` by visiting [https://console.cloud.google.com/net-services/dns/zones](https://console.cloud.google.com/net-services/dns/zones). 2. For each zone of `Type` `Public`, ensure that `DNSSEC` is set to `On`. **From Google Cloud CLI** 1. List all the Managed Zones in a project: gcloud dns managed-zones list 2. For each zone of `VISIBILITY` `public`, get its metadata: gcloud dns managed-zones describe ZONE_NAME 3. Ensure that `dnssecConfig.state` property is `on`.", + "AdditionalInformation": "", + "References": "https://cloudplatform.googleblog.com/2017/11/DNSSEC-now-available-in-Cloud-DNS.html:https://cloud.google.com/dns/dnssec-config#enabling:https://cloud.google.com/dns/dnssec", + "DefaultValue": "By default DNSSEC is not enabled." + } + ] + }, + { + "Id": "3.4", + "Description": "Ensure That RSASHA1 Is Not Used for the Key-Signing Key in Cloud DNS DNSSEC", + "Checks": [ + "dns_rsasha1_in_use_to_key_sign_in_dnssec" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "NOTE: Currently, the SHA1 algorithm has been removed from general use by Google, and, if being used, needs to be whitelisted on a project basis by Google and will also, therefore, require a Google Cloud support contract. DNSSEC algorithm numbers in this registry may be used in CERT RRs. Zone signing (DNSSEC) and transaction security mechanisms (SIG(0) and TSIG) make use of particular subsets of these algorithms. The algorithm used for key signing should be a recommended one and it should be strong.", + "RationaleStatement": "Domain Name System Security Extensions (DNSSEC) algorithm numbers in this registry may be used in CERT RRs. Zonesigning (DNSSEC) and transaction security mechanisms (SIG(0) and TSIG) make use of particular subsets of these algorithms. The algorithm used for key signing should be a recommended one and it should be strong. When enabling DNSSEC for a managed zone, or creating a managed zone with DNSSEC, the user can select the DNSSEC signing algorithms and the denial-of-existence type. Changing the DNSSEC settings is only effective for a managed zone if DNSSEC is not already enabled. If there is a need to change the settings for a managed zone where it has been enabled, turn DNSSEC off and then re-enable it with different settings.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud CLI** 1. If it is necessary to change the settings for a managed zone where it has been enabled, DNSSEC must be turned off and re-enabled with different settings. To turn off DNSSEC, run the following command: gcloud dns managed-zones update ZONE_NAME --dnssec-state off 2. To update key-signing for a reported managed DNS Zone, run the following command: gcloud dns managed-zones update ZONE_NAME --dnssec-state on --ksk-algorithm KSK_ALGORITHM --ksk-key-length KSK_KEY_LENGTH --zsk-algorithm ZSK_ALGORITHM --zsk-key-length ZSK_KEY_LENGTH --denial-of-existence DENIAL_OF_EXISTENCE Supported algorithm options and key lengths are as follows. Algorithm KSK Length ZSK Length --------- ---------- ---------- RSASHA1 1024,2048 1024,2048 RSASHA256 1024,2048 1024,2048 RSASHA512 1024,2048 1024,2048 ECDSAP256SHA256 256 256 ECDSAP384SHA384 384 384", + "AuditProcedure": "**From Google Cloud CLI** Ensure the property algorithm for keyType keySigning is not using `RSASHA1`. gcloud dns managed-zones describe ZONENAME --format=json(dnsName,dnssecConfig.state,dnssecConfig.defaultKeySpecs)", + "AdditionalInformation": "1. RSASHA1 key-signing support may be required for compatibility reasons. 2. Remediation CLI works well with gcloud-cli version 221.0.0 and later.", + "References": "https://cloud.google.com/dns/dnssec-advanced#advanced_signing_options", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.5", + "Description": "Ensure That RSASHA1 Is Not Used for the Zone-Signing Key in Cloud DNS DNSSEC", + "Checks": [ + "dns_rsasha1_in_use_to_zone_sign_in_dnssec" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "NOTE: Currently, the SHA1 algorithm has been removed from general use by Google, and, if being used, needs to be whitelisted on a project basis by Google and will also, therefore, require a Google Cloud support contract. DNSSEC algorithm numbers in this registry may be used in CERT RRs. Zone signing (DNSSEC) and transaction security mechanisms (SIG(0) and TSIG) make use of particular subsets of these algorithms. The algorithm used for key signing should be a recommended one and it should be strong.", + "RationaleStatement": "DNSSEC algorithm numbers in this registry may be used in CERT RRs. Zone signing (DNSSEC) and transaction security mechanisms (SIG(0) and TSIG) make use of particular subsets of these algorithms. The algorithm used for key signing should be a recommended one and it should be strong. When enabling DNSSEC for a managed zone, or creating a managed zone with DNSSEC, the DNSSEC signing algorithms and the denial-of-existence type can be selected. Changing the DNSSEC settings is only effective for a managed zone if DNSSEC is not already enabled. If the need exists to change the settings for a managed zone where it has been enabled, turn DNSSEC off and then re-enable it with different settings.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud CLI** 1. If the need exists to change the settings for a managed zone where it has been enabled, DNSSEC must be turned off and then re-enabled with different settings. To turn off DNSSEC, run following command: gcloud dns managed-zones update ZONE_NAME --dnssec-state off 2. To update zone-signing for a reported managed DNS Zone, run the following command: gcloud dns managed-zones update ZONE_NAME --dnssec-state on --ksk-algorithm KSK_ALGORITHM --ksk-key-length KSK_KEY_LENGTH --zsk-algorithm ZSK_ALGORITHM --zsk-key-length ZSK_KEY_LENGTH --denial-of-existence DENIAL_OF_EXISTENCE Supported algorithm options and key lengths are as follows. Algorithm KSK Length ZSK Length --------- ---------- ---------- RSASHA1 1024,2048 1024,2048 RSASHA256 1024,2048 1024,2048 RSASHA512 1024,2048 1024,2048 ECDSAP256SHA256 256 384 ECDSAP384SHA384 384 384", + "AuditProcedure": "**From Google Cloud CLI** Ensure the property algorithm for keyType zone signing is not using RSASHA1. gcloud dns managed-zones describe --format=json(dnsName,dnssecConfig.state,dnssecConfig.defaultKeySpecs) ", + "AdditionalInformation": "1. RSASHA1 zone-signing support may be required for compatibility reasons. 2. The remediation CLI works well with gcloud-cli version 221.0.0 and later.", + "References": "https://cloud.google.com/dns/dnssec-advanced#advanced_signing_options", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.6", + "Description": "Ensure That SSH Access Is Restricted From the Internet", + "Checks": [ + "compute_firewall_ssh_access_from_the_internet_allowed" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "GCP `Firewall Rules` are specific to a `VPC Network`. Each rule either `allows` or `denies` traffic when its conditions are met. Its conditions allow the user to specify the type of traffic, such as ports and protocols, and the source or destination of the traffic, including IP addresses, subnets, and instances. Firewall rules are defined at the VPC network level and are specific to the network in which they are defined. The rules themselves cannot be shared among networks. Firewall rules only support IPv4 traffic. When specifying a source for an ingress rule or a destination for an egress rule by address, only an `IPv4` address or `IPv4 block in CIDR` notation can be used. Generic `(0.0.0.0/0)` incoming traffic from the internet to VPC or VM instance using `SSH` on `Port 22` can be avoided.", + "RationaleStatement": "GCP `Firewall Rules` within a `VPC Network` apply to outgoing (egress) traffic from instances and incoming (ingress) traffic to instances in the network. Egress and ingress traffic flows are controlled even if the traffic stays within the network (for example, instance-to-instance communication). For an instance to have outgoing Internet access, the network must have a valid Internet gateway route or custom route whose destination IP is specified. This route simply defines the path to the Internet, to avoid the most general `(0.0.0.0/0)` destination `IP Range` specified from the Internet through `SSH` with the default `Port 22`. Generic access from the Internet to a specific IP Range needs to be restricted.", + "ImpactStatement": "All Secure Shell (SSH) connections from outside of the network to the concerned VPC(s) will be blocked. There could be a business need where SSH access is required from outside of the network to access resources associated with the VPC. In that case, specific source IP(s) should be mentioned in firewall rules to white-list access to SSH port for the concerned VPC(s).", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `VPC Network`. 2. Go to the `Firewall Rules`. 3. Click the `Firewall Rule` you want to modify. 4. Click `Edit`. 5. Modify `Source IP ranges` to specific `IP`. 6. Click `Save`. **From Google Cloud CLI** 1.Update the Firewall rule with the new `SOURCE_RANGE` from the below command: gcloud compute firewall-rules update FirewallName --allow=[PROTOCOL[:PORT[-PORT]],...] --source-ranges=[CIDR_RANGE,...]", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `VPC network`. 2. Go to the `Firewall Rules`. 3. Ensure that `Port` is not equal to `22` and `Action` is not set to `Allow`. 4. Ensure `IP Ranges` is not equal to `0.0.0.0/0` under `Source filters`. **From Google Cloud CLI** gcloud compute firewall-rules list --format=table'(name,direction,sourceRanges,allowed)' Ensure that there is no rule matching the below criteria: - `SOURCE_RANGES` is `0.0.0.0/0` - AND `DIRECTION` is `INGRESS` - AND IPProtocol is `tcp` or `ALL` - AND `PORTS` is set to `22` or `range containing 22` or `Null (not set)` Note: - When ALL TCP ports are allowed in a rule, PORT does not have any value set (`NULL`) - When ALL Protocols are allowed in a rule, PORT does not have any value set (`NULL`)", + "AdditionalInformation": "Currently, GCP VPC only supports IPV4; however, Google is already working on adding IPV6 support for VPC. In that case along with source IP range `0.0.0.0`, the rule should be checked for IPv6 equivalent `::/0` as well.", + "References": "https://cloud.google.com/vpc/docs/firewalls#blockedtraffic:https://cloud.google.com/blog/products/identity-security/cloud-iap-enables-context-aware-access-to-vms-via-ssh-and-rdp-without-bastion-hosts", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.7", + "Description": "Ensure That RDP Access Is Restricted From the Internet", + "Checks": [ + "compute_firewall_rdp_access_from_the_internet_allowed" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "GCP `Firewall Rules` are specific to a `VPC Network`. Each rule either `allows` or `denies` traffic when its conditions are met. Its conditions allow users to specify the type of traffic, such as ports and protocols, and the source or destination of the traffic, including IP addresses, subnets, and instances. Firewall rules are defined at the VPC network level and are specific to the network in which they are defined. The rules themselves cannot be shared among networks. Firewall rules only support IPv4 traffic. When specifying a source for an ingress rule or a destination for an egress rule by address, an `IPv4` address or `IPv4 block in CIDR` notation can be used. Generic `(0.0.0.0/0)` incoming traffic from the Internet to a VPC or VM instance using `RDP` on `Port 3389` can be avoided.", + "RationaleStatement": "GCP `Firewall Rules` within a `VPC Network`. These rules apply to outgoing (egress) traffic from instances and incoming (ingress) traffic to instances in the network. Egress and ingress traffic flows are controlled even if the traffic stays within the network (for example, instance-to-instance communication). For an instance to have outgoing Internet access, the network must have a valid Internet gateway route or custom route whose destination IP is specified. This route simply defines the path to the Internet, to avoid the most general `(0.0.0.0/0)` destination `IP Range` specified from the Internet through `RDP` with the default `Port 3389`. Generic access from the Internet to a specific IP Range should be restricted.", + "ImpactStatement": "All Remote Desktop Protocol (RDP) connections from outside of the network to the concerned VPC(s) will be blocked. There could be a business need where secure shell access is required from outside of the network to access resources associated with the VPC. In that case, specific source IP(s) should be mentioned in firewall rules to white-list access to RDP port for the concerned VPC(s).", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `VPC Network`. 2. Go to the `Firewall Rules`. 3. Click the `Firewall Rule` to be modified. 4. Click `Edit`. 5. Modify `Source IP ranges` to specific `IP`. 6. Click `Save`. **From Google Cloud CLI** 1.Update RDP Firewall rule with new `SOURCE_RANGE` from the below command: gcloud compute firewall-rules update FirewallName --allow=[PROTOCOL[:PORT[-PORT]],...] --source-ranges=[CIDR_RANGE,...]", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `VPC network`. 2. Go to the `Firewall Rules`. 3. Ensure `Port` is not equal to `3389` and `Action` is not `Allow`. 4. Ensure `IP Ranges` is not equal to `0.0.0.0/0` under `Source filters`. **From Google Cloud CLI** gcloud compute firewall-rules list --format=table'(name,direction,sourceRanges,allowed)' Ensure that there is no rule matching the below criteria: - `SOURCE_RANGES` is `0.0.0.0/0` - AND `DIRECTION` is `INGRESS` - AND IPProtocol is `TCP` or `ALL` - AND `PORTS` is set to `3389` or `range containing 3389` or `Null (not set)` Note: - When ALL TCP ports are allowed in a rule, PORT does not have any value set (`NULL`) - When ALL Protocols are allowed in a rule, PORT does not have any value set (`NULL`)", + "AdditionalInformation": "Currently, GCP VPC only supports IPV4; however, Google is already working on adding IPV6 support for VPC. In that case along with source IP range `0.0.0.0`, the rule should be checked for IPv6 equivalent `::/0` as well.", + "References": "https://cloud.google.com/vpc/docs/firewalls#blockedtraffic:https://cloud.google.com/blog/products/identity-security/cloud-iap-enables-context-aware-access-to-vms-via-ssh-and-rdp-without-bastion-hosts", + "DefaultValue": "" + } + ] + }, + { + "Id": "3.8", + "Description": "Ensure that VPC Flow Logs is Enabled for Every Subnet in a VPC Network", + "Checks": [ + "compute_subnet_flow_logs_enabled" + ], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Flow Logs is a feature that enables users to capture information about the IP traffic going to and from network interfaces in the organization's VPC Subnets. Once a flow log is created, the user can view and retrieve its data in Stackdriver Logging. It is recommended that Flow Logs be enabled for every business-critical VPC subnet.", + "RationaleStatement": "VPC networks and subnetworks not reserved for internal HTTP(S) load balancing provide logically isolated and secure network partitions where GCP resources can be launched. When Flow Logs are enabled for a subnet, VMs within that subnet start reporting on all Transmission Control Protocol (TCP) and User Datagram Protocol (UDP) flows. Each VM samples the TCP and UDP flows it sees, inbound and outbound, whether the flow is to or from another VM, a host in the on-premises datacenter, a Google service, or a host on the Internet. If two GCP VMs are communicating, and both are in subnets that have VPC Flow Logs enabled, both VMs report the flows. Flow Logs supports the following use cases: - Network monitoring - Understanding network usage and optimizing network traffic expenses - Network forensics - Real-time security analysis Flow Logs provide visibility into network traffic for each VM inside the subnet and can be used to detect anomalous traffic or provide insight during security workflows. The Flow Logs must be configured such that all network traffic is logged, the interval of logging is granular to provide detailed information on the connections, no logs are filtered, and metadata to facilitate investigations are included. **Note**: Subnets reserved for use by internal HTTP(S) load balancers do not support VPC flow logs.", + "ImpactStatement": "Standard pricing for Stackdriver Logging, BigQuery, or Cloud Pub/Sub applies. VPC Flow Logs generation will be charged starting in GA as described in reference: https://cloud.google.com/vpc/", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the VPC network GCP Console visiting `https://console.cloud.google.com/networking/networks/list` 2. Click the name of a subnet, The `Subnet details` page displays. 3. Click the `EDIT` button. 4. Set `Flow Logs` to `On`. 5. Expand the `Configure Logs` section. 6. Set `Aggregation Interval` to `5 SEC`. 7. Check the box beside `Include metadata`. 8. Set `Sample rate` to `100`. 9. Click Save. **Note**: It is not possible to configure a Log filter from the console. **From Google Cloud CLI** To enable VPC Flow Logs for a network subnet, run the following command: gcloud compute networks subnets update [SUBNET_NAME] --region [REGION] --enable-flow-logs --logging-aggregation-interval=interval-5-sec --logging-flow-sampling=1 --logging-metadata=include-all ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the VPC network GCP Console visiting `https://console.cloud.google.com/networking/networks/list` 2. From the list of network subnets, make sure for each subnet: - `Flow Logs` is set to `On` - `Aggregation Interval` is set to `5 sec` - `Include metadata` checkbox is checked - `Sample rate` is set to `100%` **Note**: It is not possible to determine if a Log filter has been defined from the console. **From Google Cloud CLI** gcloud compute networks subnets list --format json | jq -r '([Subnet,Purpose,Flow_Logs,Aggregation_Interval,Flow_Sampling,Metadata,Logs_Filtered] | (., map(length*-))), (.[] | [ .name, .purpose, (if has(enableFlowLogs) and .enableFlowLogs == true then Enabled else Disabled end), (if has(logConfig) then .logConfig.aggregationInterval else N/A end), (if has(logConfig) then .logConfig.flowSampling else N/A end), (if has(logConfig) then .logConfig.metadata else N/A end), (if has(logConfig) then (.logConfig | has(filterExpr)) else N/A end) ] ) | @tsv' | column -t The output of the above command will list: - each subnet - the subnet's purpose - a `Enabled` or `Disabled` value if `Flow Logs` are enabled - the value for `Aggregation Interval` or `N/A` if disabled, the value for `Flow Sampling` or `N/A` if disabled - the value for `Metadata` or `N/A` if disabled - 'true' or 'false' if a Logging Filter is configured or 'N/A' if disabled. If the subnet's purpose is `PRIVATE` then `Flow Logs` should be `Enabled`. If `Flow Logs` is enabled then: - `Aggregation_Interval` should be `INTERVAL_5_SEC` - `Flow_Sampling` should be 1 - `Metadata` should be `INCLUDE_ALL_METADATA` - `Logs_Filtered` should be `false`.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/vpc/docs/using-flow-logs#enabling_vpc_flow_logging:https://cloud.google.com/vpc/", + "DefaultValue": "By default, Flow Logs is set to Off when a new VPC network subnet is created." + } + ] + }, + { + "Id": "3.9", + "Description": "Ensure No HTTPS or SSL Proxy Load Balancers Permit SSL Policies With Weak Cipher Suites", + "Checks": [], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "Secure Sockets Layer (SSL) policies determine what port Transport Layer Security (TLS) features clients are permitted to use when connecting to load balancers. To prevent usage of insecure features, SSL policies should use (a) at least TLS 1.2 with the MODERN profile; or (b) the RESTRICTED profile, because it effectively requires clients to use TLS 1.2 regardless of the chosen minimum TLS version; or (3) a CUSTOM profile that does not support any of the following features: TLS_RSA_WITH_AES_128_GCM_SHA256 TLS_RSA_WITH_AES_256_GCM_SHA384 TLS_RSA_WITH_AES_128_CBC_SHA TLS_RSA_WITH_AES_256_CBC_SHA TLS_RSA_WITH_3DES_EDE_CBC_SHA ", + "RationaleStatement": "Load balancers are used to efficiently distribute traffic across multiple servers. Both SSL proxy and HTTPS load balancers are external load balancers, meaning they distribute traffic from the Internet to a GCP network. GCP customers can configure load balancer SSL policies with a minimum TLS version (1.0, 1.1, or 1.2) that clients can use to establish a connection, along with a profile (Compatible, Modern, Restricted, or Custom) that specifies permissible cipher suites. To comply with users using outdated protocols, GCP load balancers can be configured to permit insecure cipher suites. In fact, the GCP default SSL policy uses a minimum TLS version of 1.0 and a Compatible profile, which allows the widest range of insecure cipher suites. As a result, it is easy for customers to configure a load balancer without even knowing that they are permitting outdated cipher suites.", + "ImpactStatement": "Creating more secure SSL policies can prevent clients using older TLS versions from establishing a connection.", + "RemediationProcedure": "**From Google Cloud Console** If the TargetSSLProxy or TargetHttpsProxy does not have an SSL policy configured, create a new SSL policy. Otherwise, modify the existing insecure policy. 1. Navigate to the `SSL Policies` page by visiting: [https://console.cloud.google.com/net-security/sslpolicies](https://console.cloud.google.com/net-security/sslpolicies) 2. Click on the name of the insecure policy to go to its `SSL policy details` page. 3. Click `EDIT`. 4. Set `Minimum TLS version` to `TLS 1.2`. 5. Set `Profile` to `Modern` or `Restricted`. 6. Alternatively, if teh user selects the profile `Custom`, make sure that the following features are disabled: TLS_RSA_WITH_AES_128_GCM_SHA256 TLS_RSA_WITH_AES_256_GCM_SHA384 TLS_RSA_WITH_AES_128_CBC_SHA TLS_RSA_WITH_AES_256_CBC_SHA TLS_RSA_WITH_3DES_EDE_CBC_SHA **From Google Cloud CLI** 1. For each insecure SSL policy, update it to use secure cyphers: gcloud compute ssl-policies update NAME [--profile COMPATIBLE|MODERN|RESTRICTED|CUSTOM] --min-tls-version 1.2 [--custom-features FEATURES] 2. If the target proxy has a GCP default SSL policy, use the following command corresponding to the proxy type to update it. gcloud compute target-ssl-proxies update TARGET_SSL_PROXY_NAME --ssl-policy SSL_POLICY_NAME gcloud compute target-https-proxies update TARGET_HTTPS_POLICY_NAME --ssl-policy SSL_POLICY_NAME ", + "AuditProcedure": "**From Google Cloud Console** 1. See all load balancers by visiting [https://console.cloud.google.com/net-services/loadbalancing/loadBalancers/list](https://console.cloud.google.com/net-services/loadbalancing/loadBalancers/list). 2. For each load balancer for `SSL (Proxy)` or `HTTPS`, click on its name to go the `Load balancer details` page. 3. Ensure that each target proxy entry in the `Frontend` table has an `SSL Policy` configured. 4. Click on each SSL policy to go to its `SSL policy details` page. 5. Ensure that the SSL policy satisfies one of the following conditions: - has a `Min TLS` set to `TLS 1.2` and `Profile` set to `Modern` profile, or - has `Profile` set to `Restricted`. Note that a Restricted profile effectively requires clients to use TLS 1.2 regardless of the chosen minimum TLS version, or - has `Profile` set to `Custom` and the following features are all disabled: TLS_RSA_WITH_AES_128_GCM_SHA256 TLS_RSA_WITH_AES_256_GCM_SHA384 TLS_RSA_WITH_AES_128_CBC_SHA TLS_RSA_WITH_AES_256_CBC_SHA TLS_RSA_WITH_3DES_EDE_CBC_SHA **From Google Cloud CLI** 1. List all TargetHttpsProxies and TargetSslProxies. gcloud compute target-https-proxies list gcloud compute target-ssl-proxies list 2. For each target proxy, list its properties: gcloud compute target-https-proxies describe TARGET_HTTPS_PROXY_NAME gcloud compute target-ssl-proxies describe TARGET_SSL_PROXY_NAME 3. Ensure that the `sslPolicy` field is present and identifies the name of the SSL policy: sslPolicy: https://www.googleapis.com/compute/v1/projects/PROJECT_ID/global/sslPolicies/SSL_POLICY_NAME If the `sslPolicy` field is missing from the configuration, it means that the GCP default policy is used, which is insecure. 4. Describe the SSL policy: gcloud compute ssl-policies describe SSL_POLICY_NAME 5. Ensure that the policy satisfies one of the following conditions: - has `Profile` set to `Modern` and `minTlsVersion` set to `TLS_1_2`, or - has `Profile` set to `Restricted`, or - has `Profile` set to `Custom` and `enabledFeatures` does not contain any of the following values: TLS_RSA_WITH_AES_128_GCM_SHA256 TLS_RSA_WITH_AES_256_GCM_SHA384 TLS_RSA_WITH_AES_128_CBC_SHA TLS_RSA_WITH_AES_256_CBC_SHA TLS_RSA_WITH_3DES_EDE_CBC_SHA ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/load-balancing/docs/use-ssl-policies:https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-52r2.pdf", + "DefaultValue": "The GCP default SSL policy is the least secure setting: Min TLS 1.0 and Compatible profile" + } + ] + }, + { + "Id": "3.1", + "Description": "Use Identity Aware Proxy (IAP) to Ensure Only Traffic From Google IP Addresses are 'Allowed'", + "Checks": [], + "Attributes": [ + { + "Section": "3 Networking", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "IAP authenticates the user requests to your apps via a Google single sign in. You can then manage these users with permissions to control access. It is recommended to use both IAP permissions and firewalls to restrict this access to your apps with sensitive information.", + "RationaleStatement": "IAP ensure that access to VMs is controlled by authenticating incoming requests. Access to your apps and the VMs should be restricted by firewall rules that allow only the proxy IAP IP addresses contained in the 35.235.240.0/20 subnet. Otherwise, unauthenticated requests can be made to your apps. To ensure that load balancing works correctly health checks should also be allowed.", + "ImpactStatement": "If firewall rules are not configured correctly, legitimate business services could be negatively impacted. It is recommended to make these changes during a time of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud Console [VPC network > Firewall rules](https://console.cloud.google.com/networking/firewalls/list?_ga=2.72166934.480049361.1580860862-1336643914.1580248695). 2. Select the checkbox next to the following rules: - default-allow-http - default-allow-https - default-allow-internal 3. Click `Delete`. 4. Click `Create firewall rule` and set the following values: - Name: allow-iap-traffic - Targets: All instances in the network - Source IP ranges (press Enter after you paste each value in the box, copy each full CIDR IP address): - IAP Proxy Addresses `35.235.240.0/20` - Google Health Check `130.211.0.0/22` - Google Health Check `35.191.0.0/16` - Protocols and ports: - Specified protocols and ports required for access and management of your app. For example most health check connection protocols would be covered by; - tcp:80 (Default HTTP Health Check port) - tcp:443 (Default HTTPS Health Check port) **Note: if you have custom ports used by your load balancers, you will need to list them here** 5. When you're finished updating values, click `Create`.", + "AuditProcedure": "**From Google Cloud Console** 1. For each of your apps that have IAP enabled go to the Cloud Console VPC network > Firewall rules. 2. Verify that the only rules correspond to the following values: - Targets: All instances in the network - Source IP ranges: - IAP Proxy Addresses `35.235.240.0/20` - Google Health Check `130.211.0.0/22` - Google Health Check `35.191.0.0/16` - Protocols and ports: - Specified protocols and ports required for access and management of your app. For example most health check connection protocols would be covered by; - tcp:80 (Default HTTP Health Check port) - tcp:443 (Default HTTPS Health Check port) **Note: if you have custom ports used by your load balancers, you will need to list them here**", + "AdditionalInformation": "", + "References": "https://cloud.google.com/iap/docs/concepts-overview:https://cloud.google.com/iap/docs/load-balancer-howto:https://cloud.google.com/load-balancing/docs/health-checks:https://cloud.google.com/blog/products/identity-security/cloud-iap-enables-context-aware-access-to-vms-via-ssh-and-rdp-without-bastion-hosts", + "DefaultValue": "By default all traffic is allowed." + } + ] + }, + { + "Id": "4.1", + "Description": "Ensure That Instances Are Not Configured To Use the Default Service Account", + "Checks": [ + "compute_instance_default_service_account_in_use" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to configure your instance to not use the default Compute Engine service account because it has the Editor role on the project.", + "RationaleStatement": "When a default Compute Engine service account is created, it is automatically granted the Editor role (roles/editor) on your project which allows read and write access to most Google Cloud Services. This role includes a very large number of permissions. To defend against privilege escalations if your VM is compromised and prevent an attacker from gaining access to all of your project, you should either revoke the Editor role from the default Compute Engine service account or create a new service account and assign only the permissions needed by your instance. To mitigate this at scale, we strongly recommend that you disable the automatic role grant by adding a constraint to your organization policy. The default Compute Engine service account is named `[PROJECT_NUMBER]-compute@developer.gserviceaccount.com`.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the instance name to go to its `VM instance details` page. 3. Click `STOP` and then click `EDIT`. 4. Under the section `API and identity management`, select a service account other than the default Compute Engine service account. You may first need to create a new service account. 5. Click `Save` and then click `START`. **From Google Cloud CLI** 1. Stop the instance: gcloud compute instances stop 2. Update the instance: gcloud compute instances set-service-account --service-account= 3. Restart the instance: gcloud compute instances start ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on each instance name to go to its `VM instance details` page. 3. Under the section `API and identity management`, ensure that the default Compute Engine service account is not used. This account is named `[PROJECT_NUMBER]-compute@developer.gserviceaccount.com`. **From Google Cloud CLI** 1. List the instances in your project and get details on each instance: gcloud compute instances list --format=json | jq -r '. | SA: (.[].serviceAccounts[].email) Name: (.[].name)' 2. Ensure that the service account section has an email that does not match the pattern `[PROJECT_NUMBER]-compute@developer.gserviceaccount.com`. **Exception:** VMs created by GKE should be excluded. These VMs have names that start with `gke-` and are labeled `goog-gke-node`.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/compute/docs/access/service-accounts:https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances:https://cloud.google.com/sdk/gcloud/reference/compute/instances/set-service-account", + "DefaultValue": "By default, Compute instances are configured to use the default Compute Engine service account." + } + ] + }, + { + "Id": "4.2", + "Description": "Ensure That Instances Are Not Configured To Use the Default Service Account With Full Access to All Cloud APIs", + "Checks": [ + "compute_instance_default_service_account_in_use_with_full_api_access" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "To support principle of least privileges and prevent potential privilege escalation it is recommended that instances are not assigned to default service account `Compute Engine default service account` with Scope `Allow full access to all Cloud APIs`.", + "RationaleStatement": "Along with ability to optionally create, manage and use user managed custom service accounts, Google Compute Engine provides default service account `Compute Engine default service account` for an instances to access necessary cloud services. `Project Editor` role is assigned to `Compute Engine default service account` hence, This service account has almost all capabilities over all cloud services except billing. However, when `Compute Engine default service account` assigned to an instance it can operate in 3 scopes. 1. Allow default access: Allows only minimum access required to run an Instance (Least Privileges) 2. Allow full access to all Cloud APIs: Allow full access to all the cloud APIs/Services (Too much access) 3. Set access for each API: Allows Instance administrator to choose only those APIs that are needed to perform specific business functionality expected by instance When an instance is configured with `Compute Engine default service account` with Scope `Allow full access to all Cloud APIs`, based on IAM roles assigned to the user(s) accessing Instance, it may allow user to perform cloud operations/API calls that user is not supposed to perform leading to successful privilege escalation.", + "ImpactStatement": "In order to change service account or scope for an instance, it needs to be stopped.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the impacted VM instance. 3. If the instance is not stopped, click the `Stop` button. Wait for the instance to be stopped. 4. Next, click the `Edit` button. 5. Scroll down to the `Service Account` section. 6. Select a different service account or ensure that `Allow full access to all Cloud APIs` is not selected. 7. Click the `Save` button to save your changes and then click `START`. **From Google Cloud CLI** 1. Stop the instance: gcloud compute instances stop 2. Update the instance: gcloud compute instances set-service-account --service-account= --scopes [SCOPE1, SCOPE2...] 3. Restart the instance: gcloud compute instances start ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on each instance name to go to its `VM instance details` page. 3. Under the `API and identity management`, ensure that `Cloud API access scopes` is not set to `Allow full access to all Cloud APIs`. **From Google Cloud CLI** 1. List the instances in your project and get details on each instance: gcloud compute instances list --format=json | jq -r '. | SA Scopes: (.[].serviceAccounts[].scopes) Name: (.[].name) Email: (.[].serviceAccounts[].email)' 2. Ensure that the service account section has an email that does not match the pattern `[PROJECT_NUMBER]-compute@developer.gserviceaccount.com`. **Exception:** VMs created by GKE should be excluded. These VMs have names that start with `gke-` and are labeled `goog-gke-node", + "AdditionalInformation": "- User IAM roles will override service account scope but configuring minimal scope ensures defense in depth - Non-default service accounts do not offer selection of access scopes like default service account. IAM roles with non-default service accounts should be used to control VM access.", + "References": "https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances:https://cloud.google.com/compute/docs/access/service-accounts", + "DefaultValue": "While creating an VM instance, default service account is used with scope `Allow default access`." + } + ] + }, + { + "Id": "4.3", + "Description": "Ensure “Block Project-Wide SSH Keys” Is Enabled for VM Instances", + "Checks": [ + "compute_instance_block_project_wide_ssh_keys_disabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to use Instance specific SSH key(s) instead of using common/shared project-wide SSH key(s) to access Instances.", + "RationaleStatement": "Project-wide SSH keys are stored in Compute/Project-meta-data. Project wide SSH keys can be used to login into all the instances within project. Using project-wide SSH keys eases the SSH key management but if compromised, poses the security risk which can impact all the instances within project. It is recommended to use Instance specific SSH keys which can limit the attack surface if the SSH keys are compromised.", + "ImpactStatement": "Users already having Project-wide ssh key pairs and using third party SSH clients will lose access to the impacted Instances. For Project users using gcloud or GCP Console based SSH option, no manual key creation and distribution is required and will be handled by GCE (Google Compute Engine) itself. To access Instance using third party SSH clients Instance specific SSH key pairs need to be created and distributed to the required users.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). It will list all the instances in your project. 2. Click on the name of the Impacted instance 3. Click `Edit` in the toolbar 4. Under SSH Keys, go to the `Block project-wide SSH keys` checkbox 5. To block users with project-wide SSH keys from connecting to this instance, select `Block project-wide SSH keys` 6. Click `Save` at the bottom of the page 7. Repeat steps for every impacted Instance **From Google Cloud CLI** To block project-wide public SSH keys, set the metadata value to `TRUE`: gcloud compute instances add-metadata --metadata block-project-ssh-keys=TRUE ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). It will list all the instances in your project. 2. For every instance, click on the name of the instance. 3. Under `SSH Keys`, ensure `Block project-wide SSH keys` is selected. **From Google Cloud CLI** 1. List the instances in your project and get details on each instance: gcloud compute instances list --format=json 2. Ensure `key: block-project-ssh-keys` is set to `value: 'true'`.", + "AdditionalInformation": "If OS Login is enabled, SSH keys in instance metadata are ignored, and therefore blocking project-wide SSH keys is not necessary.", + "References": "https://cloud.google.com/compute/docs/instances/adding-removing-ssh-keys:https://cloud.google.com/sdk/gcloud/reference/topic/formats", + "DefaultValue": "By Default `Block Project-wide SSH keys` is not enabled." + } + ] + }, + { + "Id": "4.4", + "Description": "Ensure Oslogin Is Enabled for a Project", + "Checks": [ + "compute_project_os_login_enabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enabling OS login binds SSH certificates to IAM users and facilitates effective SSH certificate management.", + "RationaleStatement": "Enabling osLogin ensures that SSH keys used to connect to instances are mapped with IAM users. Revoking access to IAM user will revoke all the SSH keys associated with that particular user. It facilitates centralized and automated SSH key pair management which is useful in handling cases like response to compromised SSH key pairs and/or revocation of external/third-party/Vendor users.", + "ImpactStatement": "Enabling OS Login on project disables metadata-based SSH key configurations on all instances from a project. Disabling OS Login restores SSH keys that you have configured in project or instance meta-data.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the VM compute metadata page by visiting: [https://console.cloud.google.com/compute/metadata](https://console.cloud.google.com/compute/metadata). 2. Click `Edit`. 3. Add a metadata entry where the key is `enable-oslogin` and the value is `TRUE`. 4. Click `Save` to apply the changes. 5. For every instance that overrides the project setting, go to the `VM Instances` page at [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 6. Click the name of the instance on which you want to remove the metadata value. 7. At the top of the instance details page, click `Edit` to edit the instance settings. 8. Under `Custom metadata`, remove any entry with key `enable-oslogin` and the value is `FALSE` 9. At the bottom of the instance details page, click `Save` to apply your changes to the instance. **From Google Cloud CLI** 1. Configure oslogin on the project: gcloud compute project-info add-metadata --metadata enable-oslogin=TRUE 2. Remove instance metadata that overrides the project setting. gcloud compute instances remove-metadata --keys=enable-oslogin Optionally, you can enable two factor authentication for OS login. For more information, see: [https://cloud.google.com/compute/docs/oslogin/setup-two-factor-authentication](https://cloud.google.com/compute/docs/oslogin/setup-two-factor-authentication).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the VM compute metadata page by visiting [https://console.cloud.google.com/compute/metadata](https://console.cloud.google.com/compute/metadata). 2. Ensure that key `enable-oslogin` is present with value set to `TRUE`. 3. Because instances can override project settings, ensure that no instance has custom metadata with key `enable-oslogin` and value `FALSE`. **From Google Cloud CLI** 1. List the instances in your project and get details on each instance: gcloud compute instances list --format=json 2. Verify that the section `commonInstanceMetadata` has a key `enable-oslogin` set to value `TRUE`. **Exception:** VMs created by GKE should be excluded. These VMs have names that start with `gke-` and are labeled `goog-gke-node`", + "AdditionalInformation": "1. In order to use osLogin, instance using Custom Images must have the latest version of the Linux Guest Environment installed. The following image families do not yet support OS Login: Project cos-cloud (Container-Optimized OS) image family cos-stable. All project coreos-cloud (CoreOS) image families Project suse-cloud (SLES) image family sles-11 All Windows Server and SQL Server image families 2. Project enable-oslogin can be over-ridden by setting enable-oslogin parameter to an instance metadata individually.", + "References": "https://cloud.google.com/compute/docs/instances/managing-instance-access:https://cloud.google.com/compute/docs/instances/managing-instance-access#enable_oslogin:https://cloud.google.com/sdk/gcloud/reference/compute/instances/remove-metadata:https://cloud.google.com/compute/docs/oslogin/setup-two-factor-authentication", + "DefaultValue": "By default, parameter `enable-oslogin` is not set, which is equivalent to setting it to `FALSE`." + } + ] + }, + { + "Id": "4.5", + "Description": "Ensure ‘Enable Connecting to Serial Ports’ Is Not Enabled for VM Instance", + "Checks": [ + "compute_instance_serial_ports_in_use" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Interacting with a serial port is often referred to as the serial console, which is similar to using a terminal window, in that input and output is entirely in text mode and there is no graphical interface or mouse support. If you enable the interactive serial console on an instance, clients can attempt to connect to that instance from any IP address. Therefore interactive serial console support should be disabled.", + "RationaleStatement": "A virtual machine instance has four virtual serial ports. Interacting with a serial port is similar to using a terminal window, in that input and output is entirely in text mode and there is no graphical interface or mouse support. The instance's operating system, BIOS, and other system-level entities often write output to the serial ports, and can accept input such as commands or answers to prompts. Typically, these system-level entities use the first serial port (port 1) and serial port 1 is often referred to as the serial console. The interactive serial console does not support IP-based access restrictions such as IP whitelists. If you enable the interactive serial console on an instance, clients can attempt to connect to that instance from any IP address. This allows anybody to connect to that instance if they know the correct SSH key, username, project ID, zone, and instance name. Therefore interactive serial console support should be disabled.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** 1. Login to Google Cloud console 2. Go to Computer Engine 3. Go to VM instances 4. Click on the Specific VM 5. Click `EDIT` 6. Unselect `Enable connecting to serial ports` below `Remote access` block. 7. Click `Save` **From Google Cloud CLI** Use the below command to disable gcloud compute instances add-metadata --zone= --metadata=serial-port-enable=false or gcloud compute instances add-metadata --zone= --metadata=serial-port-enable=0 **Prevention:** You can prevent VMs from having serial port access enable by `Disable VM serial port access` organization policy: [https://console.cloud.google.com/iam-admin/orgpolicies/compute-disableSerialPortAccess](https://console.cloud.google.com/iam-admin/orgpolicies/compute-disableSerialPortAccess).", + "AuditProcedure": "**From Google Cloud Console** 1. Login to Google Cloud console 2. Go to Compute Engine 3. Go to VM instances 4. Click on the Specific VM 5. Ensure the statement `Connecting to serial serial ports is disabled` is displayed at the top of the details tab, just below the `Connect to serial console` drop-down.. **From Google Cloud CLI** Ensure the below command's output shows `null`: gcloud compute instances describe --zone= --format=json(metadata.items[].key,metadata.items[].value) or `key` and `value` properties from below command's json response are equal to `serial-port-enable` and `0` or `false` respectively. { metadata: { items: [ { key: serial-port-enable, value: 0 } ] } } ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/compute/docs/instances/interacting-with-serial-console", + "DefaultValue": "By default, connecting to serial ports is not enabled." + } + ] + }, + { + "Id": "4.6", + "Description": "Ensure That IP Forwarding Is Not Enabled on Instances", + "Checks": [ + "compute_instance_ip_forwarding_is_enabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Compute Engine instance cannot forward a packet unless the source IP address of the packet matches the IP address of the instance. Similarly, GCP won't deliver a packet whose destination IP address is different than the IP address of the instance receiving the packet. However, both capabilities are required if you want to use instances to help route packets. Forwarding of data packets should be disabled to prevent data loss or information disclosure.", + "RationaleStatement": "Compute Engine instance cannot forward a packet unless the source IP address of the packet matches the IP address of the instance. Similarly, GCP won't deliver a packet whose destination IP address is different than the IP address of the instance receiving the packet. However, both capabilities are required if you want to use instances to help route packets. To enable this source and destination IP check, disable the `canIpForward` field, which allows an instance to send and receive packets with non-matching destination or source IPs.", + "ImpactStatement": "", + "RemediationProcedure": "You only edit the `canIpForward` setting at instance creation or using CLI. **From Google Cloud CLI** 1. Use the instances export command to export the existing instance properties: gcloud compute instances export --project --zone --destination= **Note**Replace the following: INSTANCE_NAME the name for the instance that you want to export. PROJECT_ID: the project ID for this request. ZONE: the zone for this instance. FILE_PATH: the output path where you want to save the instance configuration file on your local workstation. 2. Use a text editor to modify this file Replace `canIpForward: true` with `canIpForward: false` 3. Run this command to import the file you just modified gcloud compute instances update-from-file INSTANCE_NAME --project PROJECT_ID --zone ZONE --source=FILE_PATH --most-disruptive-allowed-action=REFRESH If the update request is valid and the required resources are available, the instance update process begins. You can monitor the status of this operation by viewing the audit logs. This update requires only a REFRESH not a full restart.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM Instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. For every instance, click on its name to go to the `VM instance details` page. 3. Under the `Network interfaces` section, ensure that `IP forwarding` is set to `Off` for every network interface. **From Google Cloud CLI** 1. List all instances: gcloud compute instances list --format='table(name,canIpForward)' 2. Ensure that `CAN_IP_FORWARD` column in the output of above command does not contain `True` for any VM instance. **Exception:** Instances created by GKE should be excluded because they need to have IP forwarding enabled and cannot be changed. Instances created by GKE have names that start with gke-.", + "AdditionalInformation": "You can only set the `canIpForward` field at instance creation time or using CLI.", + "References": "https://cloud.google.com/vpc/docs/using-routes#canipforward:https://cloud.google.com/compute/docs/instances/update-instance-properties", + "DefaultValue": "By default, instances are not configured to allow IP forwarding." + } + ] + }, + { + "Id": "4.7", + "Description": "Ensure VM Disks for Critical VMs Are Encrypted With Customer-Supplied Encryption Keys (CSEK)", + "Checks": [ + "compute_instance_encryption_with_csek_enabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Customer-Supplied Encryption Keys (CSEK) are a feature in Google Cloud Storage and Google Compute Engine. If you supply your own encryption keys, Google uses your key to protect the Google-generated keys used to encrypt and decrypt your data. By default, Google Compute Engine encrypts all data at rest. Compute Engine handles and manages this encryption for you without any additional actions on your part. However, if you wanted to control and manage this encryption yourself, you can provide your own encryption keys.", + "RationaleStatement": "By default, Google Compute Engine encrypts all data at rest. Compute Engine handles and manages this encryption for you without any additional actions on your part. However, if you wanted to control and manage this encryption yourself, you can provide your own encryption keys. If you provide your own encryption keys, Compute Engine uses your key to protect the Google-generated keys used to encrypt and decrypt your data. Only users who can provide the correct key can use resources protected by a customer-supplied encryption key. Google does not store your keys on its servers and cannot access your protected data unless you provide the key. This also means that if you forget or lose your key, there is no way for Google to recover the key or to recover any data encrypted with the lost key. At least business critical VMs should have VM disks encrypted with CSEK.", + "ImpactStatement": "If you lose your encryption key, you will not be able to recover the data.", + "RemediationProcedure": "Currently there is no way to update the encryption of an existing disk. Therefore you should create a new disk with `Encryption` set to `Customer supplied`. **From Google Cloud Console** 1. Go to Compute Engine `Disks` by visiting: [https://console.cloud.google.com/compute/disks](https://console.cloud.google.com/compute/disks). 2. Click `CREATE DISK`. 3. Set `Encryption type` to `Customer supplied`, 4. Provide the `Key` in the box. 5. Select `Wrapped key`. 6. Click `Create`. **From Google Cloud CLI** In the gcloud compute tool, encrypt a disk using the --csek-key-file flag during instance creation. If you are using an RSA-wrapped key, use the gcloud beta component: gcloud compute instances create --csek-key-file To encrypt a standalone persistent disk: gcloud compute disks create --csek-key-file ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to Compute Engine `Disks` by visiting: [https://console.cloud.google.com/compute/disks](https://console.cloud.google.com/compute/disks). 2. Click on the disk for your critical VMs to see its configuration details. 4. Ensure that `Encryption type` is set to `Customer supplied`. **From Google Cloud CLI** Ensure `diskEncryptionKey` property in the below command's response is not null, and contains key `sha256` with corresponding value gcloud compute disks describe --zone --format=json(diskEncryptionKey,name) ", + "AdditionalInformation": "`Note 1:` When you delete a persistent disk, Google discards the cipher keys, rendering the data irretrievable. This process is irreversible. `Note 2:` It is up to you to generate and manage your key. You must provide a key that is a 256-bit string encoded in RFC 4648 standard base64 to Compute Engine. `Note 3:` An example key file looks like this. [ { uri: https://www.googleapis.com/compute/v1/projects/myproject/zones/us-central1-a/disks/example-disk, key: acXTX3rxrKAFTF0tYVLvydU1riRZTvUNC4g5I11NY-c=, key-type: raw }, { uri: https://www.googleapis.com/compute/v1/projects/myproject/global/snapshots/my-private-snapshot, key: ieCx/NcW06PcT7Ep1X6LUTc/hLvUDYyzSZPPVCVPTVEohpeHASqC8uw5TzyO9U+Fka9JFHz0mBibXUInrC/jEk014kCK/NPjYgEMOyssZ4ZINPKxlUh2zn1bV+MCaTICrdmuSBTWlUUiFoDD6PYznLwh8ZNdaheCeZ8ewEXgFQ8V+sDroLaN3Xs3MDTXQEMMoNUXMCZEIpg9Vtp9x2oeQ5lAbtt7bYAAHf5l+gJWw3sUfs0/Glw5fpdjT8Uggrr+RMZezGrltJEF293rvTIjWOEB3z5OHyHwQkvdrPDFcTqsLfh+8Hr8g+mf+7zVPEC8nEbqpdl3GPv3A7AwpFp7MA== key-type: rsa-encrypted } ]", + "References": "https://cloud.google.com/compute/docs/disks/customer-supplied-encryption#encrypt_a_new_persistent_disk_with_your_own_keys:https://cloud.google.com/compute/docs/reference/rest/v1/disks/get:https://cloud.google.com/compute/docs/disks/customer-supplied-encryption#key_file", + "DefaultValue": "By default, VM disks are encrypted with Google-managed keys. They are not encrypted with Customer-Supplied Encryption Keys." + } + ] + }, + { + "Id": "4.8", + "Description": "Ensure Compute Instances Are Launched With Shielded VM Enabled", + "Checks": [ + "compute_instance_shielded_vm_enabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "To defend against advanced threats and ensure that the boot loader and firmware on your VMs are signed and untampered, it is recommended that Compute instances are launched with Shielded VM enabled.", + "RationaleStatement": "Shielded VMs are virtual machines (VMs) on Google Cloud Platform hardened by a set of security controls that help defend against rootkits and bootkits. Shielded VM offers verifiable integrity of your Compute Engine VM instances, so you can be confident your instances haven't been compromised by boot- or kernel-level malware or rootkits. Shielded VM's verifiable integrity is achieved through the use of Secure Boot, virtual trusted platform module (vTPM)-enabled Measured Boot, and integrity monitoring. Shielded VM instances run firmware which is signed and verified using Google's Certificate Authority, ensuring that the instance's firmware is unmodified and establishing the root of trust for Secure Boot. Integrity monitoring helps you understand and make decisions about the state of your VM instances and the Shielded VM vTPM enables Measured Boot by performing the measurements needed to create a known good boot baseline, called the integrity policy baseline. The integrity policy baseline is used for comparison with measurements from subsequent VM boots to determine if anything has changed. Secure Boot helps ensure that the system only runs authentic software by verifying the digital signature of all boot components, and halting the boot process if signature verification fails.", + "ImpactStatement": "", + "RemediationProcedure": "To be able turn on `Shielded VM` on an instance, your instance must use an image with Shielded VM support. **From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the instance name to see its `VM instance details` page. 3. Click `STOP` to stop the instance. 4. When the instance has stopped, click `EDIT`. 5. In the Shielded VM section, select `Turn on vTPM` and `Turn on Integrity Monitoring`. 6. Optionally, if you do not use any custom or unsigned drivers on the instance, also select `Turn on Secure Boot`. 7. Click the `Save` button to modify the instance and then click `START` to restart it. **From Google Cloud CLI** You can only enable Shielded VM options on instances that have Shielded VM support. For a list of Shielded VM public images, run the gcloud compute images list command with the following flags: gcloud compute images list --project gce-uefi-images --no-standard-images 1. Stop the instance: gcloud compute instances stop 2. Update the instance: gcloud compute instances update --shielded-vtpm --shielded-vm-integrity-monitoring 3. Optionally, if you do not use any custom or unsigned drivers on the instance, also turn on secure boot. gcloud compute instances update --shielded-vm-secure-boot 4. Restart the instance: gcloud compute instances start **Prevention:** You can ensure that all new VMs will be created with Shielded VM enabled by setting up an Organization Policy to for `Shielded VM` at [https://console.cloud.google.com/iam-admin/orgpolicies/compute-requireShieldedVm](https://console.cloud.google.com/iam-admin/orgpolicies/compute-requireShieldedVm). Learn more at: [https://cloud.google.com/security/shielded-cloud/shielded-vm#organization-policy-constraint](https://cloud.google.com/security/shielded-cloud/shielded-vm#organization-policy-constraint).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the instance name to see its `VM instance details` page. 3. Under the section `Shielded VM`, ensure that `vTPM` and `Integrity Monitoring` are `on`. **From Google Cloud CLI** 1. For each instance in your project, get its metadata: gcloud compute instances list --format=json | jq -r '. | vTPM: (.[].shieldedInstanceConfig.enableVtpm) IntegrityMonitoring: (.[].shieldedInstanceConfig.enableIntegrityMonitoring) Name: (.[].name)' 2. Ensure that there is a `shieldedInstanceConfig` configuration and that configuration has the `enableIntegrityMonitoring` and `enableVtpm` set to `true`. If the VM is not a Shield VM image, you will not see a shieldedInstanceConfig` in the output.", + "AdditionalInformation": "If you do use custom or unsigned drivers on the instance, enabling Secure Boot will cause the machine to no longer boot. Turn on Secure Boot only on instances that have been verified to not have any custom drivers installed.", + "References": "https://cloud.google.com/compute/docs/instances/modifying-shielded-vm:https://cloud.google.com/shielded-vm:https://cloud.google.com/security/shielded-cloud/shielded-vm#organization-policy-constraint", + "DefaultValue": "By default, Compute Instances do not have Shielded VM enabled." + } + ] + }, + { + "Id": "4.9", + "Description": "Ensure That Compute Instances Do Not Have Public IP Addresses", + "Checks": [ + "compute_instance_public_ip" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Compute instances should not be configured to have external IP addresses.", + "RationaleStatement": "To reduce your attack surface, Compute instances should not have public IP addresses. Instead, instances should be configured behind load balancers, to minimize the instance's exposure to the internet.", + "ImpactStatement": "Removing the external IP address from your Compute instance may cause some applications to stop working.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the instance name to go the the `Instance detail page`. 3. Click `Edit`. 4. For each Network interface, ensure that `External IP` is set to `None`. 5. Click `Done` and then click `Save`. **From Google Cloud CLI** 1. Describe the instance properties: gcloud compute instances describe --zone= 2. Identify the access config name that contains the external IP address. This access config appears in the following format: networkInterfaces: - accessConfigs: - kind: compute#accessConfig name: External NAT natIP: 130.211.181.55 type: ONE_TO_ONE_NAT 3. Delete the access config. gcloud compute instances delete-access-config --zone= --access-config-name In the above example, the `ACCESS_CONFIG_NAME` is `External NAT`. The name of your access config might be different. **Prevention:** You can configure the `Define allowed external IPs for VM instances` Organization Policy to prevent VMs from being configured with public IP addresses. Learn more at: [https://console.cloud.google.com/orgpolicies/compute-vmExternalIpAccess](https://console.cloud.google.com/orgpolicies/compute-vmExternalIpAccess)", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. For every VM, ensure that there is no `External IP` configured. **From Google Cloud CLI** gcloud compute instances list --format=json 1. The output should not contain an `accessConfigs` section under `networkInterfaces`. Note that the `natIP` value is present only for instances that are running or for instances that are stopped but have a static IP address. For instances that are stopped and are configured to have an ephemeral public IP address, the `natIP` field will not be present. Example output: networkInterfaces: - accessConfigs: - kind: compute#accessConfig name: External NAT networkTier: STANDARD type: ONE_TO_ONE_NAT **Exception:** Instances created by GKE should be excluded because some of them have external IP addresses and cannot be changed by editing the instance settings. Instances created by GKE should be excluded. These instances have names that start with gke- and are labeled goog-gke-node.", + "AdditionalInformation": "You can connect to Linux VMs that do not have public IP addresses by using Identity-Aware Proxy for TCP forwarding. Learn more at [https://cloud.google.com/compute/docs/instances/connecting-advanced#sshbetweeninstances](https://cloud.google.com/compute/docs/instances/connecting-advanced#sshbetweeninstances) For Windows VMs, see [https://cloud.google.com/compute/docs/instances/connecting-to-instance](https://cloud.google.com/compute/docs/instances/connecting-to-instance).", + "References": "https://cloud.google.com/load-balancing/docs/backend-service#backends_and_external_ip_addresses:https://cloud.google.com/compute/docs/instances/connecting-advanced#sshbetweeninstances:https://cloud.google.com/compute/docs/instances/connecting-to-instance:https://cloud.google.com/compute/docs/ip-addresses/reserve-static-external-ip-address#unassign_ip:https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints", + "DefaultValue": "By default, Compute instances have a public IP address." + } + ] + }, + { + "Id": "4.1", + "Description": "Ensure That App Engine Applications Enforce HTTPS Connections", + "Checks": [], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "In order to maintain the highest level of security all connections to an application should be secure by default.", + "RationaleStatement": "Insecure HTTP connections maybe subject to eavesdropping which can expose sensitive data.", + "ImpactStatement": "All connections to appengine will automatically be redirected to the HTTPS endpoint ensuring that all connections are secured by TLS.", + "RemediationProcedure": "Add a line to the app.yaml file controlling the application which enforces secure connections. For example handlers: - url: /.* **secure: always** redirect_http_response_code: 301 script: auto [https://cloud.google.com/appengine/docs/standard/python3/config/appref]", + "AuditProcedure": "Verify that the app.yaml file controlling the application contains a line which enforces secure connections. For example handlers: - url: /.* secure: always redirect_http_response_code: 301 script: auto [https://cloud.google.com/appengine/docs/standard/python3/config/appref](https://cloud.google.com/appengine/docs/standard/python3/config/appref)", + "AdditionalInformation": "", + "References": "https://cloud.google.com/appengine/docs/standard/python3/config/appref:https://cloud.google.com/appengine/docs/flexible/nodejs/configuring-your-app-with-app-yaml", + "DefaultValue": "By default both HTTP and HTTP are supported" + } + ] + }, + { + "Id": "4.11", + "Description": "Ensure That Compute Instances Have Confidential Computing Enabled", + "Checks": [ + "compute_instance_confidential_computing_enabled" + ], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "Google Cloud encrypts data at-rest and in-transit, but customer data must be decrypted for processing. Confidential Computing is a breakthrough technology that encrypts data in-use while it is being processed. Confidential Computing environments keep data encrypted in memory and elsewhere outside the central processing unit (CPU). Confidential VMs leverage hardware-based memory encryption technologies, such as AMD Secure Encrypted Virtualization (SEV), AMD SEV-SNP, and Intel Trust Domain Extensions (TDX), depending on the chosen machine type and CPU platform. Customer data will stay encrypted while it is used, indexed, queried, or trained on. Encryption keys are generated by and reside solely in dedicated hardware and are not exportable, enhancing isolation and security. Built-in hardware optimizations ensure Confidential Computing workloads experience minimal to no significant performance penalties.", + "RationaleStatement": "Confidential Computing enables customers' sensitive code and other data encrypted in memory during processing. Google does not have access to the encryption keys. Confidential VM can help alleviate concerns about risk related to either dependency on Google infrastructure or Google insiders' access to customer data in the clear.", + "ImpactStatement": "- Confidential Computing for Compute instances does not support live migration. Unlike regular Compute instances, Confidential VMs experience disruptions during maintenance events like a software or hardware update. - Additional charges may be incurred when enabling this security feature. See [https://cloud.google.com/compute/confidential-vm/pricing](https://cloud.google.com/compute/confidential-vm/pricing) for more info.", + "RemediationProcedure": "Confidential Computing can only be enabled when an instance is created. You must delete the current instance and create a new one. **From Google Cloud Console** 1. Go to the VM instances page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click `CREATE INSTANCE`. 3. Fill out the desired configuration for your instance. 4. Under the `Confidential VM service` section, check the option `Enable the Confidential Computing service on this VM instance`. 5. Click `Create`. **From Google Cloud CLI** Create a new instance with Confidential Compute enabled. gcloud compute instances create --zone --confidential-compute --maintenance-policy=TERMINATE ", + "AuditProcedure": "Note: Confidential Computing is currently only supported on limited VM configurations. To learn more about VM configurations supported by Confidential Computing, visit [https://cloud.google.com/confidential-computing/confidential-vm/docs/supported-configurations](https://cloud.google.com/confidential-computing/confidential-vm/docs/supported-configurations) **From Google Cloud Console** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on the instance name to see its VM instance details page. 3. Ensure that `Confidential VM service` is `Enabled`. **From Google Cloud CLI** 1. List the instances in your project and get details on each instance: gcloud compute instances list --format=json 2. Ensure that `enableConfidentialCompute` is set to `true` for all instances with machine type starting with n2d-. confidentialInstanceConfig: enableConfidentialCompute: true ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/compute/confidential-vm/docs/creating-cvm-instance:https://cloud.google.com/compute/confidential-vm/docs/about-cvm:https://cloud.google.com/confidential-computing:https://cloud.google.com/blog/products/identity-security/introducing-google-cloud-confidential-computing-with-confidential-vms", + "DefaultValue": "By default, Confidential Computing is disabled for Compute instances." + } + ] + }, + { + "Id": "4.12", + "Description": "Ensure the Latest Operating System Updates Are Installed On Your Virtual Machines in All Projects", + "Checks": [], + "Attributes": [ + { + "Section": "4 Virtual Machines", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "Google Cloud Virtual Machines have the ability via an OS Config agent API to periodically (about every 10 minutes) report OS inventory data. A patch compliance API periodically reads this data, and cross references metadata to determine if the latest updates are installed. This is not the only Patch Management solution available to your organization and you should weigh your needs before committing to using this method.", + "RationaleStatement": "Keeping virtual machine operating systems up to date is a security best practice. Using this service will simplify this process.", + "ImpactStatement": "Most Operating Systems require a restart or changing critical resources to apply the updates. Using the Google Cloud VM manager for its OS Patch management will incur additional costs for each VM managed by it. Please view the VM manager pricing reference for further information.", + "RemediationProcedure": "**From Google Cloud Console** **Enabling OS Patch Management on a Project by Project Basis** **Install OS Config API for the Project** 1. Navigate into a project. In the expanded portal menu located at the top left of the screen hover over APIs & Services. Then in the menu right of that select API Libraries 2. Search for VM Manager (OS Config API) or scroll down in the left hand column and select the filter labeled Compute where it is the last listed. Open this API. 3. Click the blue 'Enable' button. **Add MetaData Tags for OSConfig Parsing** 1. From the main Google Cloud console, open the portal menu in the top left. Mouse over Computer Engine to expand the menu next to it. 2. Under the Settings heading, select Metadata. 3. In this view there will be a list of the project wide metadata tags for VMs. Click edit and 'add item' in the key column type 'enable-osconfig' and in the value column set it to 'true'. From Command Line 1. For project wide tagging, run the following command gcloud compute project-info add-metadata --project --metadata=enable-osconfig=TRUE Please see the reference /compute/docs/troubleshooting/vm-manager/verify-setup#metadata-enabled at the bottom for more options like instance specific tagging. Note: Adding a new tag via commandline may overwrite existing tags. You will need to do this at a time of low usage for the least impact. **Install and Start the Local OSConfig for Data Parsing** There is no way to centrally manage or start the Local OSConfig agent. Please view the reference of manage-os#agent-install to view specific operating system commands. **Setup a project wide Service Account** Please view Recommendation 4.1 to view how to setup a service account. Rerun the audit procedure to test if it has taken effect. **Enable NAT or Configure Private Google Access to allow Access to Public Update Hosting** For the sake of brevity, please see the attached resources to enable NAT or Private Google Access. Rerun the audit procedure to test if it has taken effect. From Command Line: **Install OS Config API for the Project** 1. In each project you wish to audit run gcloud services enable osconfig.googleapis.com **Install and Start the Local OSConfig for Data Parsing** Please view the reference of manage-os#agent-install to view specific operating system commands. **Setup a project wide Service Account** Please view Recommendation 4.1 to view how to setup a service account. Rerun the audit procedure to test if it has taken effect. **Enable NAT or Configure Private Google Access to allow Access to Public Update Hosting** For the sake of brevity, please see the attached resources to enable NAT or Private Google Access. Rerun the audit procedure to test if it has taken effect. Determine if Instances can connect to public update hosting Linux Debian Based Operating Systems sudo apt update The output should have a numbered list of lines with Hit: URL of updates. Redhat Based Operating Systems yum check-update The output should show a list of packages that have updates available. Windows ping http://windowsupdate.microsoft.com/ The ping should successfully be delivered and received.", + "AuditProcedure": "**From Google Cloud Console** **Determine if OS Config API is Enabled for the Project** 1. Navigate into a project. In the expanded navigation menu located at the top left of the screen hover over `APIs & Services`. Then in the menu right of that select `API Libraries` 2. Search for VM Manager (OS Config API) or scroll down in the left hand column and select the filter labeled Compute where it is the last listed. Open this API. 3. Verify the blue button at the top is enabled. **Determine if VM Instances have correct metadata tags for OSConfig parsing** 1. From the main Google Cloud console, open the hamburger menu in the top left. Mouse over Computer Engine to expand the menu next to it. 1. Under the Settings heading, select Metadata. 1. In this view there will be a list of the project wide metadata tags for VMs. Determine if the tag enable-osconfig is set to true. **Determine if the Operating System of VM Instances have the local OS-Config Agent running** There is no way to determine this from the Google Cloud console. The only way is to run operating specific commands locally inside the operating system via remote connection. For the sake of brevity of this recommendation please view the docs/troubleshooting/vm-manager/verify-setup reference at the bottom of the page. If you initialized your VM instance with a Google Supplied OS Image with a build date of later than v20200114 it will have the service installed. You should still determine its status for proper operation. **Verify the service account you have setup for the project in Recommendation 4.1 is running** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on each instance name to go to its `VM instance details` page. 3. Under the section `Service Account`, take note of the service account 4. Run the commands locally for your operating system that are located at the docs/troubleshooting/vm-manager/verify-setup#service-account-enabled reference located at the bottom of this page. They should return the name of your service account. **Determine if Instances can connect to public update hosting** Each type of operating system has its own update process. You will need to determine on each operating system that it can reach the update servers via its network connection. The VM Manager doesn't host the updates, it will only allow you to centrally issue a command to each VM to update. **Determine if OS Config API is Enabled for the Project** 1. In each project you wish to enable run the following command gcloud services list 2. If osconfig.googleapis.com is in the left hand column it is enabled for this project. **Determine if VM Manager is Enabled for the Project** 1. Within the project run the following command: gcloud compute instances os-inventory describe VM-NAME --zone=ZONE The output will look like INSTANCE_ID INSTANCE_NAME OS OSCONFIG_AGENT_VERSION UPDATE_TIME 29255009728795105 centos7 CentOS Linux 7 (Core) 20210217.00-g1.el7 2021-04-12T22:19:36.559Z 5138980234596718741 rhel-8 Red Hat Enterprise Linux 8.3 (Ootpa) 20210316.00-g1.el8 2021-09-16T17:19:24Z 7127836223366142250 windows Microsoft Windows Server 2019 Datacenter 20210316.00.0+win@1 2021-09-16T17:13:18Z **Determine if VM Instances have correct metadata tags for OSConfig parsing** 1. Select the project you want to view tagging in. **From Google Cloud Console** 1. From the main Google Cloud console, open the hamburger menu in the top left. Mouse over Computer Engine to expand the menu next to it. 2. Under the Settings heading, select Metadata. 3. In this view there will be a list of the project wide metadata tags for Vms. Verify a tag of ‘enable-osconfig’ is in this list and it is set to ‘true’. **From Command Line** Run the following command to view instance data gcloud compute instances list --format=table(name,status,tags.list()) On each instance it should have a tag of ‘enable-osconfig’ set to ‘true’ **Determine if the Operating System of VM Instances have the local OS-Config Agent running** There is no way to determine this from the Google Cloud CLI. The best way is to run the the commands inside the operating system located at 'Check OS-Config agent is installed and running' at the /docs/troubleshooting/vm-manager/verify-setup reference at the bottom of the page. If you initialized your VM instance with a Google Supplied OS Image with a build date of later than v20200114 it will have the service installed. You should still determine its status. **Verify the service account you have setup for the project in Recommendation 4.1 is running** 1. Go to the `VM instances` page by visiting: [https://console.cloud.google.com/compute/instances](https://console.cloud.google.com/compute/instances). 2. Click on each instance name to go to its `VM instance details` page. 3. Under the section `Service Account`, take note of the service account 4. View the compute/docs/troubleshooting/vm-manager/verify-setup#service-account-enabled resource at the bottom of the page for operating system specific commands to run locally. **Determine if Instances can connect to public update hosting** Linux Debian Based Operating Systems sudo apt update The output should have a numbered list of lines with Hit: URL of updates. Redhat Based Operating Systems yum check-update The output should show a list of packages that have updates available. Windows ping http://windowsupdate.microsoft.com/ The ping should successfully be delivered and received.", + "AdditionalInformation": "This is not your only solution to handle updates. This is a Google Cloud specific recommendation to leverage a resource to solve the need for comprehensive update procedures and policy. If you have a solution already in place you do not need to make the switch. There are also further resources that would be out of the scope of this recommendation. If you need to allow your VMs to access public hosted updates, please see the reference to setup NAT or Private Google Access.", + "References": "https://cloud.google.com/compute/docs/manage-os:https://cloud.google.com/compute/docs/os-patch-management:https://cloud.google.com/compute/docs/vm-manager:https://cloud.google.com/compute/docs/images/os-details#vm-manager:https://cloud.google.com/compute/docs/vm-manager#pricing:https://cloud.google.com/compute/docs/troubleshooting/vm-manager/verify-setup:https://cloud.google.com/compute/docs/instances/view-os-details#view-data-tools:https://cloud.google.com/compute/docs/os-patch-management/create-patch-job:https://cloud.google.com/nat/docs/set-up-network-address-translation:https://cloud.google.com/vpc/docs/configure-private-google-access:https://workbench.cisecurity.org/sections/811638/recommendations/1334335:https://cloud.google.com/compute/docs/manage-os#agent-install:https://cloud.google.com/compute/docs/troubleshooting/vm-manager/verify-setup#service-account-enabled:https://cloud.google.com/compute/docs/os-patch-management#use-dashboard:https://cloud.google.com/compute/docs/troubleshooting/vm-manager/verify-setup#metadata-enabled", + "DefaultValue": "By default most operating systems and programs do not update themselves. The Google Cloud VM Manager which is a dependency of the OS Patch management feature is installed on Google Built OS images with a build date of v20200114 or later. The VM manager is not enabled in a project by default and will need to be setup." + } + ] + }, + { + "Id": "5.1", + "Description": "Ensure That Cloud Storage Bucket Is Not Anonymously or Publicly Accessible", + "Checks": [ + "cloudstorage_bucket_public_access" + ], + "Attributes": [ + { + "Section": "5 Storage", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that IAM policy on Cloud Storage bucket does not allows anonymous or public access.", + "RationaleStatement": "Allowing anonymous or public access grants permissions to anyone to access bucket content. Such access might not be desired if you are storing any sensitive data. Hence, ensure that anonymous or public access to a bucket is not allowed.", + "ImpactStatement": "No storage buckets would be publicly accessible. You would have to explicitly administer bucket access.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `Storage browser` by visiting [https://console.cloud.google.com/storage/browser](https://console.cloud.google.com/storage/browser). 2. Click on the bucket name to go to its `Bucket details` page. 3. Click on the `Permissions` tab. 4. Click `Delete` button in front of `allUsers` and `allAuthenticatedUsers` to remove that particular role assignment. **From Google Cloud CLI** Remove `allUsers` and `allAuthenticatedUsers` access. gsutil iam ch -d allUsers gs://BUCKET_NAME gsutil iam ch -d allAuthenticatedUsers gs://BUCKET_NAME **Prevention:** You can prevent Storage buckets from becoming publicly accessible by setting up the `Domain restricted sharing` organization policy at:[ https://console.cloud.google.com/iam-admin/orgpolicies/iam-allowedPolicyMemberDomains ](https://console.cloud.google.com/iam-admin/orgpolicies/iam-allowedPolicyMemberDomains).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Storage browser` by visiting [https://console.cloud.google.com/storage/browser](https://console.cloud.google.com/storage/browser). 2. Click on each bucket name to go to its `Bucket details` page. 3. Click on the `Permissions` tab. 4. Ensure that `allUsers` and `allAuthenticatedUsers` are not in the `Members` list. **From Google Cloud CLI** 1. List all buckets in a project gsutil ls 2. Check the IAM Policy for each bucket: gsutil iam get gs://BUCKET_NAME No role should contain `allUsers` and/or `allAuthenticatedUsers` as a member. **Using Rest API** 1. List all buckets in a project Get https://www.googleapis.com/storage/v1/b?project= 2. Check the IAM Policy for each bucket GET https://www.googleapis.com/storage/v1/b//iam No role should contain `allUsers` and/or `allAuthenticatedUsers` as a member.", + "AdditionalInformation": "To implement Access restrictions on buckets, configuring Bucket IAM is preferred way than configuring Bucket ACL. On GCP console, Edit Permissions for bucket exposes IAM configurations only. Bucket ACLs are configured automatically as per need in order to implement/support User enforced Bucket IAM policy. In-case administrator changes bucket ACL using command-line(gsutils)/API bucket IAM also gets updated automatically.", + "References": "https://cloud.google.com/storage/docs/access-control/iam-reference:https://cloud.google.com/storage/docs/access-control/making-data-public:https://cloud.google.com/storage/docs/gsutil/commands/iam", + "DefaultValue": "By Default, Storage buckets are not publicly shared." + } + ] + }, + { + "Id": "5.2", + "Description": "Ensure That Cloud Storage Buckets Have Uniform Bucket-Level Access Enabled", + "Checks": [ + "cloudstorage_bucket_uniform_bucket_level_access" + ], + "Attributes": [ + { + "Section": "5 Storage", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended that uniform bucket-level access is enabled on Cloud Storage buckets.", + "RationaleStatement": "It is recommended to use uniform bucket-level access to unify and simplify how you grant access to your Cloud Storage resources. Cloud Storage offers two systems for granting users permission to access your buckets and objects: Cloud Identity and Access Management (Cloud IAM) and Access Control Lists (ACLs). These systems act in parallel - in order for a user to access a Cloud Storage resource, only one of the systems needs to grant the user permission. Cloud IAM is used throughout Google Cloud and allows you to grant a variety of permissions at the bucket and project levels. ACLs are used only by Cloud Storage and have limited permission options, but they allow you to grant permissions on a per-object basis. In order to support a uniform permissioning system, Cloud Storage has uniform bucket-level access. Using this feature disables ACLs for all Cloud Storage resources: access to Cloud Storage resources then is granted exclusively through Cloud IAM. Enabling uniform bucket-level access guarantees that if a Storage bucket is not publicly accessible, no object in the bucket is publicly accessible either.", + "ImpactStatement": "If you enable uniform bucket-level access, you revoke access from users who gain their access solely through object ACLs. Certain Google Cloud services, such as Stackdriver, Cloud Audit Logs, and Datastore, cannot export to Cloud Storage buckets that have uniform bucket-level access enabled.", + "RemediationProcedure": "**From Google Cloud Console** 1. Open the Cloud Storage browser in the Google Cloud Console by visiting: [https://console.cloud.google.com/storage/browser](https://console.cloud.google.com/storage/browser) 2. In the list of buckets, click on the name of the desired bucket. 3. Select the `Permissions` tab near the top of the page. 4. In the text box that starts with `This bucket uses fine-grained access control...`, click `Edit`. 5. In the pop-up menu that appears, select `Uniform`. 6. Click `Save`. **From Google Cloud CLI** Use the on option in a uniformbucketlevelaccess set command: gsutil uniformbucketlevelaccess set on gs://BUCKET_NAME/ **Prevention** You can set up an Organization Policy to enforce that any new bucket has uniform bucket level access enabled. Learn more at: [https://cloud.google.com/storage/docs/setting-org-policies#uniform-bucket](https://cloud.google.com/storage/docs/setting-org-policies#uniform-bucket)", + "AuditProcedure": "**From Google Cloud Console** 1. Open the Cloud Storage browser in the Google Cloud Console by visiting: [https://console.cloud.google.com/storage/browser](https://console.cloud.google.com/storage/browser) 2. For each bucket, make sure that `Access control` column has the value `Uniform`. **From Google Cloud CLI** 1. List all buckets in a project gsutil ls 2. For each bucket, verify that uniform bucket-level access is enabled. gsutil uniformbucketlevelaccess get gs://BUCKET_NAME/ If uniform bucket-level access is enabled, the response looks like: Uniform bucket-level access setting for gs://BUCKET_NAME/: Enabled: True LockedTime: LOCK_DATE ", + "AdditionalInformation": "Uniform bucket-level access can no longer be disabled if it has been active on a bucket for 90 consecutive days.", + "References": "https://cloud.google.com/storage/docs/uniform-bucket-level-access:https://cloud.google.com/storage/docs/using-uniform-bucket-level-access:https://cloud.google.com/storage/docs/setting-org-policies#uniform-bucket", + "DefaultValue": "By default, Cloud Storage buckets do not have uniform bucket-level access enabled." + } + ] + }, + { + "Id": "6.4", + "Description": "Ensure That the Cloud SQL Database Instance Requires All Incoming Connections To Use SSL", + "Checks": [ + "cloudsql_instance_ssl_connections" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to enforce all incoming connections to SQL database instance to use SSL.", + "RationaleStatement": "SQL database connections if successfully trapped (MITM); can reveal sensitive data like credentials, database queries, query outputs etc. For security, it is recommended to always use SSL encryption when connecting to your instance. This recommendation is applicable for Postgresql, MySql generation 1, MySql generation 2 and SQL Server 2017 instances.", + "ImpactStatement": "After enforcing SSL requirement for connections, existing client will not be able to communicate with Cloud SQL database instance unless they use SSL encrypted connections to communicate to Cloud SQL database instance.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Click on an instance name to see its configuration overview. 3. In the left-side panel, select `Connections`. 3. In the `security` section, select SSL mode as `Allow only SSL connections`. 4. Under `Configure SSL server certificates` click `Create new certificate` and save the setting **From Google Cloud CLI** To enforce SSL encryption for an instance run the command: gcloud sql instances patch INSTANCE_NAME --ssl-mode= ENCRYPTED_ONLY Note: `RESTART` is required for type MySQL Generation 1 Instances (`backendType: FIRST_GEN`) to get this configuration in effect.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Click on an instance name to see its configuration overview. 3. In the left-side panel, select `Connections`. 3. In the `Security` section, ensure that `Allow only SSL connections` option is selected. **From Google Cloud CLI** 1. Get the detailed configuration for every SQL database instance using the following command: gcloud sql instances list --format=json Ensure that section `settings: ipConfiguration` has the parameter `sslMode` set to `ENCRYPTED_ONLY `.", + "AdditionalInformation": "By default `Settings: ipConfiguration` has no `authorizedNetworks` set/configured. In that case even if by default `sslMode` is not set, which is equivalent to `sslMode:ALLOW_UNENCRYPTED_AND_ENCRYPTED ` there is no risk as instance cannot be accessed outside of the network unless `authorizedNetworks` are configured. However, If default for `sslMode` is not updated to `ENCRYPTED_ONLY ` any `authorizedNetworks` created later on will not enforce SSL only connection.", + "References": "https://cloud.google.com/sql/docs/postgres/configure-ssl-instance/", + "DefaultValue": "By default parameter `settings: ipConfiguration: sslMode` is not set which is equivalent to `sslMode:ALLOW_UNENCRYPTED_AND_ENCRYPTED`." + } + ] + }, + { + "Id": "6.5", + "Description": "Ensure That Cloud SQL Database Instances Do Not Implicitly Whitelist All Public IP Addresses", + "Checks": [ + "cloudsql_instance_public_access" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Database Server should accept connections only from trusted Network(s)/IP(s) and restrict access from public IP addresses.", + "RationaleStatement": "To minimize attack surface on a Database server instance, only trusted/known and required IP(s) should be white-listed to connect to it. An authorized network should not have IPs/networks configured to `0.0.0.0/0` which will allow access to the instance from anywhere in the world. Note that authorized networks apply only to instances with public IPs.", + "ImpactStatement": "The Cloud SQL database instance would not be available to public IP addresses.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Click the instance name to open its `Instance details` page. 3. Under the `Configuration` section click `Edit configurations` 4. Under `Configuration options` expand the `Connectivity` section. 5. Click the `delete` icon for the authorized network `0.0.0.0/0`. 6. Click `Save` to update the instance. **From Google Cloud CLI** Update the authorized network list by dropping off any addresses. gcloud sql instances patch --authorized-networks=IP_ADDR1,IP_ADDR2... **Prevention:** To prevent new SQL instances from being configured to accept incoming connections from any IP addresses, set up a `Restrict Authorized Networks on Cloud SQL instances` Organization Policy at: [https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictAuthorizedNetworks](https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictAuthorizedNetworks).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Click the instance name to open its `Instance details` page. 3. Under the `Configuration` section click `Edit configurations` 4. Under `Configuration options` expand the `Connectivity` section. 5. Ensure that no authorized network is configured to allow `0.0.0.0/0`. **From Google Cloud CLI** 1. Get detailed configuration for every Cloud SQL database instance. gcloud sql instances list --format=json Ensure that the section `settings: ipConfiguration : authorizedNetworks` does not have any parameter `value` containing `0.0.0.0/0`.", + "AdditionalInformation": "There is no IPv6 configuration found for Google cloud SQL server services.", + "References": "https://cloud.google.com/sql/docs/mysql/configure-ip:https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictAuthorizedNetworks:https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints:https://cloud.google.com/sql/docs/mysql/connection-org-policy", + "DefaultValue": "By default, authorized networks are not configured. Remote connection to Cloud SQL database instance is not possible unless authorized networks are configured." + } + ] + }, + { + "Id": "6.6", + "Description": "Ensure That Cloud SQL Database Instances Do Not Have Public IPs", + "Checks": [ + "cloudsql_instance_public_access" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "It is recommended to configure Second Generation Sql instance to use private IPs instead of public IPs.", + "RationaleStatement": "To lower the organization's attack surface, Cloud SQL databases should not have public IPs. Private IPs provide improved network security and lower latency for your application.", + "ImpactStatement": "Removing the public IP address on SQL instances may break some applications that relied on it for database connectivity.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console: [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances) 2. Click the instance name to open its Instance details page. 3. Select the `Connections` tab. 4. Deselect the `Public IP` checkbox. 5. Click `Save` to update the instance. **From Google Cloud CLI** 1. For every instance remove its public IP and assign a private IP instead: gcloud sql instances patch --network= --no-assign-ip 2. Confirm the changes using the following command:: gcloud sql instances describe **Prevention:** To prevent new SQL instances from getting configured with public IP addresses, set up a `Restrict Public IP access on Cloud SQL instances` Organization policy at: [https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictPublicIp](https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictPublicIp).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console: [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances) 2. Ensure that every instance has a private IP address and no public IP address configured. **From Google Cloud CLI** 1. List all Cloud SQL database instances using the following command: gcloud sql instances list 2. For every instance of type `instanceType: CLOUD_SQL_INSTANCE` with `backendType: SECOND_GEN`, get detailed configuration. Ignore instances of type `READ_REPLICA_INSTANCE` because these instances inherit their settings from the primary instance. Also, note that first generation instances cannot be configured to have a private IP address. gcloud sql instances describe 3. Ensure that the setting `ipAddresses` has an IP address configured of `type: PRIVATE` and has no IP address of `type: PRIMARY`. `PRIMARY` IP addresses are public addresses. An instance can have both a private and public address at the same time. Note also that you cannot use private IP with First Generation instances.", + "AdditionalInformation": "Replicas inherit their private IP status from their primary instance. You cannot configure a private IP directly on a replica.", + "References": "https://cloud.google.com/sql/docs/mysql/configure-private-ip:https://cloud.google.com/sql/docs/mysql/private-ip:https://cloud.google.com/resource-manager/docs/organization-policy/org-policy-constraints:https://console.cloud.google.com/iam-admin/orgpolicies/sql-restrictPublicIp", + "DefaultValue": "By default, Cloud Sql instances have a public IP." + } + ] + }, + { + "Id": "6.7", + "Description": "Ensure That Cloud SQL Database Instances Are Configured With Automated Backups", + "Checks": [ + "cloudsql_instance_automated_backups" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to have all SQL database instances set to enable automated backups.", + "RationaleStatement": "Backups provide a way to restore a Cloud SQL instance to recover lost data or recover from a problem with that instance. Automated backups need to be set for any instance that contains data that should be protected from loss or damage. This recommendation is applicable for SQL Server, PostgreSql, MySql generation 1 and MySql generation 2 instances.", + "ImpactStatement": "Automated Backups will increase required size of storage and costs associated with it.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance where the backups need to be configured. 3. Click `Edit`. 4. In the `Backups` section, check `Enable automated backups', and choose a backup window. 5. Click `Save`. **From Google Cloud CLI** 1. List all Cloud SQL database instances using the following command: gcloud sql instances list --format=json | jq '. | map(select(.instanceType != READ_REPLICA_INSTANCE)) | .[].name' NOTE: gcloud command has been added with the filter to exclude read-replicas instances, as GCP do not provide Automated Backups for read-replica instances. 2. Enable `Automated backups` for every Cloud SQL database instance using the below command: gcloud sql instances patch --backup-start-time <[HH:MM]> The `backup-start-time` parameter is specified in 24-hour time, in the UTC±00 time zone, and specifies the start of a 4-hour backup window. Backups can start any time during the backup window.", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Click the instance name to open its instance details page. 3. Go to the `Backups` menu. 4. Ensure that `Automated backups` is set to `Enabled` and `Backup time` is mentioned. **From Google Cloud CLI** 1. List all Cloud SQL database instances using the following command: gcloud sql instances list --format=json | jq '. | map(select(.instanceType != READ_REPLICA_INSTANCE)) | .[].name' NOTE: gcloud command has been added with the filter to exclude read-replicas instances, as GCP do not provide Automated Backups for read-replica instances. 2. Ensure that the below command returns `True` for every Cloud SQL database instance. gcloud sql instances describe --format=value('Enabled':settings.backupConfiguration.enabled) ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/sql/docs/mysql/backup-recovery/backups:https://cloud.google.com/sql/docs/postgres/backup-recovery/backups:https://cloud.google.com/sql/docs/sqlserver/backup-recovery/backups:https://cloud.google.com/sql/docs/mysql/backup-recovery/backing-up:https://cloud.google.com/sql/docs/postgres/backup-recovery/backing-up:https://cloud.google.com/sql/docs/sqlserver/backup-recovery/backing-up", + "DefaultValue": "By default, automated backups are not configured for Cloud SQL instances." + } + ] + }, + { + "Id": "6.1.1", + "Description": "Ensure That a MySQL Instance Does Not Allow Anyone To Connect With Administrative Privileges", + "Checks": [], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.1 MySQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Manual", + "Description": "It is recommended to set a password for the administrative user (`root` by default) to prevent unauthorized access to the SQL database instances. This recommendation is applicable only for MySQL Instances. PostgreSQL does not offer any setting for No Password from the cloud console.", + "RationaleStatement": "At the time of MySQL Instance creation, not providing an administrative password allows anyone to connect to the SQL database instance with administrative privileges. The root password should be set to ensure only authorized users have these privileges.", + "ImpactStatement": "Connection strings for administrative clients need to be reconfigured to use a password.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Platform Console using `https://console.cloud.google.com/sql/` 2. Select the instance to open its Overview page. 3. Select `Access Control > Users`. 4. Click the `More actions icon` for the user to be updated. 5. Select `Change password`, specify a `New password`, and click `OK`. **From Google Cloud CLI** 1. Set a password to a MySql instance: gcloud sql users set-password root --host= --instance= --prompt-for-password 2. A prompt will appear, requiring the user to enter a password: Instance Password: 3. With a successful password configured, the following message should be seen: Updating Cloud SQL user...done. ", + "AuditProcedure": "**From Google Cloud CLI** 1. List All SQL database instances of type MySQL: gcloud sql instances list --filter='DATABASE_VERSION:MYSQL*' --project --format=(NAME,PRIMARY_ADDRESS) 2. For every MySQL instance try to connect using the `PRIMARY_ADDRESS`, if available: mysql -u root -h The command should return either an error message or a password prompt. Sample Error message: ERROR 1045 (28000): Access denied for user 'root'@'' (using password: NO) If a command produces the `mysql>` prompt, the MySQL instance allows anyone to connect with administrative privileges without needing a password. **Note:** The `No Password` setting is exposed only at the time of MySQL instance creation. Once the instance is created, the Google Cloud Platform Console does not expose the set to confirm whether a password for an administrative user is set to a MySQL instance.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/sql/docs/mysql/create-manage-users:https://cloud.google.com/sql/docs/mysql/create-instance", + "DefaultValue": "From the Google Cloud Platform Console, the `Create Instance` workflow enforces the rule to enter the root password unless the option `No Password` is selected explicitly." + } + ] + }, + { + "Id": "6.1.2", + "Description": "Ensure ‘Skip_show_database’ Database Flag for Cloud SQL MySQL Instance Is Set to ‘On’", + "Checks": [ + "cloudsql_instance_mysql_skip_show_database_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.1 MySQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set `skip_show_database` database flag for Cloud SQL Mysql instance to `on`", + "RationaleStatement": "`skip_show_database` database flag prevents people from using the SHOW DATABASES statement if they do not have the SHOW DATABASES privilege. This can improve security if you have concerns about users being able to see databases belonging to other users. Its effect depends on the SHOW DATABASES privilege: If the variable value is ON, the SHOW DATABASES statement is permitted only to users who have the SHOW DATABASES privilege, and the statement displays all database names. If the value is OFF, SHOW DATABASES is permitted to all users, but displays the names of only those databases for which the user has the SHOW DATABASES or other privilege. This recommendation is applicable to Mysql database instances.", + "ImpactStatement": "", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the Mysql instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `skip_show_database` from the drop-down menu, and set its value to `on`. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. List all Cloud SQL database Instances gcloud sql instances list 2. Configure the `skip_show_database` database flag for every Cloud SQL Mysql database instance using the below command. gcloud sql instances patch --database-flags skip_show_database=on **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `skip_show_database` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. List all Cloud SQL database Instances gcloud sql instances list 2. Ensure the below command returns `on` for every Cloud SQL Mysql database instance gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==skip_show_database)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/mysql/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag restarts the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/mysql/flags:https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_skip_show_database", + "DefaultValue": "" + } + ] + }, + { + "Id": "6.1.3", + "Description": "Ensure That the ‘Local_infile’ Database Flag for a Cloud SQL MySQL Instance Is Set to ‘Off’", + "Checks": [ + "cloudsql_instance_mysql_local_infile_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.1 MySQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set the `local_infile` database flag for a Cloud SQL MySQL instance to `off`.", + "RationaleStatement": "The `local_infile` flag controls the server-side LOCAL capability for LOAD DATA statements. Depending on the `local_infile` setting, the server refuses or permits local data loading by clients that have LOCAL enabled on the client side. To explicitly cause the server to refuse LOAD DATA LOCAL statements (regardless of how client programs and libraries are configured at build time or runtime), start mysqld with local_infile disabled. local_infile can also be set at runtime. Due to security issues associated with the `local_infile` flag, it is recommended to disable it. This recommendation is applicable to MySQL database instances.", + "ImpactStatement": "Disabling `local_infile` makes the server refuse local data loading by clients that have LOCAL enabled on the client side.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the MySQL instance where the database flag needs to be enabled. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `local_infile` from the drop-down menu, and set its value to `off`. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. List all Cloud SQL database instances using the following command: gcloud sql instances list 2. Configure the `local_infile` database flag for every Cloud SQL Mysql database instance using the below command: gcloud sql instances patch --database-flags local_infile=off **Note**: This command will overwrite all database flags that were previously set. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `local_infile` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. List all Cloud SQL database instances: gcloud sql instances list 2. Ensure the below command returns `off` for every Cloud SQL MySQL database instance. gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==local_infile)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require the instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/mysql/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines.", + "References": "https://cloud.google.com/sql/docs/mysql/flags:https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html#sysvar_local_infile:https://dev.mysql.com/doc/refman/5.7/en/load-data-local.html", + "DefaultValue": "By default `local_infile` is `on`." + } + ] + }, + { + "Id": "6.2.1", + "Description": "Ensure ‘Log_error_verbosity’ Database Flag for Cloud SQL PostgreSQL Instance Is Set to ‘DEFAULT’ or Stricter", + "Checks": [ + "cloudsql_instance_postgres_log_error_verbosity_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "The `log_error_verbosity` flag controls the verbosity/details of messages logged. Valid values are: - `TERSE` - `DEFAULT` - `VERBOSE` `TERSE` excludes the logging of `DETAIL`, `HINT`, `QUERY`, and `CONTEXT` error information. `VERBOSE` output includes the `SQLSTATE` error code, source code file name, function name, and line number that generated the error. Ensure an appropriate value is set to 'DEFAULT' or stricter.", + "RationaleStatement": "Auditing helps in troubleshooting operational problems and also permits forensic analysis. If `log_error_verbosity` is not set to the correct value, too many details or too few details may be logged. This flag should be configured with a value of 'DEFAULT' or stricter. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting https://console.cloud.google.com/sql/instances. 2. Select the PostgreSQL instance for which you want to enable the database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `log_error_verbosity` from the drop-down menu and set appropriate value. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the log_error_verbosity database flag for every Cloud SQL PosgreSQL database instance using the below command. gcloud sql instances patch INSTANCE_NAME --database-flags log_error_verbosity= **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Go to `Configuration` card 4. Under `Database flags`, check the value of `log_error_verbosity` flag is set to 'DEFAULT' or stricter. **From Google Cloud CLI** 1. Use the below command for every Cloud SQL PostgreSQL database instance to verify the value of `log_error_verbosity` gcloud sql instances describe [INSTANCE_NAME] --format=json | jq '.settings.databaseFlags[] | select(.name==log_error_verbosity)|.value' In the output, database flags are listed under the settings as the collection databaseFlags.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-ERROR-VERBOSITY", + "DefaultValue": "By default `log_error_verbosity` is `DEFAULT`." + } + ] + }, + { + "Id": "6.2.2", + "Description": "Ensure That the ‘Log_connections’ Database Flag for Cloud SQL PostgreSQL Instance Is Set to ‘On’", + "Checks": [ + "cloudsql_instance_postgres_log_connections_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enabling the `log_connections` setting causes each attempted connection to the server to be logged, along with successful completion of client authentication. This parameter cannot be changed after the session starts.", + "RationaleStatement": "PostgreSQL does not log attempted connections by default. Enabling the `log_connections` setting will create log entries for each attempted connection as well as successful completion of client authentication which can be useful in troubleshooting issues and to determine any unusual connection attempts to the server. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting https://console.cloud.google.com/sql/instances. 2. Select the PostgreSQL instance for which you want to enable the database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `log_connections` from the drop-down menu and set the value as `on`. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `log_connections` database flag for every Cloud SQL PosgreSQL database instance using the below command. gcloud sql instances patch --database-flags log_connections=on **Note**: This command will overwrite all previously set database flags. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page. 3. Go to the `Configuration` card. 4. Under `Database flags`, check the value of `log_connections` flag to determine if it is configured as expected. **From Google Cloud CLI** 1. Ensure the below command returns `on` for every Cloud SQL PostgreSQL database instance: gcloud sql instances describe [INSTANCE_NAME] --format=json | jq '.settings.databaseFlags[] | select(.name==log_connections)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see the Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-CONNECTIONS", + "DefaultValue": "By default `log_connections` is `off`." + } + ] + }, + { + "Id": "6.2.3", + "Description": "Ensure That the ‘Log_disconnections’ Database Flag for Cloud SQL PostgreSQL Instance Is Set to ‘On’", + "Checks": [ + "cloudsql_instance_postgres_log_disconnections_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Enabling the `log_disconnections` setting logs the end of each session, including the session duration.", + "RationaleStatement": "PostgreSQL does not log session details such as duration and session end by default. Enabling the `log_disconnections` setting will create log entries at the end of each session which can be useful in troubleshooting issues and determine any unusual activity across a time period. The `log_disconnections` and `log_connections` work hand in hand and generally, the pair would be enabled/disabled together. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the PostgreSQL instance where the database flag needs to be enabled. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `log_disconnections` from the drop-down menu and set the value as `on`. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `log_disconnections` database flag for every Cloud SQL PosgreSQL database instance using the below command: gcloud sql instances patch --database-flags log_disconnections=on **Note**: This command will overwrite all previously set database flags. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Go to the `Configuration` card. 4. Under `Database flags`, check the value of `log_disconnections` flag is configured as expected. **From Google Cloud CLI** 1. Ensure the below command returns `on` for every Cloud SQL PostgreSQL database instance: gcloud sql instances list --format=json | jq '.[].settings.databaseFlags[] | select(.name==log_disconnections)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-DISCONNECTIONS", + "DefaultValue": "By default `log_disconnections` is off." + } + ] + }, + { + "Id": "6.2.4", + "Description": "Ensure ‘Log_statement’ Database Flag for Cloud SQL PostgreSQL Instance Is Set Appropriately", + "Checks": [ + "cloudsql_instance_postgres_log_statement_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "The value of `log_statement` flag determined the SQL statements that are logged. Valid values are: - `none` - `ddl` - `mod` - `all` The value `ddl` logs all data definition statements. The value `mod` logs all ddl statements, plus data-modifying statements. The statements are logged after a basic parsing is done and statement type is determined, thus this does not logs statements with errors. When using extended query protocol, logging occurs after an Execute message is received and values of the Bind parameters are included. A value of 'ddl' is recommended unless otherwise directed by your organization's logging policy.", + "RationaleStatement": "Auditing helps in forensic analysis. If `log_statement` is not set to the correct value, too many statements may be logged leading to issues in finding the relevant information from the logs, or too few statements may be logged with relevant information missing from the logs. Setting log_statement to align with your organization's security and logging policies facilitates later auditing and review of database activities. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the PostgreSQL instance for which you want to enable the database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `log_statement` from the drop-down menu and set appropriate value. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `log_statement` database flag for every Cloud SQL PosgreSQL database instance using the below command. gcloud sql instances patch --database-flags log_statement= **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Go to `Configuration` card 4. Under `Database flags`, check the value of `log_statement` flag is set to appropriately. **From Google Cloud CLI** 1. Use the below command for every Cloud SQL PostgreSQL database instance to verify the value of `log_statement` gcloud sql instances list --format=json | jq '.[].settings.databaseFlags[] | select(.name==log_statement)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-STATEMENT", + "DefaultValue": "`none`" + } + ] + }, + { + "Id": "6.2.5", + "Description": "Ensure that the ‘Log_min_messages’ Flag for a Cloud SQL PostgreSQL Instance is set at minimum to 'Warning'", + "Checks": [ + "cloudsql_instance_postgres_log_min_messages_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The `log_min_messages` flag defines the minimum message severity level that is considered as an error statement. Messages for error statements are logged with the SQL statement. Valid values include (from lowest to highest severity) `DEBUG5`, `DEBUG4`, `DEBUG3`, `DEBUG2`, `DEBUG1`, `INFO`, `NOTICE`, `WARNING`, `ERROR`, `LOG`, `FATAL`, and `PANIC`. Each severity level includes the subsequent levels mentioned above. ERROR is considered the best practice setting. Changes should only be made in accordance with the organization's logging policy.", + "RationaleStatement": "Auditing helps in troubleshooting operational problems and also permits forensic analysis. If `log_min_messages` is not set to the correct value, messages may not be classified as error messages appropriately. Setting the threshold to 'Warning' will log messages for the most needed error messages. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Setting the threshold too low will might result in increased log storage size and length, making it difficult to find actual errors. Higher severity levels may cause errors needed to troubleshoot to not be logged. An organization will need to decide their own threshold for logging `log_min_messages` flag. **Note**: To effectively turn off logging failing statements, set this parameter to PANIC.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances) 2. Select the PostgreSQL instance for which you want to enable the database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add a Database Flag`, choose the flag `log_min_messages` from the drop-down menu and set appropriate value. 6. Click `Save` to save the changes. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `log_min_messages` database flag for every Cloud SQL PosgreSQL database instance using the below command. gcloud sql instances patch --database-flags log_min_messages= **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page. 3. Go to the `Configuration` card. 4. Under `Database flags`, check the value of `log_min_messages` flag is set to `warning` or higher (WARNING|ERROR|LOG|FATAL|PANIC). **From Google Cloud CLI** 1. Use the below command for every Cloud SQL PostgreSQL database instance to verify that the value of `log_min_messages` is set to `warning` or higher . gcloud sql instances describe [INSTANCE_NAME] --format=json | jq '.settings.databaseFlags[] | select(.name==log_min_messages)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: Some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-MIN-MESSAGES", + "DefaultValue": "By default `log_min_messages` is `ERROR`." + } + ] + }, + { + "Id": "6.2.6", + "Description": "Ensure ‘Log_min_error_statement’ Database Flag for Cloud SQL PostgreSQL Instance Is Set to ‘Error’ or Stricter", + "Checks": [ + "cloudsql_instance_postgres_log_min_error_statement_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The `log_min_error_statement` flag defines the minimum message severity level that are considered as an error statement. Messages for error statements are logged with the SQL statement. Valid values include (from lowest to highest severity) `DEBUG5`, `DEBUG4`, `DEBUG3`, `DEBUG2`, `DEBUG1`, `INFO`, `NOTICE`, `WARNING`, `ERROR`, `LOG`, `FATAL`, and `PANIC`. Each severity level includes the subsequent levels mentioned above. Ensure a value of `ERROR` or stricter is set.", + "RationaleStatement": "Auditing helps in troubleshooting operational problems and also permits forensic analysis. If `log_min_error_statement` is not set to the correct value, messages may not be classified as error messages appropriately. Considering general log messages as error messages would make is difficult to find actual errors and considering only stricter severity levels as error messages may skip actual errors to log their SQL statements. The `log_min_error_statement` flag should be set to `ERROR` or stricter. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the PostgreSQL instance for which you want to enable the database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `log_min_error_statement` from the drop-down menu and set appropriate value. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `log_min_error_statement` database flag for every Cloud SQL PosgreSQL database instance using the below command. gcloud sql instances patch --database-flags log_min_error_statement= **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Go to `Configuration` card 4. Under `Database flags`, check the value of `log_min_error_statement` flag is configured as to `ERROR` or stricter. **From Google Cloud CLI** 1. Use the below command for every Cloud SQL PostgreSQL database instance to verify the value of `log_min_error_statement` is set to `ERROR` or stricter. gcloud sql instances describe --format=json | jq '.[].settings.databaseFlags[] | select(.name==log_min_error_statement)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-MIN-ERROR-STATEMENT", + "DefaultValue": "By default `log_min_error_statement` is `ERROR`." + } + ] + }, + { + "Id": "6.2.7", + "Description": "Ensure That the ‘Log_min_duration_statement’ Database Flag for Cloud SQL PostgreSQL Instance Is Set to '-1' (Disabled)", + "Checks": [ + "cloudsql_instance_postgres_log_min_duration_statement_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The `log_min_duration_statement` flag defines the minimum amount of execution time of a statement in milliseconds where the total duration of the statement is logged. Ensure that `log_min_duration_statement` is disabled, i.e., a value of `-1` is set.", + "RationaleStatement": "Logging SQL statements may include sensitive information that should not be recorded in logs. This recommendation is applicable to PostgreSQL database instances.", + "ImpactStatement": "Turning on logging will increase the required storage over time. Mismanaged logs may cause your storage costs to increase. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the PostgreSQL instance where the database flag needs to be enabled. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `log_min_duration_statement` from the drop-down menu and set a value of `-1`. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. List all Cloud SQL database instances using the following command: gcloud sql instances list 2. Configure the `log_min_duration_statement` flag for every Cloud SQL PosgreSQL database instance using the below command: gcloud sql instances patch --database-flags log_min_duration_statement=-1 **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page. 3. Go to the `Configuration` card. 4. Under `Database flags`, check that the value of `log_min_duration_statement` flag is set to `-1`. **From Google Cloud CLI** 1. Use the below command for every Cloud SQL PostgreSQL database instance to verify the value of `log_min_duration_statement` is set to `-1`. gcloud sql instances describe --format=json| jq '.settings.databaseFlags[] | select(.name==log_min_duration_statement)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. **Note**: Some database flag settings can affect instance availability or stability and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not require restarting the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags:https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-MIN-DURATION-STATEMENT", + "DefaultValue": "By default `log_min_duration_statement` is `-1`." + } + ] + }, + { + "Id": "6.2.8", + "Description": "Ensure That 'cloudsql.enable_pgaudit' Database Flag for each Cloud Sql Postgresql Instance Is Set to 'on' For Centralized Logging", + "Checks": [ + "cloudsql_instance_postgres_enable_pgaudit_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.2 PostgreSQL Database", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "Ensure `cloudsql.enable_pgaudit` database flag for Cloud SQL PostgreSQL instance is set to `on` to allow for centralized logging.", + "RationaleStatement": "As numerous other recommendations in this section consist of turning on flags for logging purposes, your organization will need a way to manage these logs. You may have a solution already in place. If you do not, consider installing and enabling the open source pgaudit extension within PostgreSQL and enabling its corresponding flag of `cloudsql.enable_pgaudit`. This flag and installing the extension enables database auditing in PostgreSQL through the open-source pgAudit extension. This extension provides detailed session and object logging to comply with government, financial, & ISO standards and provides auditing capabilities to mitigate threats by monitoring security events on the instance. Enabling the flag and settings later in this recommendation will send these logs to Google Logs Explorer so that you can access them in a central location. to This recommendation is applicable only to PostgreSQL database instances.", + "ImpactStatement": "Enabling the pgAudit extension can lead to increased data storage requirements and to ensure durability of pgAudit log records in the event of unexpected storage issues, it is recommended to enable the `Enable automatic storage increases` setting on the instance. Enabling flags via the command line will also overwrite all existing flags, so you should apply all needed flags in the CLI command. Also flags may require a restart of the server to be implemented or will break existing functionality so update your servers at a time of low usage.", + "RemediationProcedure": "**Initialize the pgAudit flag** **From Google Cloud Console** 1. Go to [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Overview` page. 3. Click `Edit`. 4. Scroll down and expand `Flags`. 5. To set a flag that has not been set on the instance before, click `Add item`. 6. Enter `cloudsql.enable_pgaudit` for the flag name and set the flag to `on`. 7. Click `Done`. 8. Click `Save` to update the configuration. 9. Confirm your changes under `Flags` on the `Overview` page. **From Google Cloud CLI** Run the below command by providing `` to enable `cloudsql.enable_pgaudit` flag. gcloud sql instances patch --database-flags cloudsql.enable_pgaudit=on Note: `RESTART` is required to get this configuration in effect. **Creating the extension** 1. Connect to the the server running PostgreSQL or through a SQL client of your choice. 2. Run the following command as a superuser. CREATE EXTENSION pgaudit; **Updating the previously created pgaudit.log flag for your Logging Needs** **From Console:** Note: there are multiple options here. This command will enable logging for all databases on a server. Please see the customizing database audit logging reference for more flag options. 1. Go to [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Overview` page. 3. Click `Edit`. 4. Scroll down and expand `Flags`. 5. To set a flag that has not been set on the instance before, click `Add item`. 6. Enter `pgaudit.log=all` for the flag name and set the flag to `on`. 7. Click `Done`. 8. Click `Save` to update the configuration. 9. Confirm your changes under `Flags` on the `Overview` page. **From Command Line:** Run the command gcloud sql instances patch --database-flags cloudsql.enable_pgaudit=on,pgaudit.log=all **Determine if logs are being sent to Logs Explorer** 1. From the Google Console home page, open the hamburger menu in the top left. 2. In the menu that pops open, scroll down to Logs Explorer under Operations. 3. In the query box, paste the following and search resource.type=cloudsql_database logName=projects//logs/cloudaudit.googleapis.com%2Fdata_access protoPayload.request.@type=type.googleapis.com/google.cloud.sql.audit.v1.PgAuditEntry If it returns any log sources, they are correctly setup.", + "AuditProcedure": "**Determining if the pgAudit Flag is set to 'on'** **From Google Cloud Console** 1. Go to [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Overview` page. 3. Click `Edit`. 4. Scroll down and expand `Flags`. 5. Ensure that `cloudsql.enable_pgaudit` flag is set to `on`. **From Google Cloud CLI** Run the command by providing ``. Ensure the value of the flag is `on`. gcloud sql instances describe --format=json | jq '.settings|.|.databaseFlags[]|select(.name==cloudsql.enable_pgaudit)|.value' **Determine if the pgAudit extension is installed** 1. Connect to the the server running PostgreSQL or through a SQL client of your choice. 2. Run the following command SELECT * FROM pg_extension; 3. If pgAudit is in this list. If so, it is installed. **Determine if Data Access Audit logs are enabled for your project and have sufficient privileges** 1. From the homepage open the hamburger menu in the top left. 2. Scroll down to `IAM & Admin`and hover over it. 3. In the menu that opens up, select `Audit Logs` 4. In the middle of the page, in the search box next to `filter` search for `Cloud Composer API` 5. Select it, and ensure that both 'Admin Read' and 'Data Read' are checked. **Determine if logs are being sent to Logs Explorer** 1. From the Google Console home page, open the hamburger menu in the top left. 2. In the menu that pops open, scroll down to Logs Explorer under Operations. 3. In the query box, paste the following and search resource.type=cloudsql_database logName=projects//logs/cloudaudit.googleapis.com%2Fdata_access protoPayload.request.@type=type.googleapis.com/google.cloud.sql.audit.v1.PgAuditEntry 4. If it returns any log sources, they are correctly setup.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/postgres/flags - to see if your instance will be restarted when this patch is submitted. Note: Configuring the 'cloudsql.enable_pgaudit' database flag requires restarting the Cloud SQL PostgreSQL instance.", + "References": "https://cloud.google.com/sql/docs/postgres/flags#list-flags-postgres:https://cloud.google.com/sql/docs/postgres/pg-audit#enable-auditing-flag:https://cloud.google.com/sql/docs/postgres/pg-audit#customizing-database-audit-logging:https://cloud.google.com/logging/docs/audit/configure-data-access#config-console-enable", + "DefaultValue": "By default `cloudsql.enable_pgaudit` database flag is set to `off` and the extension is not enabled." + } + ] + }, + { + "Id": "6.3.1", + "Description": "Ensure 'external scripts enabled' Database Flag for Cloud SQL SQL Server Instance Is Set to 'off'", + "Checks": [ + "cloudsql_instance_sqlserver_external_scripts_enabled_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.3 SQL Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set `external scripts enabled` database flag for Cloud SQL SQL Server instance to `off`", + "RationaleStatement": "`external scripts enabled` enable the execution of scripts with certain remote language extensions. This property is OFF by default. When Advanced Analytics Services is installed, setup can optionally set this property to true. As the External Scripts Enabled feature allows scripts external to SQL such as files located in an R library to be executed, which could adversely affect the security of the system, hence this should be disabled. This recommendation is applicable to SQL Server database instances.", + "ImpactStatement": "Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `external scripts enabled` from the drop-down menu, and set its value to `off`. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `external scripts enabled` database flag for every Cloud SQL SQL Server database instance using the below command. gcloud sql instances patch --database-flags external scripts enabled=off **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `external scripts enabled` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. Ensure the below command returns `off` for every Cloud SQL SQL Server database instance gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==external scripts enabled)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag restarts the Cloud SQL instance.", + "References": "https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/external-scripts-enabled-server-configuration-option?view=sql-server-ver15:https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/advanced-analytics/concepts/security?view=sql-server-ver15:https://www.stigviewer.com/stig/ms_sql_server_2016_instance/2018-03-09/finding/V-79347", + "DefaultValue": "By default `external scripts enabled` is `off`" + } + ] + }, + { + "Id": "6.3.2", + "Description": "Ensure 'cross db ownership chaining' Database Flag for Cloud SQL SQL Server Instance Is Set to 'off'", + "Checks": [ + "cloudsql_instance_sqlserver_cross_db_ownership_chaining_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.3 SQL Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set `cross db ownership chaining` database flag for Cloud SQL SQL Server instance to `off`. This flag is deprecated for all SQL Server versions in CGP. Going forward, you can't set its value to on. However, if you have this flag enabled, we strongly recommend that you either remove the flag from your database or set it to off. For cross-database access, use the [Microsoft tutorial for signing stored procedures with a certificate](https://learn.microsoft.com/en-us/sql/relational-databases/tutorial-signing-stored-procedures-with-a-certificate?view=sql-server-ver16).", + "RationaleStatement": "Use the `cross db ownership` for chaining option to configure cross-database ownership chaining for an instance of Microsoft SQL Server. This server option allows you to control cross-database ownership chaining at the database level or to allow cross-database ownership chaining for all databases. Enabling `cross db ownership` is not recommended unless all of the databases hosted by the instance of SQL Server must participate in cross-database ownership chaining and you are aware of the security implications of this setting. This recommendation is applicable to SQL Server database instances.", + "ImpactStatement": "Updating flags may cause the database to restart. This may cause it to unavailable for a short amount of time, so this is best done at a time of low usage. You should also determine if the tables in your databases reference another table without using credentials for that database, as turning off cross database ownership will break this relationship.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `cross db ownership chaining` from the drop-down menu, and set its value to `off`. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `cross db ownership chaining` database flag for every Cloud SQL SQL Server database instance using the below command: gcloud sql instances patch --database-flags cross db ownership chaining=off **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**NOTE:** This flag is deprecated for all SQL Server versions. Going forward, you can't set its value to on. However, if you have this flag enabled it should be removed from your database or set to off. **From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console. 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `cross db ownership chaining` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. Ensure the below command returns `off` for every Cloud SQL SQL Server database instance: gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==cross db ownership chaining)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: Some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not restart the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/cross-db-ownership-chaining-server-configuration-option?view=sql-server-ver15", + "DefaultValue": "This flag is deprecated for all SQL Server versions. Going forward, you can't set its value to on." + } + ] + }, + { + "Id": "6.3.3", + "Description": "Ensure 'user Connections' Database Flag for Cloud SQL SQL Server Instance Is Set to a Non-limiting Value", + "Checks": [ + "cloudsql_instance_sqlserver_user_connections_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.3 SQL Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to check the `user connections` for a Cloud SQL SQL Server instance to ensure that it is not artificially limiting connections.", + "RationaleStatement": "The `user connections` option specifies the maximum number of simultaneous user connections that are allowed on an instance of SQL Server. The actual number of user connections allowed also depends on the version of SQL Server that you are using, and also the limits of your application or applications and hardware. SQL Server allows a maximum of 32,767 user connections. Because user connections is by default a self-configuring value, with SQL Server adjusting the maximum number of user connections automatically as needed, up to the maximum value allowable. For example, if only 10 users are logged in, 10 user connection objects are allocated. In most cases, you do not have to change the value for this option. The default is 0, which means that the maximum (32,767) user connections are allowed. However if there is a number defined here that limits connections, SQL Server will not allow anymore above this limit. If the connections are at the limit, any new requests will be dropped, potentially causing lost data or outages for those using the database.", + "ImpactStatement": "Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `user connections` from the drop-down menu, and set its value to your organization recommended value. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `user connections` database flag for every Cloud SQL SQL Server database instance using the below command. gcloud sql instances patch --database-flags user connections=[0-32,767] **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `user connections` listed under the `Database flags` section is 0. **From Google Cloud CLI** 1. Ensure the below command returns a value of 0, for every Cloud SQL SQL Server database instance. gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==user connections)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag restarts the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/configure-the-user-connections-server-configuration-option?view=sql-server-ver15:https://www.stigviewer.com/stig/ms_sql_server_2016_instance/2018-03-09/finding/V-79119", + "DefaultValue": "By default `user connections` is set to '0' which does not limit the number of connections, giving the server free reign to facilitate a max of 32,767 connections." + } + ] + }, + { + "Id": "6.3.4", + "Description": "Ensure 'user options' Database Flag for Cloud SQL SQL Server Instance Is Not Configured", + "Checks": [ + "cloudsql_instance_sqlserver_user_options_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.3 SQL Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "The `user options` option specifies global defaults for all users. A list of default query processing options is established for the duration of a user's work session. The user options option allows you to change the default values of the SET options (if the server's default settings are not appropriate). A user can override these defaults by using the SET statement. You can configure user options dynamically for new logins. After you change the setting of user options, new login sessions use the new setting; current login sessions are not affected. This recommendation is applicable to SQL Server database instances.", + "RationaleStatement": "It is recommended that, `user options` database flag for Cloud SQL SQL Server instance should not be configured. A user can override these defaults set with `user options` by using the SET statement. Some of these features/options could adversely affect the security of the system if enabled.", + "ImpactStatement": "Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. Click the X next `user options` flag shown 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. List all Cloud SQL database Instances gcloud sql instances list 2. Clear the `user options` database flag for every Cloud SQL SQL Server database instance using either of the below commands. Clearing all flags to their default value gcloud sql instances patch --clear-database-flags OR To clear only `user options` database flag, configure the database flag by overriding the `user options`. Exclude `user options` flag and its value, and keep all other flags you want to configure. gcloud sql instances patch --database-flags [FLAG1=VALUE1,FLAG2=VALUE2] **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `user options` that has been set is not listed under the `Database flags` section. **From Google Cloud CLI** 1. Ensure the below command returns empty result for every Cloud SQL SQL Server database instance gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==user options)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not restart the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/configure-the-user-options-server-configuration-option?view=sql-server-ver15:https://www.stigviewer.com/stig/ms_sql_server_2016_instance/2018-03-09/finding/V-79335", + "DefaultValue": "By default 'user options' is not configured." + } + ] + }, + { + "Id": "6.3.5", + "Description": "Ensure 'remote access' Database Flag for Cloud SQL SQL Server Instance Is Set to 'off'", + "Checks": [ + "cloudsql_instance_sqlserver_remote_access_flag" + ], + "Attributes": [ + { + "Section": "6 Cloud SQL Database Services", + "SubSection": "6.3 SQL Server", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set `remote access` database flag for Cloud SQL SQL Server instance to `off`.", + "RationaleStatement": "The `remote access` option controls the execution of stored procedures from local or remote servers on which instances of SQL Server are running. This default value for this option is 1. This grants permission to run local stored procedures from remote servers or remote stored procedures from the local server. To prevent local stored procedures from being run from a remote server or remote stored procedures from being run on the local server, this must be disabled. The Remote Access option controls the execution of local stored procedures on remote servers or remote stored procedures on local server. 'Remote access' functionality can be abused to launch a Denial-of-Service (DoS) attack on remote servers by off-loading query processing to a target, hence this should be disabled. This recommendation is applicable to SQL Server database instances.", + "ImpactStatement": "Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `remote access` from the drop-down menu, and set its value to `off`. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `remote access` database flag for every Cloud SQL SQL Server database instance using the below command gcloud sql instances patch --database-flags remote access=off **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `remote access` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. Ensure the below command returns `off` for every Cloud SQL SQL Server database instance gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==remote access)|.value' In the output, database flags are listed under the `settings` as the collection `databaseFlags`.", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag restarts the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/configure-the-remote-access-server-configuration-option?view=sql-server-ver15:https://www.stigviewer.com/stig/ms_sql_server_2016_instance/2018-03-09/finding/V-79337", + "DefaultValue": "By default 'remote access' is 'on'." + } + ] + }, + { + "Id": "6.3.6", + "Description": "Ensure '3625 (trace flag)' Database Flag for all Cloud SQL SQL Server Instances Is Set to 'on'", + "Checks": [ + "cloudsql_instance_sqlserver_trace_flag" + ], + "Attributes": [ + { + "Section": "6.3", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended to set `3625 (trace flag)` database flag for Cloud SQL SQL Server instance to `on`.", + "RationaleStatement": "Microsoft SQL Trace Flags are frequently used to diagnose performance issues or to debug stored procedures or complex computer systems, but they may also be recommended by Microsoft Support to address behavior that is negatively impacting a specific workload. All documented trace flags and those recommended by Microsoft Support are fully supported in a production environment when used as directed. `3625(trace log)` Limits the amount of information returned to users who are not members of the sysadmin fixed server role, by masking the parameters of some error messages using '******'. Setting this in a Google Cloud flag for the instance allows for security through obscurity and prevents the disclosure of sensitive information, hence this is recommended to set this flag globally to on to prevent the flag having been left off, or changed by bad actors. This recommendation is applicable to SQL Server database instances.", + "ImpactStatement": "Changing flags on a database may cause it to be restarted. The best time to do this is at a time where there is low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. To set a flag that has not been set on the instance before, click `Add item`, choose the flag `3625` from the drop-down menu, and set its value to `on`. 6. Click `Save` to save your changes. 7. Confirm your changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. Configure the `3625` database flag for every Cloud SQL SQL Server database instance using the below command. gcloud sql instances patch --database-flags 3625=on **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags you want set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Ensure the database flag `3625` that has been set is listed under the `Database flags` section. **From Google Cloud CLI** 1. Ensure the below command returns `on` for every Cloud SQL SQL Server database instance gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==3625)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag restarts the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/t-sql/database-console-commands/dbcc-traceon-trace-flags-transact-sql?view=sql-server-ver15#trace-flags:https://github.com/ktaranov/sqlserver-kit/blob/master/SQL%20Server%20Trace%20Flag.md", + "DefaultValue": "MS SQL Server implementations by default have trace flags, including the '3625' flag, turned off, as they are used for logging purposes." + } + ] + }, + { + "Id": "6.3.7", + "Description": "Ensure 'contained database authentication' Database Flag for Cloud SQL SQL Server Instance Is Set to 'off'", + "Checks": [ + "cloudsql_instance_sqlserver_contained_database_authentication_flag" + ], + "Attributes": [ + { + "Section": "6.3", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "A contained database includes all database settings and metadata required to define the database and has no configuration dependencies on the instance of the Database Engine where the database is installed. Users can connect to the database without authenticating a login at the Database Engine level. Isolating the database from the Database Engine makes it possible to easily move the database to another instance of SQL Server. Contained databases have some unique threats that should be understood and mitigated by SQL Server Database Engine administrators. Most of the threats are related to the USER WITH PASSWORD authentication process, which moves the authentication boundary from the Database Engine level to the database level, hence this is recommended not to enable this flag. This recommendation is applicable to SQL Server database instances.", + "RationaleStatement": "When contained databases are enabled, database users with the ALTER ANY USER permission, such as members of the db_owner and db_accessadmin database roles, can grant access to databases and by doing so, grant access to the instance of SQL Server. This means that control over access to the server is no longer limited to members of the sysadmin and securityadmin fixed server role, and logins with the server level CONTROL SERVER and ALTER ANY LOGIN permission. It is recommended to set `contained database authentication` database flag for Cloud SQL on the SQL Server instance to `off`.", + "ImpactStatement": "When `contained database authentication` is off (0) for the instance, contained databases cannot be created, or attached to the Database Engine. Setting custom flags via command line on certain instances will cause all omitted flags to be reset to defaults. This may cause you to lose custom flags and could result in unforeseen complications or instance restarts. Because of this, it is recommended you apply these flags changes during a period of low usage.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the SQL Server instance for which you want to enable to database flag. 3. Click `Edit`. 4. Scroll down to the `Flags` section. 5. If the flag `contained database authentication` is present and its value is set to 'on', then change it to 'off'. 6. Click `Save`. 7. Confirm the changes under `Flags` on the Overview page. **From Google Cloud CLI** 1. If any Cloud SQL for SQL Server instance has the database flag `contained database authentication` set to 'on', then change it to 'off' using the below command: gcloud sql instances patch --database-flags contained database authentication=off **Note**: This command will overwrite all database flags previously set. To keep those and add new ones, include the values for all flags to be set on the instance; any flag not specifically included is set to its default value. For flags that do not take a value, specify the flag name followed by an equals sign (=).", + "AuditProcedure": "**From Google Cloud Console** 1. Go to the Cloud SQL Instances page in the Google Cloud Console by visiting [https://console.cloud.google.com/sql/instances](https://console.cloud.google.com/sql/instances). 2. Select the instance to open its `Instance Overview` page 3. Under the 'Database flags' section, if the database flag `contained database authentication` is present, then ensure that it is set to 'off'. **From Google Cloud CLI** 1. Ensure the below command returns `off` for any Cloud SQL for SQL Server database instance. gcloud sql instances describe --format=json | jq '.settings.databaseFlags[] | select(.name==contained database authentication)|.value' ", + "AdditionalInformation": "WARNING: This patch modifies database flag values, which may require your instance to be restarted. Check the list of supported flags - https://cloud.google.com/sql/docs/sqlserver/flags - to see if your instance will be restarted when this patch is submitted. **Note**: Some database flag settings can affect instance availability or stability, and remove the instance from the Cloud SQL SLA. For information about these flags, see Operational Guidelines. **Note**: Configuring the above flag does not restart the Cloud SQL instance.", + "References": "https://cloud.google.com/sql/docs/sqlserver/flags:https://docs.microsoft.com/en-us/sql/database-engine/configure-windows/contained-database-authentication-server-configuration-option?view=sql-server-ver15:https://docs.microsoft.com/en-us/sql/relational-databases/databases/security-best-practices-with-contained-databases?view=sql-server-ver15", + "DefaultValue": "" + } + ] + }, + { + "Id": "7.1", + "Description": "Ensure That BigQuery Datasets Are Not Anonymously or Publicly Accessible", + "Checks": [ + "bigquery_dataset_public_access" + ], + "Attributes": [ + { + "Section": "7 BigQuery", + "Profile": "Level 1", + "AssessmentStatus": "Automated", + "Description": "It is recommended that the IAM policy on BigQuery datasets does not allow anonymous and/or public access.", + "RationaleStatement": "Granting permissions to `allUsers` or `allAuthenticatedUsers` allows anyone to access the dataset. Such access might not be desirable if sensitive data is being stored in the dataset. Therefore, ensure that anonymous and/or public access to a dataset is not allowed.", + "ImpactStatement": "The dataset is not publicly accessible. Explicit modification of IAM privileges would be necessary to make them publicly accessible.", + "RemediationProcedure": "**From Google Cloud Console** 1. Go to `BigQuery` by visiting: [https://console.cloud.google.com/bigquery](https://console.cloud.google.com/bigquery). 2. Select the dataset from 'Resources'. 3. Click `SHARING` near the right side of the window and select `Permissions`. 4. Review each attached role. 5. Click the delete icon for each member `allUsers` or `allAuthenticatedUsers`. On the popup click `Remove`. **From Google Cloud CLI** List the name of all datasets. bq ls Retrieve the data set details: bq show --format=prettyjson PROJECT_ID:DATASET_NAME > PATH_TO_FILE In the access section of the JSON file, update the dataset information to remove all roles containing `allUsers` or `allAuthenticatedUsers`. Update the dataset: bq update --source PATH_TO_FILE PROJECT_ID:DATASET_NAME **Prevention:** You can prevent Bigquery dataset from becoming publicly accessible by setting up the `Domain restricted sharing` organization policy at: https://console.cloud.google.com/iam-admin/orgpolicies/iam-allowedPolicyMemberDomains .", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `BigQuery` by visiting: [https://console.cloud.google.com/bigquery](https://console.cloud.google.com/bigquery). 2. Select a dataset from `Resources`. 3. Click `SHARING` near the right side of the window and select `Permissions`. 4. Validate that none of the attached roles contain `allUsers` or `allAuthenticatedUsers`. **From Google Cloud CLI** List the name of all datasets. bq ls Retrieve each dataset details using the following command: bq show PROJECT_ID:DATASET_NAME Ensure that `allUsers` and `allAuthenticatedUsers` have not been granted access to the dataset.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/bigquery/docs/dataset-access-controls", + "DefaultValue": "By default, BigQuery datasets are not publicly accessible." + } + ] + }, + { + "Id": "7.2", + "Description": "Ensure That All BigQuery Tables Are Encrypted With Customer-Managed Encryption Key (CMEK)", + "Checks": [ + "bigquery_table_cmk_encryption" + ], + "Attributes": [ + { + "Section": "7 BigQuery", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "BigQuery by default encrypts the data as rest by employing `Envelope Encryption` using Google managed cryptographic keys. The data is encrypted using the `data encryption keys` and data encryption keys themselves are further encrypted using `key encryption keys`. This is seamless and do not require any additional input from the user. However, if you want to have greater control, Customer-managed encryption keys (CMEK) can be used as encryption key management solution for BigQuery Data Sets. If CMEK is used, the CMEK is used to encrypt the data encryption keys instead of using google-managed encryption keys.", + "RationaleStatement": "BigQuery by default encrypts the data as rest by employing `Envelope Encryption` using Google managed cryptographic keys. This is seamless and does not require any additional input from the user. For greater control over the encryption, customer-managed encryption keys (CMEK) can be used as encryption key management solution for BigQuery tables. The CMEK is used to encrypt the data encryption keys instead of using google-managed encryption keys. BigQuery stores the table and CMEK association and the encryption/decryption is done automatically. Applying the Default Customer-managed keys on BigQuery data sets ensures that all the new tables created in the future will be encrypted using CMEK but existing tables need to be updated to use CMEK individually. Note: Google does not store your keys on its servers and cannot access your protected data unless you provide the key. This also means that if you forget or lose your key, there is no way for Google to recover the key or to recover any data encrypted with the lost key. ", + "ImpactStatement": "Using Customer-managed encryption keys (CMEK) will incur additional labor-hour investment to create, protect, and manage the keys.", + "RemediationProcedure": "**From Google Cloud CLI** Use the following command to copy the data. The source and the destination needs to be same in case copying to the original table. bq cp --destination_kms_key source_dataset.source_table destination_dataset.destination_table ", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Analytics` 2. Go to `BigQuery` 3. Under `SQL Workspace`, select the project 4. Select Data Set, select the table 5. Go to `Details` tab 6. Under `Table info`, verify `Customer-managed key` is present. 7. Repeat for each table in all data sets for all projects. **From Google Cloud CLI** List all dataset names bq ls Use the following command to view the table details. Verify the `kmsKeyName` is present. bq show ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/bigquery/docs/customer-managed-encryption", + "DefaultValue": "Google Managed keys are used as `key encryption keys`." + } + ] + }, + { + "Id": "7.3", + "Description": "Ensure That a Default Customer-Managed Encryption Key (CMEK) Is Specified for All BigQuery Data Sets", + "Checks": [ + "bigquery_dataset_cmk_encryption" + ], + "Attributes": [ + { + "Section": "7 BigQuery", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "BigQuery by default encrypts the data as rest by employing `Envelope Encryption` using Google managed cryptographic keys. The data is encrypted using the `data encryption keys` and data encryption keys themselves are further encrypted using `key encryption keys`. This is seamless and do not require any additional input from the user. However, if you want to have greater control, Customer-managed encryption keys (CMEK) can be used as encryption key management solution for BigQuery Data Sets.", + "RationaleStatement": "BigQuery by default encrypts the data as rest by employing `Envelope Encryption` using Google managed cryptographic keys. This is seamless and does not require any additional input from the user. For greater control over the encryption, customer-managed encryption keys (CMEK) can be used as encryption key management solution for BigQuery Data Sets. Setting a Default Customer-managed encryption key (CMEK) for a data set ensure any tables created in future will use the specified CMEK if none other is provided. Note: Google does not store your keys on its servers and cannot access your protected data unless you provide the key. This also means that if you forget or lose your key, there is no way for Google to recover the key or to recover any data encrypted with the lost key. ", + "ImpactStatement": "Using Customer-managed encryption keys (CMEK) will incur additional labor-hour investment to create, protect, and manage the keys.", + "RemediationProcedure": "**From Google Cloud CLI** The default CMEK for existing data sets can be updated by specifying the default key in the `EncryptionConfiguration.kmsKeyName` field when calling the `datasets.insert` or `datasets.patch` methods", + "AuditProcedure": "**From Google Cloud Console** 1. Go to `Analytics` 2. Go to `BigQuery` 3. Under `Analysis` click on `SQL Workspaces`, select the project 4. Select Data Set 5. Ensure `Customer-managed key` is present under `Dataset info` section. 6. Repeat for each data set in all projects. **From Google Cloud CLI** List all dataset names bq ls Use the following command to view each dataset details. bq show Verify the `kmsKeyName` is present.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/bigquery/docs/customer-managed-encryption", + "DefaultValue": "Google Managed keys are used as `key encryption keys`." + } + ] + }, + { + "Id": "7.4", + "Description": "Ensure all data in BigQuery has been classified", + "Checks": [], + "Attributes": [ + { + "Section": "7 BigQuery", + "Profile": "Level 2", + "AssessmentStatus": "Manual", + "Description": "BigQuery tables can contain sensitive data that for security purposes should be discovered, monitored, classified, and protected. Google Cloud's Sensitive Data Protection tools can automatically provide data classification of all BigQuery data across an organization.", + "RationaleStatement": "Using a cloud service or 3rd party software to continuously monitor and automate the process of data discovery and classification for BigQuery tables is an important part of protecting the data. Sensitive Data Protection is a fully managed data protection and data privacy platform that uses machine learning and pattern matching to discover and classify sensitive data in Google Cloud.", + "ImpactStatement": "There is a cost associated with using Sensitive Data Protection. There is also typically a cost associated with 3rd party tools that perform similar processes and protection.", + "RemediationProcedure": "**Enable profiling:** 1. Go to Cloud DLP by visiting https://console.cloud.google.com/dlp/landing/dataProfiles/configurations 1. Click Create Configuration 1. For projects follow https://cloud.google.com/dlp/docs/profile-project. For organizations or folders follow https://cloud.google.com/dlp/docs/profile-org-folder **Review findings:** - Columns or tables with high data risk have evidence of sensitive information without additional protections. To lower the data risk score, consider doing the following: - For columns containing sensitive data, apply a BigQuery policy tag to restrict access to accounts with specific access rights. - De-identify the raw sensitive data using de-identification techniques like masking and tokenization. **Incorporate findings into your security and governance operations:** - Enable sending findings into your security and posture services. You can publish data profiles to Security Command Center and Chronicle. - Automate remediation or enable alerting of new or changed data risk with Pub/Sub.", + "AuditProcedure": "1. Go to Cloud DLP by visiting https://console.cloud.google.com/dlp/landing/dataProfiles/configurations. 2. Verify there is a discovery scan configuration either for the organization or project.", + "AdditionalInformation": "", + "References": "https://cloud.google.com/dlp/docs/data-profiles:https://cloud.google.com/dlp/docs/analyze-data-profiles:https://cloud.google.com/dlp/docs/data-profiles-remediation:https://cloud.google.com/dlp/docs/send-profiles-to-scc:https://cloud.google.com/dlp/docs/profile-org-folder#chronicle:https://cloud.google.com/dlp/docs/profile-org-folder#publish-pubsub", + "DefaultValue": "" + } + ] + }, + { + "Id": "8.1", + "Description": "Ensure that Dataproc Cluster is encrypted using Customer-Managed Encryption Key", + "Checks": [ + "dataproc_encrypted_with_cmks_disabled" + ], + "Attributes": [ + { + "Section": "8 Dataproc", + "Profile": "Level 2", + "AssessmentStatus": "Automated", + "Description": "When you use Dataproc, cluster and job data is stored on Persistent Disks (PDs) associated with the Compute Engine VMs in your cluster and in a Cloud Storage staging bucket. This PD and bucket data is encrypted using a Google-generated data encryption key (DEK) and key encryption key (KEK). The CMEK feature allows you to create, use, and revoke the key encryption key (KEK). Google still controls the data encryption key (DEK).", + "RationaleStatement": "Cloud services offer the ability to protect data related to those services using encryption keys managed by the customer within Cloud KMS. These encryption keys are called customer-managed encryption keys (CMEK). When you protect data in Google Cloud services with CMEK, the CMEK key is within your control.", + "ImpactStatement": "Using Customer Managed Keys involves additional overhead in maintenance by administrators.", + "RemediationProcedure": "**From Google Cloud Console** 1. Login to the GCP Console and navigate to the Dataproc Cluster page by visiting https://console.cloud.google.com/dataproc/clusters. 1. Select the project from the projects dropdown list. 1. On the `Dataproc Cluster` page, click on the `Create Cluster` to create a new cluster with Customer managed encryption keys. 1. On `Create a cluster` page, perform below steps: - Inside `Set up cluster` section perform below steps: -In the `Name` textbox, provide a name for your cluster. - From `Location` select the location in which you want to deploy a cluster. - Configure other configurations as per your requirements. - Inside `Configure Nodes` and `Customize cluster` section configure the settings as per your requirements. - Inside `Manage security` section, perform below steps: - From `Encryption`, select `Customer-managed key`. - Select a customer-managed key from dropdown list. - Ensure that the selected KMS Key have Cloud KMS CryptoKey Encrypter/Decrypter role assign to Dataproc Cluster service account (serviceAccount:service-@compute-system.iam.gserviceaccount.com). - Click on `Create` to create a cluster. - Once the cluster is created migrate all your workloads from the older cluster to the new cluster and delete the old cluster by performing the below steps: - On the `Clusters` page, select the old cluster and click on `Delete cluster`. - On the `Confirm deletion` window, click on `Confirm` to delete the cluster. - Repeat step above for other Dataproc clusters available in the selected project. - Change the project from the project dropdown list and repeat the remediation procedure for other Dataproc clusters available in other projects. **From Google Cloud CLI** Before creating cluster ensure that the selected KMS Key have Cloud KMS CryptoKey Encrypter/Decrypter role assign to Dataproc Cluster service account (serviceAccount:service-@compute-system.iam.gserviceaccount.com). Run clusters create command to create new cluster with customer-managed key: gcloud dataproc clusters create --region=us-central1 --gce-pd-kms-key= The above command will create a new cluster in the selected region. Once the cluster is created migrate all your workloads from the older cluster to the new cluster and Run clusters delete command to delete cluster: gcloud dataproc clusters delete --region=us-central1 Repeat step no. 1 to create a new Dataproc cluster. Change the project by running the below command and repeat the remediation procedure for other projects: gcloud config set project ", + "AuditProcedure": "**From Google Cloud Console** 1. Login to the GCP Console and navigate to the Dataproc Cluster page by visiting https://console.cloud.google.com/dataproc/clusters. 1. Select the project from the project dropdown list. 1. On the `Dataproc Clusters` page, select the cluster and click on the Name attribute value that you want to examine. 1. On the `details` page, select the `Configurations` tab. 1. On the `Configurations` tab, check the `Encryption type` configuration attribute value. If the value is set to `Google-managed key`, then Dataproc Cluster is not encrypted with Customer managed encryption keys. Repeat step no. 3 - 5 for other Dataproc Clusters available in the selected project. 6. Change the project from the project dropdown list and repeat the audit procedure for other projects. **From Google Cloud CLI** 1. Run clusters list command to list all the Dataproc Clusters available in the region: gcloud dataproc clusters list --region='us-central1' 2. Run clusters describe command to get the key details of the selected cluster: gcloud dataproc clusters describe --region=us-central1 --flatten=config.encryptionConfig.gcePdKmsKeyName 3. If the above command output return null, then the selected cluster is not encrypted with Customer managed encryption keys. 4. Repeat step no. 2 and 3 for other Dataproc Clusters available in the selected region. Change the region by updating --region and repeat step no. 2 for other clusters available in the project. Change the project by running the below command and repeat the audit procedure for other Dataproc clusters available in other projects: gcloud config set project ", + "AdditionalInformation": "", + "References": "https://cloud.google.com/docs/security/encryption/default-encryption", + "DefaultValue": "" + } + ] + } + ] +} diff --git a/prowler/compliance/gcp/nis2_gcp.json b/prowler/compliance/gcp/nis2_gcp.json new file mode 100644 index 0000000000..bac6601434 --- /dev/null +++ b/prowler/compliance/gcp/nis2_gcp.json @@ -0,0 +1,1492 @@ +{ + "Framework": "NIS2", + "Version": "", + "Provider": "GCP", + "Description": "ANNEX to the Commission Implementing Regulation laying down rules for the application of Directive (EU) 2022/2555 as regards technical and methodological requirements of cybersecurity risk-management measures and further specification of the cases in which an incident is considered to be significant with regard to DNS service providers, TLD name registries, cloud computing service providers, data centre service providers, content delivery network providers, managed service providers, managed security service providers, providers of online market places, of online search engines and of social networking services platforms, and trust service providers", + "Requirements": [ + { + "Id": "1.1.1.a", + "Description": "set out the relevant entities approach to managing the security of their network and information systems;", + "Checks": [ + "iam_organization_essential_contacts_configured" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "iam" + } + ] + }, + { + "Id": "1.1.1.c", + "Description": "set out network and information security objectives;", + "Checks": [ + "compute_network_default_in_use", + "compute_network_dns_logging_enabled", + "compute_network_not_legacy", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_firewall_rdp_access_from_the_internet_allowed" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "compute" + } + ] + }, + { + "Id": "1.1.2", + "Description": "The network and information system security policy shall be reviewed and, where appropriate, updated by management bodies at least annually and when significant incidents or significant changes to operations or risks occur. The result of the reviews shall be documented.", + "Checks": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.1 Policy on the security of network and information systems", + "Service": "logging" + } + ] + }, + { + "Id": "1.2.1", + "Description": "As part of their policy on the security of network and information systems referred to in point 1.1., the relevant entities shall lay down responsibilities and authorities for network and information system security and assign them to roles, allocate them according to the relevant entities needs, and communicate them to the management bodies.", + "Checks": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_network_default_in_use", + "compute_network_dns_logging_enabled", + "compute_network_not_legacy" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.2 Roles, responsibilities and authorities", + "Service": "compute" + } + ] + }, + { + "Id": "1.2.4", + "Description": "Depending on the size of the relevant entities, network and information system security shall be covered by dedicated roles or duties carried out in addition to existing roles.", + "Checks": [ + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties" + ], + "Attributes": [ + { + "Section": "1 POLICY ON THE SECURITY OF NETWORK AND INFORMATION SYSTEMS (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "1.2 Roles, responsibilities and authorities", + "Service": "iam" + } + ] + }, + { + "Id": "2.1.2.e", + "Description": "analyse the risks posed to the security of network and information systems, including threat, likelihood, impact, and risk level, taking into account cyber threat intelligence and vulnerabilities;", + "Checks": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_network_default_in_use", + "compute_network_dns_logging_enabled", + "compute_network_not_legacy", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.g", + "Description": "identify and prioritise appropriate risk treatment options and measures;", + "Checks": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_network_default_in_use", + "compute_network_dns_logging_enabled", + "compute_network_not_legacy", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.1.2.h", + "Description": "continuously monitor the implementation of the risk treatment measures;", + "Checks": [ + "compute_loadbalancer_logging_enabled", + "compute_network_dns_logging_enabled", + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", + "logging_sink_created" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.1 Risk management framework", + "Service": "generic" + } + ] + }, + { + "Id": "2.2.1", + "Description": "The relevant entities shall regularly review the compliance with their policies on network and information system security, topic-specific policies, rules, and standards. The management bodies shall be informed of the status of network and information security on the basis of the compliance reviews by means of regular reporting.", + "Checks": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_network_default_in_use", + "compute_network_dns_logging_enabled", + "compute_network_not_legacy", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "gcr_container_scanning_enabled" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.2 Compliance monitoring", + "Service": "generic" + } + ] + }, + { + "Id": "2.3.1", + "Description": "The relevant entities shall review independently their approach to managing network and information system security and its implementation including people, processes and technologies.", + "Checks": [ + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_network_default_in_use", + "compute_network_dns_logging_enabled", + "compute_network_not_legacy", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "Attributes": [ + { + "Section": "2 RISK MANAGEMENT POLICY (ARTICLE 21(2), POINT (A) OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "2.3 Independent review of information and network security", + "Service": "generic" + } + ] + }, + { + "Id": "3.1.2.d", + "Description": "documents to be used in the course of incident detection and response such as incident response manuals, escalation charts, contact lists and templates.", + "Checks": [ + "iam_organization_essential_contacts_configured" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "iam" + } + ] + }, + { + "Id": "3.1.3", + "Description": "The roles, responsibilities and procedures laid down in the policy shall be tested and reviewed and, where appropriate, updated at planned intervals and after significant incidents or significant changes to operations or risks.", + "Checks": [ + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_audit_logs_enabled", + "iam_cloud_asset_inventory_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.1 Incident handling policy", + "Service": "iam" + } + ] + }, + { + "Id": "3.2.1", + "Description": "The relevant entities shall lay down procedures and use tools to monitor and log activities on their network and information systems to detect events that could be considered as incidents and respond accordingly to mitigate the impact.", + "Checks": [ + "cloudsql_instance_postgres_log_connections_flag", + "cloudsql_instance_postgres_log_disconnections_flag", + "cloudsql_instance_postgres_log_error_verbosity_flag", + "cloudsql_instance_postgres_log_min_duration_statement_flag", + "cloudsql_instance_postgres_log_min_error_statement_flag", + "cloudsql_instance_postgres_log_min_messages_flag", + "cloudsql_instance_postgres_log_statement_flag", + "cloudstorage_bucket_log_retention_policy_lock", + "compute_loadbalancer_logging_enabled", + "compute_network_dns_logging_enabled", + "compute_project_os_login_enabled", + "compute_subnet_flow_logs_enabled", + "iam_audit_logs_enabled", + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", + "logging_sink_created" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.a", + "Description": "relevant outbound and inbound network traffic;", + "Checks": [ + "cloudsql_instance_postgres_log_connections_flag", + "compute_loadbalancer_logging_enabled", + "compute_network_dns_logging_enabled", + "compute_project_os_login_enabled", + "compute_subnet_flow_logs_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.b", + "Description": "creation, modification or deletion of users of the relevant entities network and information systems and extension of the permissions;", + "Checks": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "logging" + } + ] + }, + { + "Id": "3.2.3.c", + "Description": "access to systems and applications;", + "Checks": [ + "compute_loadbalancer_logging_enabled", + "cloudsql_instance_postgres_log_connections_flag", + "compute_project_os_login_enabled", + "compute_subnet_flow_logs_enabled", + "iam_audit_logs_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.d", + "Description": "authentication-related events;", + "Checks": [ + "cloudsql_instance_postgres_log_connections_flag", + "cloudsql_instance_postgres_log_disconnections_flag", + "compute_loadbalancer_logging_enabled", + "compute_network_dns_logging_enabled", + "compute_project_os_login_enabled", + "iam_audit_logs_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.e", + "Description": "all privileged access to systems and applications, and activities performed by administrative accounts;", + "Checks": [ + "iam_sa_no_administrative_privileges", + "iam_audit_logs_enabled", + "cloudsql_instance_postgres_log_connections_flag" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.2.3.f", + "Description": "access or changes to critical configuration and backup files;", + "Checks": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "logging" + } + ] + }, + { + "Id": "3.2.3.g", + "Description": "event logs and logs from security tools, such as antivirus, intrusion detection systems or firewalls;", + "Checks": [ + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "logging" + } + ] + }, + { + "Id": "3.2.4", + "Description": "The logs shall be regularly reviewed for any unusual or unwanted trends. Where appropriate, the relevant entities shall lay down appropriate values for alarm thresholds. If the laid down values for alarm threshold are exceeded, an alarm shall be triggered, where appropriate, automatically. The relevant entities shall ensure that, in case of an alarm, a qualified and appropriate response is initiated in a timely manner.", + "Checks": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "logging" + } + ] + }, + { + "Id": "3.2.5", + "Description": "The relevant entities shall maintain and back up logs for a predefined period and shall protect them from unauthorised access or changes.", + "Checks": [ + "cloudstorage_bucket_log_retention_policy_lock", + "logging_sink_created" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.2 Monitoring and logging", + "Service": "generic" + } + ] + }, + { + "Id": "3.4.1", + "Description": "The relevant entities shall assess suspicious events to determine whether they constitute incidents and, if so, determine their nature and severity.", + "Checks": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.4 Event assessment and classification", + "Service": "logging" + } + ] + }, + { + "Id": "3.5.3.a", + "Description": "with the Computer Security Incident Response Teams (CSIRTs) or, where applicable, the competent authorities, related to incident notification;", + "Checks": [ + "iam_organization_essential_contacts_configured" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.5 Incident response", + "Service": "iam" + } + ] + }, + { + "Id": "3.6.2", + "Description": "The relevant entities shall ensure that post-incident reviews contribute to improving their approach to network and information security, to risk treatment measures, and to incident handling, detection and response procedures.", + "Checks": [ + "cloudsql_instance_postgres_log_connections_flag", + "cloudsql_instance_postgres_log_disconnections_flag", + "cloudsql_instance_postgres_log_error_verbosity_flag", + "cloudsql_instance_postgres_log_min_duration_statement_flag", + "cloudsql_instance_postgres_log_min_error_statement_flag", + "cloudsql_instance_postgres_log_min_messages_flag", + "cloudsql_instance_postgres_log_statement_flag", + "cloudstorage_bucket_log_retention_policy_lock", + "compute_loadbalancer_logging_enabled", + "compute_network_dns_logging_enabled", + "compute_project_os_login_enabled", + "compute_subnet_flow_logs_enabled", + "iam_audit_logs_enabled", + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", + "logging_sink_created" + ], + "Attributes": [ + { + "Section": "3 INCIDENT HANDLING (ARTICLE 21(2), POINT (B), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "3.6 Post-incident reviews", + "Service": "generic" + } + ] + }, + { + "Id": "4.1.1", + "Description": "For the purpose of Article 21(2), point (c) of Directive (EU) 2022/2555, the relevant entities shall lay down and maintain a business continuity and disaster recovery plan to apply in the case of incidents.", + "Checks": [ + "cloudsql_instance_automated_backups" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "cloudsql" + } + ] + }, + { + "Id": "4.1.2.f", + "Description": "recovery plans for specific operations, including recovery objectives;", + "Checks": [ + "cloudsql_instance_automated_backups" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "cloudsql" + } + ] + }, + { + "Id": "4.1.2.g", + "Description": "required resources, including backups and redundancies;", + "Checks": [ + "cloudsql_instance_automated_backups" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "cloudsql" + } + ] + }, + { + "Id": "4.1.4", + "Description": "The business continuity plan and disaster recovery plan shall be tested, reviewed and, where appropriate, updated at planned intervals and following significant incidents or significant changes to operations or risks. The relevant entities shall ensure that the plans incorporate lessons learnt from such tests.", + "Checks": [ + "cloudsql_instance_automated_backups", + "cloudstorage_bucket_log_retention_policy_lock" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.1 Business continuity and disaster recovery plan", + "Service": "generic" + } + ] + }, + { + "Id": "4.2.2.b", + "Description": "assurance that backup copies are complete and accurate, including configuration data and data stored in cloud computing service environment;", + "Checks": [ + "cloudsql_instance_automated_backups" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.2 Backup and redundancy management", + "Service": "cloudsql" + } + ] + }, + { + "Id": "4.2.2.e", + "Description": "restoring data from backup copies;", + "Checks": [ + "cloudsql_instance_automated_backups" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.2 Backup and redundancy management", + "Service": "cloudsql" + } + ] + }, + { + "Id": "4.2.2.f", + "Description": "retention periods based on business and regulatory requirements.", + "Checks": [ + "cloudstorage_bucket_log_retention_policy_lock" + ], + "Attributes": [ + { + "Section": "4 BUSINESS CONTINUITY AND CRISIS MANAGEMENT (ARTICLE 21(2), POINT (C), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "4.2 Backup and redundancy management", + "Service": "cloudstorage" + } + ] + }, + { + "Id": "5.1.2.a", + "Description": "the cybersecurity practices of the suppliers and service providers, including their secure development procedures;", + "Checks": [ + "compute_instance_default_service_account_in_use", + "compute_instance_default_service_account_in_use_with_full_api_access", + "compute_network_default_in_use", + "gke_cluster_no_default_service_account" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "generic" + } + ] + }, + { + "Id": "5.1.4.f", + "Description": "an obligation on suppliers and service providers to handle vulnerabilities that present a risk to the security of the network and information systems of the relevant entities;", + "Checks": [ + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "generic" + } + ] + }, + { + "Id": "5.1.7.d", + "Description": "analyse the risks presented by changes related to ICT products and ICT services from suppliers and service providers and, where appropriate, take mitigating measures in a timely manner.", + "Checks": [ + "artifacts_container_analysis_enabled", + "gcr_container_scanning_enabled" + ], + "Attributes": [ + { + "Section": "5 SUPPLY CHAIN SECURITY (ARTICLE 21(2), POINT (D), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "5.1 Supply chain security policy", + "Service": "generic" + } + ] + }, + { + "Id": "6.1.2.b", + "Description": "requirements regarding security updates throughout the entire lifetime of the ICT services or ICT products, or replacement after the end of the support period;", + "Checks": [ + "apikeys_api_restrictions_configured", + "apikeys_key_rotated_in_90_days", + "artifacts_container_analysis_enabled", + "bigquery_dataset_cmk_encryption" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.1 Security in acquisition of ICT services or ICT products", + "Service": "generic" + } + ] + }, + { + "Id": "6.2.1", + "Description": "Before developing a network and information system, including software, the relevant entities shall lay down rules for the secure development of network and information systems and apply them when developing network and information systems in-house, or when outsourcing the development of network and information systems. The rules shall cover all development phases, including specification, design, development, implementation and testing.", + "Checks": [ + "compute_network_default_in_use", + "compute_network_dns_logging_enabled", + "compute_network_not_legacy" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "compute" + } + ] + }, + { + "Id": "6.2.2.b", + "Description": "apply principles for engineering secure systems and secure coding principles to any information system development activities such as promoting cybersecurity-by-design, zero-trust architectures;", + "Checks": [ + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "compute_instance_shielded_vm_enabled", + "apikeys_api_restrictions_configured", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "iam_account_access_approval_enabled", + "iam_no_service_roles_at_project_level", + "iam_sa_no_administrative_privileges", + "iam_sa_no_user_managed_keys", + "iam_sa_user_managed_key_unused", + "iam_service_account_unused" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.2 Secure development life cycle", + "Service": "generic" + } + ] + }, + { + "Id": "6.4.1", + "Description": "The relevant entities shall apply change management procedures to control changes of network and information systems. Where applicable, the procedures shall be consistent with the relevant entities general policies concerning change management.", + "Checks": [ + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.4 Change management, repairs and maintenance", + "Service": "logging" + } + ] + }, + { + "Id": "6.6.1.a", + "Description": "security patches are applied within a reasonable time after they become available;", + "Checks": [ + "compute_instance_shielded_vm_enabled", + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.6 Security patch management", + "Service": "generic" + } + ] + }, + { + "Id": "6.7.2.b", + "Description": "determine and apply controls to protect the relevant entities internal network domains from unauthorised access;", + "Checks": [ + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_instance_block_project_wide_ssh_keys_disabled", + "compute_firewall_rdp_access_from_the_internet_allowed" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "compute" + } + ] + }, + { + "Id": "6.7.2.e", + "Description": "not use systems used for administration of the security policy implementation for other purposes;", + "Checks": [ + "iam_role_kms_enforce_separation_of_duties", + "iam_sa_no_administrative_privileges" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "iam" + } + ] + }, + { + "Id": "6.7.2.g", + "Description": "where appropriate, exclusively allow access to the relevant entities network and information systems by devices authorised by those entities;", + "Checks": [ + "iam_no_service_roles_at_project_level", + "iam_sa_no_administrative_privileges", + "iam_account_access_approval_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "iam" + } + ] + }, + { + "Id": "6.7.2.i", + "Description": "establish communication between distinct systems only through trusted channels that are isolated using logical, cryptographic or physical separation from other communication channels and provide assured identification of their end points and protection of the channel data from modification or disclosure;", + "Checks": [ + "dns_dnssec_disabled", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "dns_rsasha1_in_use_to_zone_sign_in_dnssec", + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_network_dns_logging_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "generic" + } + ] + }, + { + "Id": "6.7.2.l", + "Description": "apply best practices for the security of the DNS, and for Internet routing security and routing hygiene of traffic originating from and destined to the network.", + "Checks": [ + "dns_dnssec_disabled", + "compute_network_dns_logging_enabled", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "dns_rsasha1_in_use_to_zone_sign_in_dnssec" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.7 Network security", + "Service": "generic" + } + ] + }, + { + "Id": "6.9.2", + "Description": "For that purpose, the relevant entities shall in particular implement measures that detect or prevent the use of malicious or unauthorised software. The relevant entities shall, where appropriate, ensure that their network and information systems are equipped with detection and response software, which is updated regularly in accordance with the risk assessment carried out pursuant to point 2.1 and the contractual agreements with the providers.", + "Checks": [ + "compute_instance_shielded_vm_enabled", + "gcr_container_scanning_enabled", + "artifacts_container_analysis_enabled" + ], + "Attributes": [ + { + "Section": "6 SECURITY IN NETWORK AND INFORMATION SYSTEMS ACQUISITION, DEVELOPMENT AND MAINTENANCE (ARTICLE 21(2), POINT (E), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "6.9 Protection against malicious and unauthorised software", + "Service": "generic" + } + ] + }, + { + "Id": "7.2.b", + "Description": "the methods for monitoring, measurement, analysis and evaluation, as applicable, to ensure valid results;", + "Checks": [ + "cloudsql_instance_postgres_log_connections_flag", + "cloudsql_instance_postgres_log_disconnections_flag", + "cloudsql_instance_postgres_log_error_verbosity_flag", + "cloudsql_instance_postgres_log_min_duration_statement_flag", + "cloudsql_instance_postgres_log_min_error_statement_flag", + "cloudsql_instance_postgres_log_min_messages_flag", + "cloudsql_instance_postgres_log_statement_flag", + "cloudstorage_bucket_log_retention_policy_lock", + "compute_loadbalancer_logging_enabled", + "compute_network_dns_logging_enabled", + "compute_project_os_login_enabled", + "compute_subnet_flow_logs_enabled", + "iam_audit_logs_enabled", + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", + "logging_sink_created" + ], + "Attributes": [ + { + "Section": "7 POLICIES AND PROCEDURES TO ASSESS THE EFFECTIVENESS OF CYBERSECURITY RISK-MANAGEMENT MEASURES (ARTICLE 21(2), POINT (F), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "7.2 The policy and procedures referred to in point 7.1. shall take into account results of the risk assessment pursuant to point 2.1. and past significant incidents. The relevant entities shall determine:", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.a", + "Description": "in accordance with the relevant entities classification of assets, the type, strength and quality of the cryptographic measures required to protect the relevant entities assets, including data at rest and data in transit;", + "Checks": [ + "apikeys_api_restrictions_configured", + "apikeys_key_rotated_in_90_days", + "artifacts_container_analysis_enabled", + "bigquery_dataset_cmk_encryption", + "compute_network_dns_logging_enabled", + "dns_dnssec_disabled", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "dns_rsasha1_in_use_to_zone_sign_in_dnssec", + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "compute_instance_encryption_with_csek_enabled", + "dataproc_encrypted_with_cmks_disabled" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c", + "Description": "the relevant entities approach to key management, including, where appropriate, methods for the following:", + "Checks": [ + "apikeys_api_restrictions_configured", + "apikeys_key_exists", + "apikeys_key_rotated_in_90_days", + "compute_instance_block_project_wide_ssh_keys_disabled", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "iam_sa_no_user_managed_keys", + "iam_sa_user_managed_key_rotate_90_days", + "iam_sa_user_managed_key_unused", + "kms_key_not_publicly_accessible", + "kms_key_rotation_enabled" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.i", + "Description": "generating different keys for cryptographic systems and applications;", + "Checks": [ + "apikeys_api_restrictions_configured", + "apikeys_key_exists", + "apikeys_key_rotated_in_90_days", + "compute_instance_block_project_wide_ssh_keys_disabled", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "iam_sa_no_user_managed_keys", + "iam_sa_user_managed_key_rotate_90_days", + "iam_sa_user_managed_key_unused", + "kms_key_not_publicly_accessible", + "kms_key_rotation_enabled" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.ii", + "Description": "issuing and obtaining public key certificates;", + "Checks": [ + "apikeys_key_rotated_in_90_days", + "apikeys_api_restrictions_configured", + "apikeys_key_exists", + "iam_sa_user_managed_key_rotate_90_days", + "kms_key_rotation_enabled" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.iv", + "Description": "storing keys, including how authorised users obtain access to keys;", + "Checks": [ + "apikeys_api_restrictions_configured", + "kms_key_not_publicly_accessible", + "compute_instance_block_project_wide_ssh_keys_disabled" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.v", + "Description": "changing or updating keys, including rules on when and how to change keys;", + "Checks": [ + "kms_key_rotation_enabled", + "apikeys_key_rotated_in_90_days", + "iam_sa_user_managed_key_rotate_90_days" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.vi", + "Description": "backing up or archiving keys;", + "Checks": [ + "kms_key_not_publicly_accessible", + "apikeys_api_restrictions_configured", + "apikeys_key_rotated_in_90_days", + "iam_sa_user_managed_key_rotate_90_days", + "kms_key_rotation_enabled" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.vii", + "Description": "logging and auditing of key management-related activities;", + "Checks": [ + "apikeys_api_restrictions_configured", + "iam_sa_user_managed_key_rotate_90_days", + "iam_sa_user_managed_key_unused" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "9.2.c.xii", + "Description": "setting activation and deactivation dates for keys ensuring that the keys can only be used for the specified period of time according to the organization's rules on key management.", + "Checks": [ + "apikeys_api_restrictions_configured", + "apikeys_key_exists", + "apikeys_key_rotated_in_90_days", + "compute_instance_block_project_wide_ssh_keys_disabled", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "iam_sa_no_user_managed_keys", + "iam_sa_user_managed_key_rotate_90_days", + "iam_sa_user_managed_key_unused", + "kms_key_not_publicly_accessible", + "kms_key_rotation_enabled" + ], + "Attributes": [ + { + "Section": "9 CRYPTOGRAPHY (ARTICLE 21(2), POINT (H), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "9.2 Cryptography", + "Service": "generic" + } + ] + }, + { + "Id": "11.1.1", + "Description": "For the purpose of Article 21(2), point (i) of Directive (EU) 2022/2555, the relevant entities shall establish, document and implement logical and physical access control policies for the access to their network and information systems, based on business requirements as well as network and information system security requirements.", + "Checks": [ + "compute_firewall_ssh_access_from_the_internet_allowed", + "compute_network_dns_logging_enabled", + "compute_network_not_legacy", + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_instance_block_project_wide_ssh_keys_disabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.1 Access control policy", + "Service": "compute" + } + ] + }, + { + "Id": "11.1.2.c", + "Description": "ensure that access is only granted to users that have been adequately authenticated.", + "Checks": [ + "iam_account_access_approval_enabled", + "iam_no_service_roles_at_project_level", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_sa_no_administrative_privileges", + "iam_sa_no_user_managed_keys", + "iam_service_account_unused" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.1 Access control policy", + "Service": "iam" + } + ] + }, + { + "Id": "11.2.2.a", + "Description": "assign and revoke access rights based on the principles of need-to-know, least privilege and separation of duties;", + "Checks": [ + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_sa_no_administrative_privileges", + "iam_sa_no_user_managed_keys", + "iam_service_account_unused" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "iam" + } + ] + }, + { + "Id": "11.2.2.d", + "Description": "ensure that access rights appropriately address third-party access, such as visitors, suppliers and service providers, in particular by limiting access rights in scope and in duration;", + "Checks": [ + "iam_no_service_roles_at_project_level", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_sa_no_administrative_privileges", + "iam_sa_no_user_managed_keys", + "iam_service_account_unused" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "iam" + } + ] + }, + { + "Id": "11.2.2.e", + "Description": "maintain a register of access rights granted;", + "Checks": [ + "bigquery_dataset_public_access", + "iam_audit_logs_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "generic" + } + ] + }, + { + "Id": "11.2.2.f", + "Description": "apply logging to the management of access rights.", + "Checks": [ + "iam_audit_logs_enabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.2 Management of access rights", + "Service": "iam" + } + ] + }, + { + "Id": "11.3.1", + "Description": "The relevant entities shall maintain policies for management of privileged accounts and system administration accounts as part of the access control policy referred to in point 11.1.", + "Checks": [ + "iam_account_access_approval_enabled", + "iam_no_service_roles_at_project_level", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_sa_no_administrative_privileges", + "iam_sa_no_user_managed_keys", + "iam_service_account_unused" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "iam" + } + ] + }, + { + "Id": "11.3.2.a", + "Description": "establish strong identification, authentication such as multi-factor authentication, and authorisation procedures for privileged accounts and system administration accounts;", + "Checks": [ + "cloudsql_instance_sqlserver_contained_database_authentication_flag", + "iam_sa_no_user_managed_keys", + "iam_service_account_unused" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "generic" + } + ] + }, + { + "Id": "11.3.2.b", + "Description": "set up specific accounts to be used for system administration operations exclusively, such as installation, configuration, management or maintenance;", + "Checks": [ + "iam_sa_no_administrative_privileges", + "iam_no_service_roles_at_project_level", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "iam" + } + ] + }, + { + "Id": "11.3.2.c", + "Description": "individualise and restrict system administration privileges to the highest extent possible,", + "Checks": [ + "iam_sa_no_administrative_privileges", + "iam_sa_no_user_managed_keys", + "iam_service_account_unused", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "iam" + } + ] + }, + { + "Id": "11.3.2.d", + "Description": "provide that system administration accounts are only used to connect to system administration systems.", + "Checks": [ + "iam_sa_no_administrative_privileges", + "iam_sa_no_user_managed_keys", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "iam_account_access_approval_enabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.3 Privileged accounts and system administration accounts", + "Service": "iam" + } + ] + }, + { + "Id": "11.4.2.a", + "Description": "only use system administration systems for system administration purposes, and not for any other operations;", + "Checks": [ + "iam_sa_no_administrative_privileges", + "iam_service_account_unused" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.4 Administration systems", + "Service": "iam" + } + ] + }, + { + "Id": "11.4.2.b", + "Description": "separate logically such systems from application software not used for system administrative purposes,", + "Checks": [ + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.4 Administration systems", + "Service": "iam" + } + ] + }, + { + "Id": "11.4.2.c", + "Description": "protect access to system administration systems through authentication and encryption.", + "Checks": [ + "iam_sa_no_administrative_privileges", + "iam_sa_no_user_managed_keys", + "iam_service_account_unused", + "iam_role_kms_enforce_separation_of_duties", + "iam_role_sa_enforce_separation_of_duties", + "kms_key_not_publicly_accessible", + "kms_key_rotation_enabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.4 Administration systems", + "Service": "generic" + } + ] + }, + { + "Id": "11.5.2.d", + "Description": "apply logging to the management of identities.", + "Checks": [ + "cloudsql_instance_postgres_log_connections_flag", + "cloudsql_instance_postgres_log_disconnections_flag", + "cloudsql_instance_postgres_log_error_verbosity_flag", + "cloudsql_instance_postgres_log_min_duration_statement_flag", + "cloudsql_instance_postgres_log_min_error_statement_flag", + "cloudsql_instance_postgres_log_min_messages_flag", + "cloudsql_instance_postgres_log_statement_flag", + "cloudstorage_bucket_log_retention_policy_lock", + "compute_loadbalancer_logging_enabled", + "compute_network_dns_logging_enabled", + "compute_project_os_login_enabled", + "compute_subnet_flow_logs_enabled", + "iam_audit_logs_enabled", + "logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled", + "logging_log_metric_filter_and_alert_for_custom_role_changes_enabled", + "logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled", + "logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled", + "logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled", + "logging_sink_created" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.5 Identification", + "Service": "generic" + } + ] + }, + { + "Id": "11.5.4", + "Description": "The relevant entities shall regularly review the identities for network and information systems and their users and, if no longer needed, deactivate them without delay.", + "Checks": [ + "iam_service_account_unused", + "iam_sa_user_managed_key_unused", + "iam_sa_user_managed_key_rotate_90_days", + "iam_sa_no_user_managed_keys" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.5 Identification", + "Service": "iam" + } + ] + }, + { + "Id": "11.6.1", + "Description": "The relevant entities shall implement secure authentication procedures and technologies based on access restrictions and the policy on access control.", + "Checks": [ + "cloudsql_instance_sqlserver_contained_database_authentication_flag", + "iam_role_kms_enforce_separation_of_duties", + "kms_key_not_publicly_accessible", + "kms_key_rotation_enabled" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "generic" + } + ] + }, + { + "Id": "11.6.2.a", + "Description": "ensure the strength of authentication is appropriate to the classification of the asset to be accessed;", + "Checks": [ + "cloudsql_instance_sqlserver_contained_database_authentication_flag", + "iam_account_access_approval_enabled", + "cloudsql_instance_sqlserver_remote_access_flag", + "compute_firewall_ssh_access_from_the_internet_allowed" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "generic" + } + ] + }, + { + "Id": "11.6.2.c", + "Description": "require the change of authentication credentials initially, at predefined intervals and upon suspicion that the credentials were compromised;", + "Checks": [ + "apikeys_key_rotated_in_90_days", + "kms_key_rotation_enabled", + "iam_sa_user_managed_key_rotate_90_days" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.6 Authentication", + "Service": "generic" + } + ] + }, + { + "Id": "11.7.2", + "Description": "The relevant entities shall ensure that the strength of authentication is appropriate for the classification of the asset to be accessed.", + "Checks": [ + "cloudsql_instance_sqlserver_contained_database_authentication_flag", + "iam_account_access_approval_enabled", + "cloudsql_instance_sqlserver_remote_access_flag", + "compute_firewall_ssh_access_from_the_internet_allowed" + ], + "Attributes": [ + { + "Section": "11 ACCESS CONTROL (ARTICLE 21(2), POINTS (I) AND (J), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "11.7 Multi-factor authentication", + "Service": "generic" + } + ] + }, + { + "Id": "12.1.2.c", + "Description": "align the availability requirements of the assets with the delivery and recovery objectives set out in their business continuity and disaster recovery plans.", + "Checks": [ + "cloudsql_instance_automated_backups" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.1 Asset classification", + "Service": "cloudsql" + } + ] + }, + { + "Id": "12.2.2.a", + "Description": "cover the entire life cycle of the assets, including acquisition, use, storage, transportation and disposal;", + "Checks": [ + "apikeys_key_rotated_in_90_days", + "iam_sa_user_managed_key_rotate_90_days", + "kms_key_rotation_enabled" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.2 Handling of assets", + "Service": "generic" + } + ] + }, + { + "Id": "12.2.2.b", + "Description": "provide rules on the safe use, safe storage, safe transport, and the irretrievable deletion and destruction of the assets;", + "Checks": [ + "apikeys_key_rotated_in_90_days", + "iam_sa_user_managed_key_rotate_90_days", + "kms_key_rotation_enabled", + "bigquery_dataset_cmk_encryption", + "bigquery_table_cmk_encryption", + "compute_instance_encryption_with_csek_enabled" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.2 Handling of assets", + "Service": "generic" + } + ] + }, + { + "Id": "12.2.2.c", + "Description": "provide that the transfer shall take place in a secure manner, in accordance with the type of asset to be transferred.", + "Checks": [ + "compute_network_dns_logging_enabled", + "dns_dnssec_disabled", + "dns_rsasha1_in_use_to_key_sign_in_dnssec", + "dns_rsasha1_in_use_to_zone_sign_in_dnssec", + "compute_firewall_rdp_access_from_the_internet_allowed", + "compute_firewall_ssh_access_from_the_internet_allowed", + "cloudsql_instance_ssl_connections", + "cloudsql_instance_private_ip_assignment" + ], + "Attributes": [ + { + "Section": "12 ASSET MANAGEMENT (ARTICLE 21(2), POINT (I), OF DIRECTIVE (EU) 2022/2555)", + "SubSection": "12.2 Handling of assets", + "Service": "generic" + } + ] + } + ] +} diff --git a/prowler/compliance/gcp/prowler_threatscore_gcp.json b/prowler/compliance/gcp/prowler_threatscore_gcp.json index f21949095f..ed60fed075 100644 --- a/prowler/compliance/gcp/prowler_threatscore_gcp.json +++ b/prowler/compliance/gcp/prowler_threatscore_gcp.json @@ -17,7 +17,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "Service account keys consist of a key ID (private_key_id) and a private key, which are used to authenticate programmatic requests to Google Cloud services. It is recommended to regularly rotate service account keys to enhance security and reduce the risk of unauthorized access.", "AdditionalInformation": "Regularly rotating service account keys minimizes the risk of a compromised, lost, or stolen key being used to access cloud resources. Google-managed keys are automatically rotated daily for internal authentication, ensuring strong security. For user-managed (external) keys, users are responsible for key security, storage, and rotation. Since Google does not retain private keys once generated, proper key management practices must be followed. Google Cloud allows up to 10 external keys per service account, making it easier to rotate them without disruption. Implementing regular key rotation ensures that old keys are not left active, reducing the potential attack surface.", - "LevelOfRisk": 1 + "LevelOfRisk": 1, + "Weight": 1 } ] }, @@ -34,7 +35,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "API keys should only be used when no other authentication method is available, as they pose significant security risks. Unused API keys with active permissions may still exist within a project, potentially exposing resources to unauthorized access. It is recommended to use standard authentication flows such as OAuth 2.0 or service account authentication instead.", "AdditionalInformation": "API keys are inherently insecure because they: Are simple encrypted strings that can be easily exposed in browsers, client-side applications, or devices. Do not authenticate users or applications making API requests. Can be accidentally leaked in logs, repositories, or web traffic.To enhance security, API keys should be avoided when possible, and unused keys should be deleted to minimize the risk of unauthorized access.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -51,7 +53,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "API keys should only be used when no other authentication method is available. If API keys are in use, it is recommended to rotate them every 90 days to minimize security risks.", "AdditionalInformation": "API keys are inherently insecure because: They are simple encrypted strings that can be easily exposed. They do not authenticate users or applications making API requests. They are often accessible to clients, increasing the risk of theft and misuse. Unlike credentials with expiration policies, stolen API keys remain valid indefinitely unless revoked or regenerated. Regularly rotating API keys reduces the risk of unauthorized access by ensuring that compromised keys cannot be used for extended periods. To enhance security, API keys should be rotated every 90 days or as part of a proactive security policy.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -68,7 +71,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "Service accounts should not use user-managed keys, as they introduce security risks and require manual management. Instead, use Google Cloud-managed keys, which are automatically rotated and secured by Google.", "AdditionalInformation": "User-managed keys are downloadable and manually managed, making them vulnerable to leaks, mismanagement, and unauthorized access. In contrast, GCP-managed keys are non-downloadable, automatically rotated weekly, and securely handled by Google Cloud services like App Engine and Compute Engine. Managing user-generated keys requires key storage, distribution, rotation, revocation, and protectionall of which introduce potential security gaps. Common risks include keys being exposed in source code repositories, left in unsecured locations, or unintentionally shared. To minimize security risks, it is recommended to disable user-managed service account keys and rely on GCP-managed keys instead.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -85,7 +89,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "A service account is a special Google account assigned to an application or virtual machine (VM) rather than an individual user. It is used to authenticate API requests on behalf of the application. Service accounts should not be granted admin privileges to minimize security risks.", "AdditionalInformation": "Service accounts control resource access based on their assigned roles. Granting admin privileges to a service account allows full control over applications or VMs, enabling actions like deletion, updates, and configuration changes without user intervention. This increases the risk of misconfigurations, privilege escalation, or potential security breaches. To follow the principle of least privilege, it is recommended to restrict admin access for service accounts and assign only the necessary permissions.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -102,7 +107,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "The IAM policy on Cloud KMS cryptographic keys should not allow anonymous (allUsers) or public (allAuthenticatedUsers) access to prevent unauthorized key usage.", "AdditionalInformation": "Granting permissions to allUsers or allAuthenticatedUsers allows anyone to access the cryptographic keys, which can lead to data exposure, unauthorized encryption/decryption operations, or potential key compromise. This is particularly critical if sensitive data is protected using these keys. To maintain data security and compliance, ensure that Cloud KMS cryptographic keys are only accessible to authorized users, groups, or service accounts and do not have public or anonymous access permissions.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -119,7 +125,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "Google Cloud Key Management Service (KMS) organizes cryptographic keys in a hierarchical structure to facilitate secure and efficient access control. Keys should be configured with a defined rotation schedule to ensure their cryptographic strength is maintained over time.", "AdditionalInformation": "Key rotation ensures that new key versions are automatically generated at regular intervals, reducing the risk of key compromise and unauthorized access. The key material (actual encryption bits) changes over time, even though the keys logical identity remains the same. Since cryptographic keys protect sensitive data, setting a specific rotation period ensures that encrypted data remains secure, minimizes the impact of a potential key leak, and aligns with best security practices.", - "LevelOfRisk": 1 + "LevelOfRisk": 1, + "Weight": 1 } ] }, @@ -136,7 +143,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "The principle of Separation of Duties should be enforced when assigning Google Cloud Key Management Service (KMS) roles to users. This prevents excessive privileges and reduces security risks.", "AdditionalInformation": "The Cloud KMS Admin role grants the ability to create, delete, and manage keys, while the Cloud KMS CryptoKey Encrypter/Decrypter, Encrypter, and Decrypter roles control encryption and decryption of data. Granting both administrative and cryptographic privileges to the same user violates the Separation of Duties principle, potentially allowing unauthorized access to sensitive data. To mitigate risks and prevent privilege escalation, no user should hold the Cloud KMS Admin role along with any of the CryptoKey Encrypter/Decrypter roles. Enforcing Separation of Duties helps ensure secure key management and aligns with security best practices.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -153,7 +161,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "API keys should only be used when no other authentication method is available, as they pose a higher security risk due to their public visibility. To minimize exposure, API keys should be restricted to access only the specific APIs required by an application.", "AdditionalInformation": "API keys present several security risks, including: They are simple encrypted strings that can be easily exposed in client-side applications or browsers. They do not authenticate the user or application making API requests. They are often accessible to clients, making them susceptible to discovery and theft. Google recommends using standard authentication methods instead of API keys whenever possible. However, in limited scenarios where API keys are necessary (e.g., mobile applications using Google Cloud Translation API without a backend server), restricting API key access to only the required APIs helps enforce least privilege access and reduces attack surfaces.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -170,7 +179,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "It is recommended to assign the Service Account User (iam.serviceAccountUser) and Service Account Token Creator (iam.serviceAccountTokenCreator) roles to users at the service account level rather than granting them project-wide access.", "AdditionalInformation": "Service accounts are identities used by applications and virtual machines (VMs) to interact with Google Cloud APIs. They also function as resources with IAM policies defining who can use them. Granting service account permissions at the project level allows users to access all service accounts within the project, including any created in the future. This increases the risk of privilege escalation, as users with Compute Instance Admin or App Engine Deployer roles could execute code as a service account, gaining access to additional resources. To enforce the principle of least privilege, users should be assigned service account roles at the specific service account level rather than at the project level. This ensures that each user has access only to the necessary service accounts while preventing unintended privilege escalation.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -187,7 +197,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "It is recommended to enforce the principle of Separation of Duties when assigning service account-related IAM roles to users to prevent excessive privileges and security risks.", "AdditionalInformation": "The Service Account Admin role allows a user to create, delete, and manage service accounts, while the Service Account User role allows a user to assign service accounts to applications or compute instances. Granting both roles to the same user violates the Separation of Duties principle, as it would allow an individual to create and assign service accounts, potentially leading to unauthorized access or privilege escalation. To minimize security risks, no user should be assigned both Service Account Admin and Service Account User roles simultaneously. Enforcing Separation of Duties ensures better access control, reduces the risk of privilege abuse, and aligns with security best practices.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -204,7 +215,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "Cloud Audit Logging should be configured to track all administrative activities and read/write access to user data. This ensures comprehensive visibility into who accessed or modified resources within a project, folder, or organization.", "AdditionalInformation": "Cloud Audit Logging maintains two types of audit logs: 1. Admin Activity Logs Captures API calls and administrative actions that modify configurations or metadata. These logs are enabled by default and cannot be disabled. 2. Data Access Logs Tracks API calls that create, modify, or read user data. These logs are disabled by default and should be enabled for better monitoring. Data Access Logs provide three types of visibility: Admin Read Tracks metadata or configuration reads. Data Read Logs operations where user-provided data is accessed. Data Write Captures modifications to user-provided data.To ensure effective logging, it is recommended to: 1. Enable DATA_READ logs (for user activity tracking) and DATA_WRITE logs (to track modifications). 2. Apply audit logging to all supported services where Data Access logs are available. 3.Avoid exempting users from audit logs to maintain full tracking capabilities. Properly configuring Cloud Audit Logging helps strengthen security, detect unauthorized access, and ensure compliance with security policies.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -221,7 +233,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "In order to prevent unnecessary project ownership assignments to users or service accounts and mitigate potential misuse of projects and resources, all role assignments to roles/Owner should be monitored. Users or service accounts assigned the roles/Owner primitive role are considered project owners. The Owner role grants full control over the project, including: full viewer permissions on all GCP services, permissions to modify the state of all services, manage roles and permissions for the project and its resources, and set up billing for the project. Granting the Owner role allows the member to modify the IAM policy, which contains sensitive access control data. To minimize security risks, the Owner role should only be assigned when strictly necessary, and the number of users with this role should be kept to a minimum.", "AdditionalInformation": "Project ownership has the highest level of privileges within a project, making it a high-risk role if misused. To reduce potential security risks, all project ownership assignments and changes should be monitored and alerted to security teams or relevant recipients. Critical events to monitor include: sending project ownership invitations, acceptance or rejection of ownership invites, assigning the roles/Owner role to a user or service account, and removing a user or service account from the roles/Owner role. Monitoring these activities helps prevent unauthorized access, enforces least privilege principles, and improves security auditing and compliance.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -238,7 +251,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "Google Cloud Platform (GCP) services generate audit log entries in the Admin Activity and Data Access logs, providing visibility into who performed what action, where, and when within GCP projects. These logs capture key details such as the identity of the API caller, timestamp, source IP address, request parameters, and response data. Cloud audit logging records API calls made through the GCP Console, SDKs, command-line tools, and other GCP services, offering a comprehensive activity history for security monitoring and compliance.", "AdditionalInformation": "Admin activity and data access logs play a critical role in security analysis, resource change tracking, and compliance auditing. Configuring metric filters and alerts for audit configuration changes ensures that audit logging remains in its recommended state, allowing organizations to detect and respond to unauthorized modifications while ensuring all project activities remain fully auditable at any time.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -255,7 +269,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "It is recommended to set up a metric filter and alarm to track changes to Identity and Access Management (IAM) roles, including their creation, deletion, and updates. Google Cloud IAM provides predefined roles for granular access control but also allows organizations to create custom roles to meet specific needs.", "AdditionalInformation": "IAM role modifications can impact security by granting excessive privileges if not properly managed. Monitoring role creation, deletion, and updates helps detect potential misconfigurations or over-privileged roles early, ensuring that only intended access permissions are assigned within the organization.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -272,7 +287,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "A project should not have a default network to prevent the use of preconfigured and potentially insecure network settings.", "AdditionalInformation": "The default network automatically creates permissive firewall rules, including unrestricted internal traffic, SSH, RDP, and ICMP access, which increases the risk of unauthorized access. Additionally, it is an auto mode network, limiting flexibility in subnet configuration and restricting the use of Cloud VPN or VPC Network Peering. Organizations should create a custom network tailored to their security and networking needs and remove the default network to minimize exposure.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -289,7 +305,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "Projects should not have a legacy network configured to prevent the use of outdated and inflexible networking models. While new projects can no longer create legacy networks, older projects should be checked to ensure they are not still using them.", "AdditionalInformation": "Legacy networks use a single global IPv4 prefix and a single gateway IP for the entire network, lacking subnetting capabilities. This design limits flexibility, prevents migration to auto or custom subnet networks, and can create performance bottlenecks or single points of failure for high-traffic workloads. Removing legacy networks and transitioning to modern networking models improves scalability, security, and resilience.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -306,7 +323,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "GCP Firewall Rules control ingress and egress traffic within a VPC Network. These rules define traffic conditions such as ports, protocols, and source/destination IPs. Firewall rules operate at the VPC level and cannot be shared across networks. Only IPv4 addresses are supported, and it is crucial to restrict generic (0.0.0.0/0) incoming traffic, particularly for SSH on Port 22, to prevent unauthorized access.", "AdditionalInformation": "Firewall rules regulate traffic flow between instances and external networks. Allowing unrestricted inbound SSH access (0.0.0.0/0 on port 22) increases security risks by exposing instances to unauthorized access and brute-force attacks. To minimize threats, internet-facing access should be limited by specifying granular IP ranges and enforcing least privilege access.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -323,7 +341,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "GCP Firewall Rules control incoming (ingress) and outgoing (egress) traffic within a VPC Network. Each rule specifies traffic conditions, including ports, protocols, and source/destination IPs. These rules operate at the VPC level, cannot be shared across networks, and support only IPv4 addresses. To enhance security, unrestricted RDP access (0.0.0.0/0 on port 3389) should be avoided to prevent unauthorized remote connections.", "AdditionalInformation": "Firewall rules regulate traffic flow between instances and external networks. Allowing unrestricted RDP access from the Internet exposes virtual machines (VMs) to unauthorized access and brute-force attacks. To mitigate risks, internet-facing access should be restricted by enforcing least privilege access, defining specific IP ranges, and implementing secure remote access solutions such as Bastion hosts or VPNs.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -340,7 +359,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "IAM policies on Cloud Storage buckets should not allow anonymous or public access to prevent unauthorized data exposure.", "AdditionalInformation": "Granting public or anonymous access allows anyone to access the buckets contents, posing a security risk, especially if sensitive data is stored. Restricting access ensures that only authorized users can interact with the bucket, reducing the risk of data breaches.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -357,7 +377,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Verify the user connection limits for Cloud SQL SQL Server instances to ensure they are not unnecessarily restricting the number of simultaneous connections.", "AdditionalInformation": "The user connections setting controls the maximum number of concurrent user connections allowed on an SQL Server instance. By default, SQL Server dynamically adjusts the number of connections as needed, up to a maximum of 32,767. Setting an artificial limit may prevent new connections from being established, leading to potential data loss or service outages. It is recommended to review and adjust this setting as necessary to avoid disruptions.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -374,7 +395,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Disable the remote access database flag for Cloud SQL SQL Server instances to prevent execution of stored procedures from remote servers.", "AdditionalInformation": "The remote access option allows stored procedures to be executed from or on remote SQL Server instances. By default, this setting is enabled, which could be exploited for unauthorized query execution or Denial-of-Service (DoS) attacks by offloading processing to a target server. Disabling remote access enhances security by restricting stored procedure execution to the local server, reducing potential attack vectors. This recommendation applies to SQL Server database instances.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -391,7 +413,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Restrict database server access to only trusted networks and IP addresses, preventing connections from public IPs.", "AdditionalInformation": "Allowing unrestricted access to a database server increases the risk of unauthorized access and attacks. To minimize the attack surface, only trusted and necessary IP addresses should be whitelisted. Authorized networks should not be set to 0.0.0.0/0, which permits connections from anywhere. This control applies specifically to instances with public IP addresses.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -408,7 +431,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Configure Second Generation Cloud SQL instances to use private IPs instead of public IPs.", "AdditionalInformation": "Using private IPs for Cloud SQL databases enhances security by reducing exposure to external threats. It also improves network performance and lowers latency by keeping traffic within the internal network, minimizing the attack surface of the database.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -425,7 +449,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Ensure that IAM policies on BigQuery datasets do not allow anonymous or public access.", "AdditionalInformation": "Granting access to allUsers or allAuthenticatedUsers permits unrestricted access to the dataset, which can lead to unauthorized data exposure. To protect sensitive information, public or anonymous access should be strictly prohibited.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -442,7 +467,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "To enforce the principle of least privilege and prevent potential privilege escalation, instances should not be assigned the Compute Engine default service account with the scope Allow full access to all Cloud APIs.", "AdditionalInformation": "Google Compute Engine provides a default service account for instances to access necessary cloud services. This default service account has the Project Editor role, granting broad permissions over most cloud services except billing. When assigned to an instance, it can operate in three modes: 1.Allow default access Grants minimal required permissions (recommended). 2.Allow full access to all Cloud APIs Grants excessive access to all cloud services (not recommended). 3.Set access for each API Allows administrators to specify required APIs (preferred for least privilege). Assigning an instance the Compute Engine default service account with full access to all APIs can expose cloud operations to unauthorized users based on IAM roles. To reduce security risks, instances should use custom service accounts with minimal required permissions.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -459,7 +485,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Instances should use instance-specific SSH keys instead of project-wide SSH keys to enhance security and reduce the risk of unauthorized access.", "AdditionalInformation": "Project-wide SSH keys are stored in Compute Project metadata and can be used to access all instances within a project. While this simplifies SSH key management, it also increases security risksif a project-wide SSH key is compromised, all instances in the project could be affected. Using instance-specific SSH keys provides better security by limiting access to individual instances, reducing the attack surface in case of key compromise.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -476,7 +503,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "The interactive serial console allows direct access to a virtual machines serial ports, similar to using a terminal window. When enabled, it allows connections from any IP address, creating a potential security risk. It is recommended to disable interactive serial console support.", "AdditionalInformation": "A virtual machine instance has four virtual serial ports, often used by the operating system, BIOS, or other system-level entities for input and output. The first serial port (serial port 1) is commonly referred to as the serial console. Unlike SSH, the interactive serial console does not support IP-based access restrictions, meaning anyone with the correct SSH key, username, project ID, zone, and instance name could gain access. This exposes the instance to unauthorized access. To mitigate this risk, interactive serial console support should be disabled unless absolutely necessary.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -493,7 +521,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Google Compute Engine instances should not forward data packets unless explicitly required for routing purposes. By default, an instance cannot forward packets unless the source IP matches the instances IP address. Similarly, GCP wont deliver packets if the destination IP does not match the instance. To prevent unauthorized data forwarding, it is recommended to disable IP forwarding.", "AdditionalInformation": "When IP forwarding is enabled (canIpForward field), an instance can send and receive packets with non-matching source or destination IPs, effectively allowing it to act as a network router. This can lead to data loss, information disclosure, or unauthorized traffic routing. To maintain security and prevent misuse, IP forwarding should be disabled unless explicitly required for network routing configurations.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -510,7 +539,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Shielded VMs are hardened virtual machines on Google Cloud Platform (GCP) designed to protect against rootkits, bootkits, and other low-level attacks. They ensure verifiable integrity using Secure Boot, virtual Trusted Platform Module (vTPM)-enabled Measured Boot, and integrity monitoring.", "AdditionalInformation": "Shielded VMs use signed and verified firmware from Googles Certificate Authority to establish a root of trust. Secure Boot ensures only authentic software runs by verifying digital signatures, preventing unauthorized modifications. Integrity monitoring helps detect unexpected changes in the VMs boot process, while vTPM-enabled Measured Boot provides a baseline to compare against future boots. Enabling Shielded VMs enhances security by protecting against malware, unauthorized firmware changes, and persistent threats.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -527,7 +557,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "Compute instances should not be assigned external IP addresses to minimize exposure to the internet and reduce security risks.", "AdditionalInformation": "Public IP addresses increase the attack surface of Compute instances, making them more vulnerable to threats. Instead, instances should be placed behind load balancers or use private networking to control access and reduce the risk of unauthorized exposure.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -544,7 +575,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "It is recommended to create a log sink to export and store copies of all log entries. This enables log aggregation across multiple projects and allows integration with a Security Information and Event Management (SIEM) system for centralized monitoring.", "AdditionalInformation": "Cloud Logging retains logs for a limited period. To ensure long-term storage and better security analysis, logs should be exported to a destination such as Cloud Storage, BigQuery, or Cloud Pub/Sub. A log sink allows you to: Aggregate logs from multiple projects, folders, or billing accounts. Extend log retention beyond Cloud Loggings default retention period. Send logs to a SIEM system for real-time monitoring and threat detection. To ensure all logs are captured and exported: 1.Create a sink without filters to capture all log entries. 2.Choose an appropriate destination (e.g., Cloud Storage for long-term storage, BigQuery for analysis, or Pub/Sub for real-time processing). 3.Apply logging at the organization level to cover all associated projects. Implementing log sinks enhances security visibility, forensic capabilities, and compliance adherence across cloud environments.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -561,7 +593,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "It is recommended to configure a metric filter and alarm to monitor Virtual Private Cloud (VPC) Network Firewall rule changes. Tracking modifications to firewall rules helps ensure that unauthorized or unintended changes do not compromise network security.", "AdditionalInformation": "Firewall rules control ingress and egress traffic within a VPC. Monitoring create or update events provides visibility into network access changes and helps quickly detect potential security threats or misconfigurations, reducing the risk of unauthorized access.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -578,7 +611,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "It is recommended to configure a metric filter and alarm to monitor Virtual Private Cloud (VPC) network route changes. Keeping track of modifications ensures that unauthorized or unintended changes do not disrupt expected network traffic flow.", "AdditionalInformation": "GCP routes define how network traffic is directed between VM instances and external destinations. Monitoring route table changes helps ensure that traffic follows the intended path, preventing misconfigurations or malicious alterations that could lead to data exposure or connectivity issues.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -595,7 +629,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "It is recommended to configure a metric filter and alarm to monitor Virtual Private Cloud (VPC) network changes. This helps track modifications to VPC configurations and peer connections, ensuring that network traffic remains secure and follows the intended paths.", "AdditionalInformation": "It is recommended to configure a metric filter and alarm to monitor Virtual Private Cloud (VPC) network changes. This helps track modifications to VPC configurations and peer connections, ensuring that network traffic remains secure and follows the intended paths.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -612,7 +647,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "It is recommended to set up a metric filter and alarm to monitor Cloud Storage Bucket IAM changes. This ensures that any modifications to bucket permissions are tracked and reviewed in a timely manner.", "AdditionalInformation": "Monitoring changes to Cloud Storage IAM policies helps detect and correct unauthorized access or overly permissive configurations. This reduces the risk of data exposure or breaches by ensuring that sensitive storage buckets and their contents remain properly secured.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -629,7 +665,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "It is recommended to configure a metric filter and alarm to track SQL instance configuration changes. This helps in detecting and addressing misconfigurations that may impact security, availability, and compliance.", "AdditionalInformation": "Monitoring SQL instance configuration changes ensures that critical security settings remain properly configured. Misconfigurations, such as disabling auto backups, allowing untrusted networks, or modifying high availability settings, can lead to data loss, security vulnerabilities, or operational disruptions. Early detection of such changes helps maintain a secure and resilient SQL environment.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -646,7 +683,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "Enabling logging on an HTTPS Load Balancer captures all network traffic and its destination, providing visibility into requests made to your web applications.", "AdditionalInformation": "Logging HTTPS network traffic helps monitor access patterns, troubleshoot issues, and enhance security by detecting suspicious activity or unauthorized access attempts.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -663,7 +701,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "Flow Logs capture and record IP traffic to and from network interfaces within VPC subnets. These logs are stored in Stackdriver Logging, allowing users to analyze traffic patterns, detect anomalies, and optimize network performance. It is recommended to enable Flow Logs for all critical VPC subnets to enhance network visibility and security.", "AdditionalInformation": "VPC Flow Logs provide detailed insights into inbound and outbound traffic for virtual machines (VMs), whether they communicate with other VMs, on-premises data centers, Google services, or external networks. Enabling Flow Logs supports: Network monitoring Traffic analysis and cost optimization Incident investigation and forensics Real-time security threat detection For effective monitoring, Flow Logs should be configured to capture all traffic, use granular logging intervals, avoid log filtering, and include metadata for detailed investigations. Note that subnets reserved for internal HTTP(S) load balancing do not support Flow Logs.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -680,7 +719,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "The log_connections setting should be enabled to log all attempted connections to the PostgreSQL server, including successful client authentication.", "AdditionalInformation": "By default, PostgreSQL does not log connection attempts, making it harder to detect unauthorized access. Enabling log_connections provides visibility into all connection attempts, aiding in troubleshooting and identifying unusual or suspicious access patterns. This is particularly useful for security monitoring and incident response.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -697,7 +737,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "The log_disconnections setting should be enabled to log the end of each PostgreSQL session, including session duration.", "AdditionalInformation": "By default, PostgreSQL does not log session termination details, making it difficult to track session activity. Enabling log_disconnections helps monitor session durations and detect unusual activity. Combined with log_connections, it provides a complete audit trail of user access, aiding in troubleshooting and security monitoring.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -714,7 +755,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "The log_statement setting in PostgreSQL determines which SQL statements are logged. Acceptable values include none, ddl, mod, and all. A recommended setting is ddl, which logs all data definition statements unless otherwise specified by the organizations logging policy.", "AdditionalInformation": "Proper SQL statement logging is crucial for auditing and forensic analysis. If too many statements are logged, it can become difficult to extract relevant information; if too few are logged, critical details may be missing. Setting log_statement to an appropriate value, such as ddl, ensures a balance between comprehensive auditing and log manageability, aiding in database security and compliance.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -731,7 +773,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "The log_min_messages setting in PostgreSQL defines the minimum severity level for messages to be logged as errors. Accepted values range from DEBUG5 (least severe) to PANIC (most severe). Best practice is to set this value to ERROR, ensuring that only critical issues are logged unless an organizations policy requires a different threshold.", "AdditionalInformation": "Proper logging is essential for troubleshooting and forensic analysis. If log_min_messages is not configured correctly, important error messages may be missed or unnecessary logs may clutter records. Setting this parameter to ERROR helps maintain a balance between capturing relevant issues and avoiding excessive log noise, improving system monitoring and security.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -748,7 +791,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "The log_min_error_statement setting in PostgreSQL defines the minimum severity level for statements to be logged as errors. Valid values range from DEBUG5 (least severe) to PANIC (most severe). It is recommended to set this value to ERROR or stricter to ensure only relevant error statements are logged.", "AdditionalInformation": "Proper logging aids in troubleshooting and forensic analysis. If log_min_error_statement is set too leniently, excessive log entries may make it difficult to identify actual errors. Conversely, if it is set too strictly, important errors may be missed. Setting this parameter to ERROR or higher ensures that significant issues are recorded while avoiding unnecessary log clutter.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -765,7 +809,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "The log_min_duration_statement setting in PostgreSQL determines the minimum execution time (in milliseconds) required for a statement to be logged. It is recommended to disable this setting by setting its value to -1.", "AdditionalInformation": "Logging SQL statements may expose sensitive information, which could lead to security risks if recorded in logs. Disabling this setting ensures that confidential data is not inadvertently captured. This recommendation applies to PostgreSQL database instances.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -782,7 +827,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "Ensure that the cloudsql.enable_pgaudit database flag is set to on for Cloud SQL PostgreSQL instances to enable centralized logging and auditing.", "AdditionalInformation": "Enabling the pgaudit extension provides detailed session and object-level logging, which helps organizations comply with security standards such as government, financial, and ISO regulations. This logging capability enhances threat detection by monitoring security events on the database instance. Additionally, enabling this flag allows logs to be sent to Google Logs Explorer for centralized access and monitoring. This recommendation applies specifically to PostgreSQL database instances.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -799,7 +845,8 @@ "SubSection": "3.2 Retention", "AttributeDescription": "Enabling retention policies on log storage buckets prevents logs from being overwritten or accidentally deleted. It is recommended to configure retention policies and enable Bucket Lock for all storage buckets used as log sinks.", "AdditionalInformation": "Cloud Logging allows logs to be exported to storage buckets through sinks. Without a retention policy, logs can be altered or deleted, making it difficult to perform security investigations or comply with audit requirements. To ensure logs remain intact for forensics and security analysis: 1.Set a retention policy on log storage buckets to prevent early deletion. 2.Enable Bucket Lock to make the policy immutable, ensuring logs cannot be altered even by privileged users. 3.Apply appropriate access controls to protect logs from unauthorized access. By implementing retention policies and Bucket Lock, organizations preserve critical security logs, prevent attackers from covering their tracks, and enhance compliance with security regulations.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -816,7 +863,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Google Cloud Asset Inventory provides a historical view of GCP resources and IAM policies using a time-series database. It captures metadata on cloud resources, policy configurations, and runtime data. Enabling Cloud Asset Inventory allows for efficient searching and exporting of asset data.", "AdditionalInformation": "Cloud Asset Inventory enhances security analysis, resource change tracking, and compliance auditing by maintaining a detailed history of GCP resources and their configurations. Enabling it across all GCP projects ensures visibility into changes, helping organizations detect misconfigurations, track policy changes, and strengthen security posture.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -833,7 +881,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "GCP Access Approval allows organizations to require explicit approval before Google support personnel can access their projects. Administrators can assign security roles in IAM to specific users who can review and approve these requests. Notifications of access requests, including the requesting Google employees details, are sent via email or Pub/Sub messages, providing transparency and control.", "AdditionalInformation": "Managing who accesses your organizations data is critical for information security. While Google support may require access for troubleshooting, Access Approval ensures that access is only granted when explicitly authorized. This feature adds an additional layer of security and logging, ensuring that only approved Google personnel can access sensitive information.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -850,7 +899,8 @@ "SubSection": "4.1 In-Transit", "AttributeDescription": "Cloud DNS provides a scalable and reliable domain name system (DNS) service. Domain Name System Security Extensions (DNSSEC) enhance DNS security by protecting domains against DNS hijacking, man-in-the-middle attacks, and other threats.", "AdditionalInformation": "DNSSEC cryptographically signs DNS records, ensuring the integrity and authenticity of DNS responses. Without DNSSEC, attackers can manipulate DNS lookups, redirecting users to malicious websites through DNS hijacking or spoofing attacks. Enabling DNSSEC helps prevent unauthorized modifications to DNS records, reducing the risk of phishing, malware distribution, and other cyber threats.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -867,7 +917,8 @@ "SubSection": "4.1 In-Transit", "AttributeDescription": "Require all incoming connections to SQL database instances to use SSL encryption.", "AdditionalInformation": "Unencrypted SQL database connections are vulnerable to man-in-the-middle (MITM) attacks, which can expose sensitive data such as credentials, queries, and results. Enforcing SSL ensures secure communication by encrypting data in transit, protecting against interception and unauthorized access. This recommendation applies to PostgreSQL, MySQL (Generation 1 and 2), and SQL Server 2017 instances.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -884,7 +935,8 @@ "SubSection": "4.1 In-Transit", "AttributeDescription": "DNSSEC (Domain Name System Security Extensions) relies on cryptographic algorithms to ensure the integrity and authenticity of DNS responses. It is important to use strong and recommended algorithms for key signing to maintain robust security. SHA-1 is deprecated and requires explicit approval from Google if used.", "AdditionalInformation": "DNSSEC signing algorithms play a critical role in securing DNS transactions. Using weak or outdated algorithms can expose DNS infrastructure to spoofing, hijacking, and other attacks. Organizations should select recommended and secure algorithms when enabling DNSSEC to protect DNS records from unauthorized modifications. If adjustments to DNSSEC settings are required, DNSSEC must be disabled and re-enabled with the updated configurations.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -901,7 +953,8 @@ "SubSection": "4.1 In-Transit", "AttributeDescription": "DNSSEC (Domain Name System Security Extensions) enhances DNS security by using cryptographic algorithms for zone signing and transaction security. It is essential to use strong and recommended algorithms for key signing. SHA-1 has been deprecated and requires Googles explicit approval and a support contract if used.", "AdditionalInformation": "Using weak or outdated cryptographic algorithms compromises DNS integrity and exposes systems to threats like spoofing and hijacking. Organizations should ensure that DNSSEC settings use strong, recommended algorithms. If DNSSEC is already enabled and changes are needed, it must be disabled and re-enabled with updated configurations to apply the changes effectively.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -918,7 +971,8 @@ "SubSection": "4.2 At-Rest", "AttributeDescription": "Customer-Supplied Encryption Keys (CSEK) is a feature available in Google Cloud Storage and Google Compute Engine, allowing users to supply their own encryption keys. When you provide your key, Google uses it to protect the Google-generated keys that are responsible for encrypting and decrypting your data. By default, Google Compute Engine encrypts all data at rest automatically, managing this encryption for you with no additional action required. However, if you wish to have full control over the encryption process, you can choose to supply your own encryption keys.", "AdditionalInformation": "By default, Compute Engine automatically encrypts all data at rest, with the service managing the encryption without any further input required from you or your application. However, if you require complete control over encryption, you have the option to provide your own encryption keys to manage the encryption of instance disks.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -935,7 +989,8 @@ "SubSection": "4.2 At-Rest", "AttributeDescription": "When using Dataproc, the data associated with clusters and jobs is stored on Persistent Disks (PDs) linked to the Compute Engine VMs in your cluster and in a Cloud Storage staging bucket. This data is encrypted using a Google-generated Data Encryption Key (DEK) and a Key Encryption Key (KEK). The Customer-Managed Encryption Keys (CMEK) feature allows you to create, use, and revoke the KEK, although Google still controls the DEK used to encrypt the data.", "AdditionalInformation": "Dataproc cluster data is encrypted using Google-managed keys: the Data Encryption Key (DEK) and the Key Encryption Key (KEK). If you wish to have control over the encryption of your cluster data, you can use your own Customer-Managed Keys (CMKs). Cloud KMS Customer-Managed Keys can add an extra layer of security and are commonly used in environments with strict compliance and security requirements.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -952,7 +1007,8 @@ "SubSection": "4.2 At-Rest", "AttributeDescription": "Ensure that BigQuery datasets are encrypted using Customer-Managed Keys (CMKs) to gain more granular control over the data encryption and decryption process.", "AdditionalInformation": "For enhanced control over encryption, Customer-Managed Encryption Keys (CMEK) can be implemented as a key management solution for BigQuery datasets.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -969,7 +1025,8 @@ "SubSection": "4.2 At-Rest", "AttributeDescription": "Ensure that BigQuery tables are encrypted using Customer-Managed Keys (CMKs) for more granular control over the data encryption and decryption process.", "AdditionalInformation": "For greater control over encryption, Customer-Managed Encryption Keys (CMEK) can be utilized as the key management solution for BigQuery tables.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] } diff --git a/prowler/compliance/kubernetes/pci_4.0_kubernetes.json b/prowler/compliance/kubernetes/pci_4.0_kubernetes.json index 4dde914458..5ae65347d1 100644 --- a/prowler/compliance/kubernetes/pci_4.0_kubernetes.json +++ b/prowler/compliance/kubernetes/pci_4.0_kubernetes.json @@ -1,7 +1,7 @@ { "Framework": "PCI", "Version": "4.0", - "Provider": "Core", + "Provider": "Kubernetes", "Description": "The Payment Card Industry Data Security Standard (PCI DSS) is a proprietary information security standard. It's administered by the PCI Security Standards Council, which was founded by American Express, Discover Financial Services, JCB International, MasterCard Worldwide, and Visa Inc. PCI DSS applies to entities that store, process, or transmit cardholder data (CHD) or sensitive authentication data (SAD). This includes, but isn't limited to, merchants, processors, acquirers, issuers, and service providers. The PCI DSS is mandated by the card brands and administered by the Payment Card Industry Security Standards Council.", "Requirements": [ { diff --git a/prowler/compliance/m365/prowler_threatscore_m365.json b/prowler/compliance/m365/prowler_threatscore_m365.json index 24d100064d..ace3eee2a6 100644 --- a/prowler/compliance/m365/prowler_threatscore_m365.json +++ b/prowler/compliance/m365/prowler_threatscore_m365.json @@ -17,7 +17,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "Microsoft cloud-only accounts are governed by a built-in password policy that cannot be customized. The only configurable options are the password expiration period and whether password expiration is enabled at all.", "AdditionalInformation": "Modern security guidance from organizations like NIST and Microsoft recommends against forcing regular password changes unless there is a known compromise or the user has forgotten the password. Arbitrary password expiration policies can lead to weaker password practices, such as predictable patterns or reused credentials. This is especially relevant even in single-factor (password-only) scenarios. When combined with strong security measures like Multi-Factor Authentication (MFA) and Entra ID password protection, the need for periodic password changes becomes less critical. As such, it’s more effective to focus on strengthening overall authentication practices rather than enforcing frequent password resets.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -34,7 +35,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "Multifactor Authentication (MFA) enhances account security by requiring users to provide at least two forms of identity verification during sign-in—such as a password and a one-time code from a mobile device, biometric scan, or authentication app. It is critical to ensure that all users in administrator roles have MFA enabled to protect privileged access.", "AdditionalInformation": "MFA significantly reduces the risk of unauthorized access by requiring attackers to compromise multiple independent authentication factors. For administrative accounts—often targeted due to their elevated privileges—this additional layer of security is essential. Enforcing MFA for admins helps ensure that only authorized individuals can access sensitive systems and configurations, thereby strengthening the overall security posture of the organization.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -51,7 +53,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "Enable Multifactor Authentication (MFA) for all users in the Microsoft 365 tenant to strengthen identity security. Once enabled, users will be prompted to verify their identity using a second factor during sign-in. Common second factors include a one-time code sent via SMS or generated through an authentication app such as Microsoft Authenticator.", "AdditionalInformation": "MFA adds a critical layer of protection by requiring users to provide two or more independent forms of authentication before access is granted. This significantly reduces the likelihood of unauthorized access, as an attacker would need to compromise both the primary credentials and the second authentication factor. Enabling MFA across all user accounts helps protect the organization from phishing, credential theft, and other identity-based threats.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -68,7 +71,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "Microsoft Entra ID supports a variety of authentication and authorization protocols, including legacy authentication methods. Legacy authentication typically refers to Basic Authentication, which prompts users to submit a username and password without support for modern security features like Multifactor Authentication (MFA). Several messaging and connection protocols fall under legacy authentication, including: • Authenticated SMTP – Sends authenticated email messages. • Autodiscover – Helps Outlook and Exchange ActiveSync (EAS) clients locate mailboxes. • Exchange ActiveSync (EAS) – Connects mobile devices to Exchange Online. • Exchange Online PowerShell – Requires the Exchange Online PowerShell Module when Basic Auth is blocked. • Exchange Web Services (EWS) – Used by Outlook, Outlook for Mac, and third-party applications. • IMAP4 and POP3 – Used by legacy email clients. • MAPI over HTTP (MAPI/HTTP) – Primary protocol for Outlook 2010 SP2 and newer. • Offline Address Book (OAB) – Downloads address lists for Outlook. • Outlook Anywhere (RPC over HTTP) – Legacy access method for Outlook. • Reporting Web Services – Retrieves reporting data from Exchange Online.•Universal Outlook – Used by the Windows 10 Mail and Calendar app. • Other clients – Protocols identified as using legacy authentication patterns.", "AdditionalInformation": "Legacy authentication protocols do not support multifactor authentication, making them a common attack vector for credential theft and brute-force attacks. Blocking legacy authentication significantly reduces the organization’s attack surface and helps enforce modern, more secure sign-in methods that support MFA.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -85,7 +89,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "Authentication Strength is a Conditional Access (CA) control in Microsoft Entra ID that allows administrators to define which authentication methods are permitted for accessing specific resources. This enables a tailored approach to security—stronger methods can be enforced for sensitive assets, while less secure methods may be acceptable for lower-risk scenarios. Microsoft provides three built-in authentication strength levels: • MFA Strength • Passwordless MFA Strength • Phishing-resistant MFA Strength It is recommended that all users in administrator roles are protected by a Conditional Access policy that enforces Phishing-resistant MFA Strength. Administrators can meet this requirement by registering and using one of the following phishing-resistant authentication methods: • FIDO2 Security Key • Windows Hello for Business • Certificate-based Authentication (CBA) Note: Configuration steps for these methods (e.g., setting up FIDO2 keys) are not covered here but are available in Microsoft’s documentation. The Conditional Access policy only enforces that at least one of these methods is used. Warning: Ensure that administrators are pre-registered for one of the supported strong authentication methods before enforcing the policy. As also recommended elsewhere in the CIS Benchmark, a break-glass account should be excluded from this policy to maintain emergency access.", "AdditionalInformation": "As MFA adoption increases, so does the sophistication of attacks designed to bypass it. Phishing-resistant authentication methods are more secure because they eliminate passwords from the authentication process. These methods rely on strong public/private key cryptography and ensure that authentication can only occur between trusted devices and providers—preventing login attempts from fake or phishing websites.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -102,7 +107,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "Conditional Access (CA) policies can be configured to enforce access controls based on whether a device is compliant or Microsoft Entra hybrid joined. These conditions allow organizations to distinguish between managed and unmanaged devices, enabling more granular enforcement of authentication policies. • The Require device to be marked as compliant control ensures that devices meet the compliance standards defined in Intune compliance policies. Devices must first be enrolled in Intune Mobile Device Management (MDM) before these policies can be evaluated. • The Require Microsoft Entra hybrid joined device control applies to devices synchronized from an on-premises Active Directory environment, marking them as trusted within the hybrid identity model. When both conditions are included in the same Conditional Access policy, the evaluation functions as an OR logic—only one of the two conditions needs to be met for the user to authenticate successfully from a device. Recommended configuration: • Require device to be marked as compliant • Require Microsoft Entra hybrid joined device • Require one of the selected controls", "AdditionalInformation": "Managed devices are generally more secure due to enforced configurations such as Group Policy, mobile device compliance policies, endpoint detection and response (EDR), managed patching, and centralized alerting. Limiting access to only compliant or hybrid joined devices ensures that users are authenticating from secure environments. This policy helps mitigate the risk of compromised credentials by requiring attackers to first obtain access to a trusted device. When combined with additional CA controls—such as multi-factor authentication—it adds a further barrier to unauthorized access and strengthens the organization’s overall security posture.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -119,7 +125,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "Conditional Access (CA) policies can be used to restrict the registration of multi-factor authentication (MFA) methods based on a device’s compliance status or whether it is Microsoft Entra hybrid joined. This allows organizations to enforce that only managed devices are used when users register security information.• Require device to be marked as compliant enforces that the device meets all conditions defined in Intune compliance policies. Devices must be enrolled in Intune Mobile Device Management (MDM) for this to apply. • Require Microsoft Entra hybrid joined device ensures the device has been synchronized from an on-premises Active Directory, marking it as trusted within the hybrid identity environment. When both controls are included in a Conditional Access policy for MFA registration, they operate with OR logic—only one of the conditions must be satisfied for the user to proceed. Recommended configuration: Restrict the “Register security information” operation to devices that are either compliant or Microsoft Entra hybrid joined.", "AdditionalInformation": "Restricting MFA registration to trusted, managed devices significantly reduces the risk of attackers using stolen credentials to set up fraudulent authentication methods. Accounts that exist but are not yet registered for MFA are particularly vulnerable to takeover. This policy ensures that security information is registered only from secured, policy-enforced endpoints—which often include additional layers of protection such as endpoint detection, encryption, and monitoring—thereby reducing the attack surface and strengthening the organization’s identity security posture.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -136,7 +143,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "Modern authentication in Microsoft 365 enables advanced authentication capabilities such as multifactor authentication (MFA), smart card support, certificate-based authentication (CBA), and integration with third-party SAML identity providers. It replaces legacy authentication protocols with more secure, token-based authentication methods. It is recommended to enforce modern authentication for SharePoint applications to ensure secure access.", "AdditionalInformation": "If SharePoint applications are allowed to use basic authentication, they may bypass strong authentication controls such as MFA, exposing the environment to potential compromise. Enforcing modern authentication ensures that all sessions between users, applications, and SharePoint utilize robust, policy-enforced authentication methods—significantly reducing the risk of credential theft and unauthorized access.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -153,7 +161,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "SharePoint Online allows users to share files, folders, and entire site collections both internally and externally. With appropriate permissions, internal users can extend access to external collaborators, enabling seamless cross-organizational collaboration.", "AdditionalInformation": "While external sharing supports productivity and collaboration, it’s essential that owners of files, folders, or site collections retain control over what content is shared and with whom. This helps prevent unauthorized data disclosure and ensures that sensitive information is only accessible to intended recipients. Proper sharing governance empowers data owners to make informed decisions and reinforces accountability across the organization.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -170,7 +179,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "The “Multi-Factor Authentication (MFA) Status” setting determines whether users are required to authenticate using a second factor beyond their password. For privileged users—those with administrative roles or elevated permissions—this setting should be set to “Enabled” to ensure that MFA is enforced whenever they sign in.", "AdditionalInformation": "Privileged accounts have access to critical systems, sensitive data, and administrative functions that, if compromised, could lead to significant security breaches. Enforcing MFA for all privileged users greatly reduces the risk of unauthorized access by requiring attackers to compromise two or more independent authentication factors. MFA is one of the most effective defenses against phishing, credential theft, and brute-force attacks, making it a foundational control for protecting administrative accounts in any secure identity and access management strategy.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -187,7 +197,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "The “Multi-Factor Authentication (MFA) Status” setting determines whether users must verify their identity using a second factor in addition to their password. For non-privileged users—those without administrative or elevated permissions—it is recommended that MFA is enabled across the entire user base to provide comprehensive protection against identity-based attacks.", "AdditionalInformation": "While non-privileged users may not have administrative access, they still have access to email, internal systems, and potentially sensitive business data. These accounts are often targeted in phishing campaigns, credential stuffing attacks, and social engineering tactics to gain an initial foothold in the organization. Enforcing MFA for all users significantly reduces the likelihood of account compromise by requiring a second form of verification, such as a mobile app, hardware token, or one-time passcode. This broad protection is essential in a Zero Trust security model and ensures that every account—regardless of privilege—is secured against unauthorized access.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -204,7 +215,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "The External Callouts feature in Exchange Online introduces a native visual indicator for emails originating from outside the organization. When enabled, this feature displays a localized “External” tag within supported Outlook clients, along with additional user interface elements at the top of the message reading pane. These enhancements help users easily identify and verify the actual sender’s email address, providing critical context when evaluating incoming messages. The feature is enabled via PowerShell using the Set-ExternalInOutlook cmdlet, and typically becomes visible to end users within 24–48 hours, provided their Outlook client version supports the functionality. Note: While Exchange administrators have historically used mail flow rules to prepend “[External]” or similar text to subject lines, this method is less reliable and may not consistently apply across all message types or clients. The CIS Benchmark recommends enabling the native External tagging feature for a more consistent and secure user experience.", "AdditionalInformation": "Tagging emails from external senders increases user awareness and vigilance, enabling recipients to recognize messages that originate outside the organization’s trusted environment. This visual cue acts as a simple but effective layer of defense, encouraging users to treat unexpected or suspicious emails with caution—especially those that may be phishing attempts, impersonation attacks, or social engineering lures. By clearly marking external messages, organizations enhance their users’ ability to make informed security decisions, reducing the likelihood of credential compromise, malware infection, or inadvertent data disclosure.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -221,7 +233,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "Modern authentication in Microsoft 365 enables advanced authentication capabilities such as multi-factor authentication (MFA), smart card-based login, certificate-based authentication (CBA), and integration with third-party SAML identity providers. When enabled for Exchange Online, clients like Outlook 2016 and Outlook 2013 utilize modern authentication protocols (such as OAuth 2.0) to securely connect to Microsoft 365 mailboxes. If modern authentication is disabled, these clients fall back to basic authentication, a legacy protocol that transmits credentials in plaintext and lacks support for MFA. Newer clients—including Outlook for Mac 2016, Outlook Mobile, and all Microsoft 365 Apps for Enterprise versions of Outlook—are built to use modern authentication by default.", "AdditionalInformation": "Allowing basic authentication significantly weakens the security posture of an organization. It bypasses modern controls like multi-factor authentication, exposing user credentials to a higher risk of compromise through phishing, brute-force attacks, or session hijacking. By enabling modern authentication in Exchange Online, organizations enforce the use of strong, token-based authentication methods that are resistant to credential theft and session replay. This is critical for protecting sensitive email data and ensuring secure communication between user devices and Microsoft 365 services. Enabling modern authentication also supports compliance mandates and zero-trust principles, making it a foundational step in securing user identities and email infrastructure.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -238,7 +251,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "This policy setting in Microsoft Teams controls who is allowed to bypass the meeting lobby and directly join a meeting. When properly configured, only explicitly invited attendees—either those directly invited by the organizer or individuals to whom the invitation was intentionally forwarded—can skip the lobby and enter the meeting. All other participants, including anonymous users or those with access to the meeting link but not explicitly invited, must wait in the lobby for approval by the meeting organizer or a designated participant.", "AdditionalInformation": "For meetings involving sensitive, confidential, or regulated information, it is essential to tightly control participant access. Requiring all non-invited individuals to wait in the lobby allows the organizer to review and manually admit attendees, thereby preventing unauthorized access or accidental exposure of sensitive content. Additionally, this setting prevents misuse of the meeting link by anonymous or unintended users, such as initiating unauthorized meetings outside scheduled times. Even organizations that do not regularly operate in high-security (Level 2) environments but occasionally handle sensitive data should consider enabling this policy to reinforce data protection and meeting integrity. By limiting automatic entry to only verified, intended participants, this control supports secure collaboration, reduces risk of information leakage, and enhances confidence in Microsoft Teams as a platform for sensitive communications.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -255,7 +269,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "This policy setting in Microsoft Teams determines whether anonymous participants can start a meeting before a verified user from the organization or a trusted external organization has joined. When this setting is enabled, anonymous users and dial-in callers must wait in the meeting lobby until the meeting is initiated by an authenticated participant. Anonymous participants are defined as: • Users not signed in with a work or school account • Participants from non-trusted organizations, based on external access configuration • Individuals from organizations without mutual trust relationships Note: This setting only applies when the “Who can bypass the lobby” policy is set to Everyone. If the broader setting “Anonymous users can join a meeting” is disabled at the organizational level, this policy applies only to dial-in callers.", "AdditionalInformation": "Disallowing anonymous participants from starting meetings helps mitigate the risk of meeting abuse, such as spamming, hijacking, or unauthorized use of Teams meetings for unintended purposes. Anonymous users pose a higher risk because their identities cannot be verified, and they are not subject to organizational controls or compliance policies. Requiring an authenticated user to start the meeting ensures that someone with verified access and accountability is present before the session begins. This adds a layer of security and governance, especially in meetings that could involve sensitive discussions or are exposed to a wide range of external participants. Enforcing this policy supports a secure and controlled meeting environment and aligns with best practices for preventing unauthorized or disruptive activity in collaborative platforms.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -272,7 +287,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "This policy setting in Microsoft Teams defines who can bypass the meeting lobby and join a meeting directly, versus who must wait in the lobby until admitted by a meeting organizer, co-organizer, or designated presenter. Options include allowing access to everyone, people in your organization, trusted external organizations, or only invited users.", "AdditionalInformation": "Restricting direct access to meetings—particularly those that involve sensitive, confidential, or regulated information—ensures that only authorized and expected attendees can participate. Requiring participants to wait in the lobby gives meeting organizers the opportunity to vet and approve each attendee before admitting them. This policy also helps prevent unauthorized access through forwarded meeting links and reduces the risk of anonymous users joining meetings at unscheduled times, which can lead to disruptions or even security breaches. Enforcing lobby controls aligns with zero trust principles and is a best practice for maintaining the integrity and confidentiality of Teams meetings.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -289,7 +305,8 @@ "SubSection": "1.1 Authentication", "AttributeDescription": "This policy setting in Microsoft Teams determines whether dial-in participants—users who join meetings by phone—can bypass the lobby and join directly, or if they must wait in the lobby until admitted by a meeting organizer, co-organizer, or presenter.", "AdditionalInformation": "Dial-in participants typically cannot be authenticated in the same way as users joining via Teams apps or web clients, making it more difficult to verify their identity. For meetings that may involve sensitive, confidential, or regulated information, it is essential that the meeting organizer has the opportunity to manually vet and admit these participants.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -306,7 +323,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "Microsoft 365 Groups serve as the foundation for collaboration across Microsoft 365, providing shared resources (e.g., Outlook inbox, SharePoint site, Teams workspace) to group members. While various group types exist, this recommendation specifically addresses Microsoft 365 Groups. By default, when a Microsoft 365 Group is created via the admin panel, its privacy setting is set to “Public”, meaning anyone in the organization can access its content unless the setting is manually changed.", "AdditionalInformation": "To protect sensitive organizational data, it’s important to ensure that only authorized and managed public groups exist. Public groups expose their content to all users in the organization through several access paths: • Users can add themselves to a public group using the Azure portal. • Users can request access via the Access Panel’s Groups app—this sends a request to the group owner but still grants immediate access. • Users may discover and directly access the associated SharePoint site via a guessable or easily discoverable URL.While admins are notified when Azure Portal access is used, other methods may not generate alerts. If group privacy settings are not properly managed, sensitive data could be inadvertently exposed. For this reason, privacy settings should be reviewed and adjusted to Private by default unless a public setting is explicitly required and approved.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -323,7 +341,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "A dynamic group in Microsoft Entra ID automatically manages group membership based on user attributes such as userType, department, or country/region. Administrators can define rules to ensure that users meeting specific criteria are added to—or removed from—a group without manual intervention. The recommended configuration is to create a dynamic group that specifically includes guest accounts.", "AdditionalInformation": "Dynamic groups streamline user management by automating group assignments. By including guest users in a dynamic group, organizations can consistently apply existing Conditional Access policies, access controls, and other security measures. This ensures that new guest accounts are governed by the same security standards as existing ones, reducing the risk of misconfiguration or oversight.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -340,7 +359,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "The admin consent workflow provides a secure and controlled process for granting access to applications that require administrator approval. When a user attempts to access an application but lacks permission to grant consent, they can submit a request for review. This request is sent via email to designated administrators, who act as reviewers. Once a decision is made, the user is notified of the outcome.", "AdditionalInformation": "The admin consent workflow (Preview) enhances security by ensuring that access to sensitive applications is reviewed and approved by authorized administrators. It prevents users from unintentionally granting permissions to potentially risky applications while maintaining a clear approval process with full visibility and accountability.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -357,7 +377,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "Microsoft Entra ID Protection uses user risk policies to evaluate the likelihood that a user account has been compromised. These policies assign a risk level (low, medium, or high) based on detected anomalies, such as unfamiliar sign-ins, leaked credentials, or atypical behavior. Note: While Entra ID Protection includes built-in user risk policies, Microsoft strongly recommends implementing risk-based Conditional Access (CA) policies instead of relying on the older, legacy policy model. The modern CA approach offers several key advantages:• Access to enhanced diagnostic and troubleshooting data • Integration with report-only mode for safe testing • Support for automation via Microsoft Graph API • Greater flexibility through advanced Conditional Access attributes, such as sign-in frequency and session controls", "AdditionalInformation": "Enabling user risk policies through Conditional Access allows organizations to automatically respond to suspected account compromise by enforcing real-time controls—such as blocking access or requiring secure reauthentication. This proactive approach enhances the organization’s ability to detect and mitigate identity-based threats before they escalate.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -374,7 +395,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "Microsoft Entra ID Protection evaluates sign-in risk by detecting potentially suspicious sign-in attempts—both in real time and through offline analysis. A risky sign-in indicates that the attempt may not have been performed by the legitimate account owner, based on signals such as unusual location, device anomalies, or unfamiliar sign-in behavior. Note: Although Microsoft Entra ID Protection includes built-in sign-in risk policies, it is strongly recommended to implement risk-based policies using Conditional Access instead of relying on legacy risk policies. The Conditional Access method provides several key advantages: • Access to enhanced diagnostic and investigation data • Ability to test with report-only mode • Integration with Microsoft Graph API for automation and management • Use of additional CA attributes such as sign-in frequency and session controls", "AdditionalInformation": "Enabling a sign-in risk Conditional Access policy allows organizations to automatically challenge suspicious sign-ins with multi-factor authentication (MFA). This reduces the likelihood of unauthorized access by requiring an additional verification step whenever unusual activity is detected, strengthening identity protection and reducing the risk of account compromise.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -391,7 +413,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "External sharing settings in Microsoft 365 govern how content is shared outside the organization. While each SharePoint site can have its own sharing configuration, it must be equal to or more restrictive than the organization-wide setting. The recommended configuration is “New and existing guests” or a more restrictive option. This setting requires external users to either sign in with a Microsoft 365 work or school account, a personal Microsoft account, or verify their identity using a one-time passcode. Users can share content with existing guests in the directory or invite new guests, who will be added to the directory upon sign-in.", "AdditionalInformation": "Requiring guest authentication ensures that external users are registered and identifiable within the organization’s directory. This allows administrators to apply governance controls—such as Conditional Access policies, group-based restrictions, and activity monitoring—to external identities. By enforcing authenticated sharing, organizations maintain visibility and control over externally shared resources, reducing the risk of unauthorized data access and supporting compliance with security and privacy policies.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -408,7 +431,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "Organizations can control how documents are shared externally by configuring domain-based restrictions. This can be done by either blocking specific external domains or allowing sharing only with a defined list of trusted domains. These settings apply to services like SharePoint and OneDrive to help manage external collaboration securely.", "AdditionalInformation": "Restricting document sharing to approved domains reduces the risk of accidental or malicious data exposure. Attackers may attempt to exfiltrate sensitive information by sharing it with external entities. By limiting sharing to trusted domains, organizations minimize their external attack surface and maintain greater control over data flow.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -425,7 +449,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "Anti-spam protection in Exchange Online leverages configurable policies to reduce the volume of unwanted emails—such as junk, bulk, and phishing messages—received by users. These policies include several configurable lists that influence how email from specific sources is treated:• Allowed Senders List • Allowed Domains List • Blocked Senders List • Blocked Domains List While these features offer flexibility, it is strongly recommended not to define any entries in the Allowed Domains List in a production environment.", "AdditionalInformation": "When a sender or domain is added to the Allowed Domains List, their messages bypass key security checks—including spam filtering and authentication mechanisms like SPF, DKIM, and DMARC—unless flagged as containing malware or high-confidence phishing. This introduces a significant security risk, as attackers may exploit these exceptions to deliver malicious emails directly to users’ inboxes. The risk is especially high when common or widely used domains are allow-listed, as these are frequent targets for spoofing attempts. Moreover, Microsoft’s official guidance clearly states that allowed domains should only be used for testing purposes, not for general use in production environments. To maintain a strong email security posture, organizations should avoid defining Allowed Domains and instead rely on more controlled methods such as block lists, quarantine policies, or targeted safe sender configurations for trusted entities.", - "LevelOfRisk": 1 + "LevelOfRisk": 1, + "Weight": 1 } ] }, @@ -443,7 +468,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "Exchange Online provides multiple mechanisms to manage and control the flow of outbound email messages, helping organizations prevent unauthorized data exfiltration. These mechanisms include: • Remote Domain Settings • Transport Rules • Anti-Spam Outbound Policies These tools work in tandem to control and monitor various email forwarding methods that users or attackers may exploit, such as: • Inbox rules configured in Outlook • Automatic forwarding via Out of Office (OOF) rules • Forwarding settings in Outlook Web Access (OWA) using ForwardingSmtpAddress • Admin-defined forwarding in the Exchange Admin Center (EAC) using ForwardingAddress • Automated forwarding using Power Automate / Microsoft Flow To effectively reduce the risk of unauthorized data leaks, organizations should implement both a Transport Rule and an Outbound Anti-Spam Policy to block automatic mail forwarding. Note: If any exclusions are required (e.g., for trusted third-party systems or compliance tools), they should be strictly defined and approved in accordance with organizational policy.", "AdditionalInformation": "", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -460,7 +486,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "Mail flow rules (also known as transport rules) in Exchange Online allow administrators to inspect, modify, or block email messages as they pass through the organization. These rules can be configured based on a wide range of conditions—such as sender, recipient, subject content, or attachment type—and can enforce actions including message redirection, header modification, or delivery rejection. While transport rules offer powerful control over email behavior, they must be implemented with caution—particularly when it comes to whitelisting domains or bypassing standard filtering mechanisms.", "AdditionalInformation": "Whitelisting external domains through transport rules can disable critical security checks such as anti-malware scanning, phishing detection, and sender authentication (e.g., SPF, DKIM, DMARC). If a trusted domain is later compromised—or was malicious from the start—this bypass can allow attackers to deliver malicious content directly to user inboxes without scrutiny. By avoiding broad or permanent domain whitelisting in transport rules, organizations preserve the integrity of their email filtering and reduce the risk of successful phishing campaigns, malware delivery, or data exfiltration originating from seemingly trusted sources. Transport rules should be reviewed regularly, and any exceptions must be justified, narrowly scoped, and documented according to organizational policy.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -477,7 +504,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "MailTips are real-time, context-aware notifications displayed to users as they compose email messages in Outlook. These tips are generated by Exchange while a message is being drafted and are based on an analysis of the email’s content and recipient list. If Exchange detects potential issues—such as the message being sent to a large distribution group, an external recipient, or someone who is out of office—it presents the user with a MailTip alert before the message is sent.This proactive feedback helps users avoid common issues like sending sensitive information to unintended recipients, triggering non-delivery reports (NDRs), or violating communication policies.", "AdditionalInformation": "Enabling MailTips provides valuable visual cues that promote user awareness and responsible communication. For example, users are warned when they are sending emails to external recipients or large distribution lists, which helps prevent data leakage, unintentional over-sharing, and excessive email traffic. MailTips serve as a lightweight but effective safeguard by nudging users to review recipients and message context before sending, reducing the risk of human error. In regulated or security-conscious environments, this feature reinforces compliance by helping users adhere to organizational communication policies in real time.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -494,7 +522,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "Microsoft Teams channel email addresses are an optional feature that enables users to send emails directly into a Teams channel. When enabled, each channel is assigned a unique email address that users can use to forward messages, share content, or initiate discussions from outside Teams. While this can enhance collaboration by bridging email and Teams-based communication, the generated email addresses are typically not part of the organization’s primary domain, and their usage is subject to broader Microsoft 365 infrastructure settings.", "AdditionalInformation": "Channel email addresses introduce potential security and governance concerns, as they are not managed under the organization’s domain and are exposed to external communication. If an attacker is able to discover or guess a channel’s email address, they could send messages directly into Teams, potentially introducing phishing links, malicious attachments, or inappropriate content into collaborative spaces. Furthermore, since organizations have limited control over the security configurations and exposure of these addresses, they may become a blind spot in security monitoring and email filtering. Disabling or restricting the use of Teams channel email addresses helps reduce the attack surface, prevent unauthorized message injection, and strengthen the overall security posture of Microsoft Teams.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -511,7 +540,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "This policy governs how external access is managed in Microsoft Teams, specifically determining whether users in your organization can communicate and collaborate with individuals from external domains. Administrators can configure this setting to: • Allow communication with all external domains • Block all external domains • Allow only specific (approved) external domains using an allowlist When external access is enabled, users can chat, invite external participants to meetings, and use audio/video conferencing with users in other Microsoft 365 or federated organizations. Recommended Configuration: To reduce exposure, it is recommended to either allow only specific external domains with whom collaboration is necessary or block all external domains entirely.", "AdditionalInformation": "While external collaboration can be valuable, unrestricted access to external domains introduces significant security risks. Without proper controls, users may inadvertently engage with untrusted or malicious entities, opening the door to phishing, social engineering, malware delivery, or data exfiltration. Notable threats that have leveraged Teams’ external access features include: • DarkGate malware distributed through malicious Teams messages • Phishing and impersonation campaigns by actors like Midnight Blizzard (APT29) • GIFShell, a technique for covert communication using GIFs within Teams • Username enumeration, allowing attackers to confirm the existence of user accounts By allowlisting only trusted domains, organizations retain the benefits of external collaboration while maintaining tight control over who can interact with internal users. This aligns with zero trust principles and helps ensure that external communication is both intentional and secure.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -528,7 +558,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "This policy setting in Microsoft Teams controls whether users in your organization can chat or join meetings with external Teams users who are not affiliated with a managed organization—for example, users of Microsoft Teams (free) or those without an associated Microsoft Entra ID (formerly Azure AD) tenant. These unmanaged accounts operate outside of enterprise governance and lack the administrative oversight, compliance enforcement, and security controls typically applied in organizational environments. Recommended Configuration: Set the policy to “Off” for “People in my organization can communicate with Teams users whose accounts aren’t managed by an organization” to block communication with unmanaged Teams users.", "AdditionalInformation": "Allowing communication with unmanaged external Teams users introduces a significant security risk. Since anyone can register for a free Teams account, attackers can easily create unmanaged identities and attempt to initiate contact with internal users. These interactions can be used to deliver malicious content, perform social engineering, or carry out reconnaissance. Documented attacks exploiting this communication channel include: • DarkGate malware delivery via malicious messages • Phishing and impersonation campaigns attributed to Midnight Blizzard (APT29) • GIFShell, a technique allowing covert exfiltration via GIFs in Teams chats • Username enumeration, enabling attackers to identify valid users in an organization Disabling communication with unmanaged Teams users helps enforce a zero trust posture, ensuring that all external interactions occur only with verified and trusted organizations under enforceable security policies. This reduces the organization’s exposure to external threats, protects sensitive communications, and upholds compliance standards.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -545,7 +576,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "This policy setting in Microsoft Teams controls whether external users with unmanaged Teams accounts—such as those using Microsoft Teams (free)—can initiate conversations with users in your organization. These unmanaged users do not belong to a verified Microsoft Entra (Azure AD) tenant and are not subject to organizational controls or governance. Recommended Configuration: Uncheck the option “External users with Teams accounts not managed by an organization can contact users in my organization” to prevent these users from initiating communication. This setting is designed as an additional safeguard to complement the broader policy that disables communication with unmanaged Teams users entirely. In scenarios where an organization allows limited interaction with such users, this control ensures that only internal users can initiate communication, further reducing exposure to unsolicited or malicious contact attempts.", "AdditionalInformation": "Enabling unmanaged Teams users to initiate contact with internal users poses a significant security risk, as anyone can easily register for a free Teams account with minimal identity verification. Threat actors can exploit this feature to deliver malicious content, impersonate legitimate contacts, or conduct reconnaissance by probing user availability and behavior. Notable real-world threats facilitated through external Teams access include:• DarkGate malware delivered via malicious chats • Social engineering and phishing campaigns by advanced threat actors such as Midnight Blizzard (APT29) • GIFShell, a covert data exfiltration method using GIFs in Teams • Username enumeration, enabling discovery of valid user accounts within an organization By preventing unmanaged external users from initiating conversations, organizations can better protect their internal users from unsolicited and potentially harmful contact attempts. This policy reinforces a defense-in-depth strategy, ensuring that even in exceptional cases where limited unmanaged communication is permitted, external contact remains tightly controlled and monitored.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -562,7 +594,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "This policy setting in Microsoft Teams controls who can read and write messages in the meeting chat. It allows administrators or meeting organizers to specify whether chat is available to everyone, only specific roles (such as presenters), or is disabled entirely for participants. This setting applies to chat interactions during the meeting and helps manage the flow and visibility of information shared in the chat pane.", "AdditionalInformation": "Limiting chat access to only authorized participants helps prevent the unintended disclosure of sensitive information and reduces the risk of inappropriate or disruptive content being shared during a meeting. In meetings involving confidential topics or external participants, restricting chat can safeguard against data leakage and maintain focus on the meeting agenda.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -579,7 +612,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "This policy setting in Microsoft Teams determines who is allowed to present content during a meeting. Presenters have elevated permissions that allow them to share their screen, display files, manage participants, and control other collaborative features. This setting can be configured at the organizational or meeting level to allow only organizers, co-organizers, or a designated group of participants to present.", "AdditionalInformation": "Restricting presentation privileges to authorized individuals helps ensure that only trusted participants can share content with the group. This minimizes the risk of inappropriate, disruptive, or unapproved material being displayed, whether intentionally or accidentally.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -596,7 +630,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "This policy setting in Microsoft Teams provides control over who can present content and who can request control of shared content during a meeting. It enables administrators and meeting organizers to limit these privileges to internal, trusted participants, while restricting or blocking external participants—including guests, external users, and anonymous users—from taking control of the presentation or initiating content sharing.", "AdditionalInformation": "Restricting presentation and control capabilities to authorized, internal participants significantly reduces the risk of accidental or malicious content sharing, interruptions, or abuse of meeting privileges. External participants—including guests, federated users, and anonymous joiners—may not be subject to the same identity verification or policy enforcement as users within the organization.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -613,7 +648,8 @@ "SubSection": "1.2 Authorization", "AttributeDescription": "This policy setting in Microsoft Teams determines whether a user is allowed to initiate the recording of a meeting in progress. When enabled, participants with the appropriate permissions can start recording audio, video, and screen-sharing content during the session.", "AdditionalInformation": "Restricting the ability to start a meeting recording ensures that only authorized individuals—such as organizers, co-organizers, team leads, or designated presenters—can capture meeting content. This is especially important for meetings that involve sensitive, confidential, or regulated information, where inappropriate or unauthorized recording could lead to data exposure, compliance violations, or reputational harm.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -630,7 +666,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "Each tenant should have more than one designated Global Administrator to ensure both accountability and redundancy in case one administrator leaves the organization. However, it’s equally important to limit the total number of Global Administrators to no more than four to reduce the overall security risk. Ideally, Global Administrator accounts should not have any user licenses assigned, limiting their exposure to commonly targeted services.", "AdditionalInformation": "Relying on a single Global Administrator creates a risk of unmonitored malicious activity. On the other hand, having too many Global Administrators increases the likelihood that one of their accounts could be compromised. A balanced approach supports both oversight and security.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -647,7 +684,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "Administrative accounts are privileged identities with elevated access to critical data, user management, and system settings. Assigning a license to these accounts may grant access to various applications, depending on the license type. The recommended practice is to avoid assigning licenses to privileged accounts altogether. If licensing is required—for example, to enable features such as Identity Protection, Privileged Identity Management (PIM), or Conditional Access—only Microsoft Entra ID P1 or P2 licenses should be used, as they do not include access to potentially vulnerable services like email or Teams.", "AdditionalInformation": "Minimizing application access for administrative accounts significantly reduces the attack surface associated with high-privilege identities. Access to tools like mailboxes or collaboration apps increases the risk of exposure to phishing or social engineering attacks. Administrative tasks should be performed using dedicated, unlicensed accounts, while day-to-day activities should be conducted through separate, unprivileged “daily driver” accounts.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -664,7 +702,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "App registration allows users to register custom or third-party applications for use within the organization’s Microsoft Entra ID directory. These applications can request access to organizational data and integrate with various Microsoft 365 services.", "AdditionalInformation": "While there are valid business cases for registering applications, this capability should be restricted to prevent unauthorized or insecure integrations. Attackers can exploit this feature by using compromised accounts to grant persistent access to third-party applications, enabling data exfiltration without needing to maintain direct control of the breached account. App registration should be disabled for standard users unless there is a clear business need and strong security controls—such as app consent policies and review workflows—are in place.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -681,7 +720,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "By default, non-privileged users can create new Microsoft Entra tenants through the “Manage tenant” option in the Entra admin portal. When a user creates a tenant, the action is logged in the Audit Log under the category DirectoryManagement with the activity Create Company. The user who creates the tenant is automatically assigned the Global Administrator role for that tenant. Note that newly created tenants do not inherit any of the organization’s existing security or configuration settings.", "AdditionalInformation": "Allowing unrestricted tenant creation introduces the risk of unauthorized or unmanaged environments, often referred to as shadow IT. These tenants may be mistakenly perceived as part of the organization’s secure infrastructure, leading users to adopt them for business use. This can fragment IT governance, complicate security oversight, and increase the likelihood of data exposure or policy violations. Restricting tenant creation ensures centralized control over the organization’s cloud environment and helps maintain consistent security and compliance standards.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -698,7 +738,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "Organizations can control whether end users and group owners are allowed to grant consent to applications, or whether such requests require administrator review and approval. While allowing user consent can enhance productivity by enabling access to useful apps, it also introduces potential security risks if not properly managed.", "AdditionalInformation": "Attackers often exploit application consent mechanisms by tricking users into authorizing malicious apps, thereby gaining access to sensitive company data. Disabling user consent for future app authorizations helps mitigate this risk by reducing the overall attack surface. When user consent is disabled, any existing consent remains valid, but all future consent requests must be explicitly approved by an administrator—ensuring better oversight and stronger security controls.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -715,7 +756,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "Microsoft Entra ID, as part of the Microsoft Entra suite, allows organizations to control what external guest users can view and access within the directory. By default, guest users are assigned a more limited permission level than internal members, who receive the full set of user permissions. These directory-level permissions apply across Microsoft Entra services, including Microsoft Graph, PowerShell v2, the Azure portal, and the My Apps portal. They also affect Microsoft 365 services that rely on Microsoft 365 Groups for collaboration—such as Outlook, Microsoft Teams, and SharePoint—though they do not override guest-specific settings within Teams or SharePoint. The recommended configuration is to ensure that guest users have limited access to directory properties and group memberships, or an even more restrictive setting.", "AdditionalInformation": "Restricting guest access helps prevent unauthorized enumeration of users and groups within the directory—a common reconnaissance tactic used by attackers during the early stages of a targeted attack (as defined in the Cyber Kill Chain framework). Limiting this visibility reduces the organization’s exposure to potential threats and supports a stronger security posture.", - "LevelOfRisk": 3 + "LevelOfRisk": 3, + "Weight": 10 } ] }, @@ -732,7 +774,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "By default, all users in the organization—including B2B collaboration guest users—can invite external users to collaborate via Microsoft Entra ID. This invitation capability can be broadly enabled or disabled, or it can be restricted to users in specific administrative roles. The recommended configuration is to limit guest invitations to only those users assigned to specific admin roles.", "AdditionalInformation": "Restricting who can invite external guests reduces the risk of unauthorized or unmanaged external access. By limiting this ability to trusted administrative roles, organizations can maintain tighter control over their environment and reduce potential exposure to security threats originating from unvetted accounts.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -749,7 +792,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "In complex environments, organizations may need to place limits on authentication session durations to reduce risk. Microsoft Entra Conditional Access (CA) policies allow organizations to enforce session controls based on user roles, device state, location, and application sensitivity. Common scenarios include: • Access from unmanaged or shared devices • External access to sensitive resources • High-privileged user accounts • Business-critical applications The following configurations are recommended: • Sign-in frequency: Require reauthentication at least every 4 hours for Microsoft 365 E3 tenants, or every 24 hours for E5 tenants using Privileged Identity Management (PIM). • Persistent browser session: Set to Never persistent. Note: These settings can be integrated into the Conditional Access policy that enforces multifactor authentication for users in administrative roles.", "AdditionalInformation": "Limiting the duration of authentication sessions helps prevent long-lived sessions that could be hijacked by attackers. Requiring periodic reauthentication ensures that a session cannot remain active indefinitely. Disabling persistent browser sessions further reduces the risk of drive-by browser attacks and ensures that session cookies are not stored, leaving nothing behind for an attacker to reuse in case of a compromised device.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -766,7 +810,8 @@ "SubSection": "1.3 Privilege Escalation Prevention", "AttributeDescription": "Microsoft OneDrive allows users to sign in with their organizational (cloud tenant) account and sync their OneDrive files—including selected folders or the entire contents of their storage—to a local computer. By default, synchronization is permitted on any device where OneDrive is installed, regardless of whether the device is Microsoft Entra ID Joined, Hybrid Entra ID Joined, or Active Directory Domain Joined. To improve control over where organizational data can be synchronized, it is recommended to restrict OneDrive syncing to only those devices joined to specific, trusted domains by enabling the policy: “Allow syncing only on computers joined to specific domains”, and specifying the appropriate Active Directory (AD) domain GUID(s)", "AdditionalInformation": "Allowing users to sync OneDrive data to unmanaged or personal devices introduces significant risk, as those endpoints may not comply with corporate security policies, lack endpoint protection, or be subject to malicious activity. When organizational data is synchronized to such devices, the organization loses visibility and control over how that data is accessed, shared, or protected. This opens the door to accidental data leaks, intentional misuse, or loss of sensitive information through theft or compromise. Restricting synchronization to verified, domain-joined devices ensures that only endpoints under the organization’s management and monitoring can access and store OneDrive data locally. This approach aligns with zero-trust principles, enforces data governance policies, and significantly reduces the risk of unauthorized access or exfiltration.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -783,7 +828,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "In Microsoft 365 environments—whether using Exchange Online mailboxes or standalone Exchange Online Protection (EOP)—connection filtering policies play a critical role in determining the trustworthiness of incoming email based on the source IP address. The default connection filter policy includes three main components: the IP Allow List, the IP Block List, and a Safe List. These lists influence how email messages are processed before any content filtering occurs. It is recommended that the IP Allow List remains empty or undefined to avoid bypassing essential security checks.", "AdditionalInformation": "Email originating from IP addresses on the Allow List bypasses several key layers of protection, including spam filtering and sender authentication protocols such as SPF, DKIM, and DMARC. Without additional safeguards like mail flow rules, this configuration introduces a significant risk: malicious actors can exploit the Allow List to deliver spoofed or harmful emails directly to users’ inboxes. Maintaining an empty IP Allow List ensures that all messages undergo full evaluation and filtering, reducing the likelihood of malware, phishing attempts, and impersonation attacks reaching end users.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -800,7 +846,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "In Microsoft 365 environments—whether using Exchange Online mailboxes or standalone Exchange Online Protection (EOP)—connection filtering policies are used to evaluate and classify incoming email based on the IP address of the sending server. The default connection filter policy includes three main components: the IP Allow List, the IP Block List, and the Safe List. The Safe List is a Microsoft-managed, dynamically updated set of sender IP addresses that are automatically treated as trusted sources. The recommended configuration is to have the Safe List disabled (set to Off or False) to ensure all incoming mail is properly evaluated by the organization’s email security policies.", "AdditionalInformation": "When the Safe List is enabled, messages from IP addresses on this list bypass key security mechanisms, including spam filtering and sender authentication checks such as SPF, DKIM, and DMARC. Although Microsoft manages this list dynamically, administrators have no visibility or control over which senders are included. As a result, allowing Safe List traffic to skip verification introduces significant risk—malicious actors could exploit this blind spot to deliver spam, phishing, or malware directly to user inboxes. Disabling the Safe List ensures that all messages undergo full inspection, allowing organizations to maintain strict control over the email filtering pipeline and reduce the likelihood of successful compromise.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -817,7 +864,8 @@ "SubSection": "2.1 Network", "AttributeDescription": "The SMTP AUTH (Simple Mail Transfer Protocol Authentication) setting in Exchange Online controls whether authenticated client SMTP submission is enabled at the organization level. This legacy protocol is used primarily by older applications and devices to send email via SMTP using basic authentication. By default, Microsoft recommends disabling SMTP AUTH at the tenant level to enhance security posture. Modern email clients and applications that connect to Microsoft 365 mailboxes no longer require SMTP AUTH and can use more secure, modern authentication methods (such as OAuth 2.0).", "AdditionalInformation": "SMTP AUTH is an outdated and insecure protocol that relies on basic authentication, which transmits credentials in plaintext and lacks support for multifactor authentication. Leaving this protocol enabled increases the risk of credential theft, account compromise, and unauthorized access, especially in environments not protected by additional controls such as Conditional Access or legacy protocol blocking. Disabling SMTP AUTH supports the principle of least functionality by reducing protocol exposure and hardening the email infrastructure against exploitation attempts. This action also aligns with Microsoft’s broader security guidance and helps organizations phase out legacy authentication methods in favor of modern, secure protocols.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -834,7 +882,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "The AdditionalStorageProvidersAvailable setting in Microsoft 365 controls whether users can connect to and open files from third-party storage services while using Outlook on the Web (OWA). When enabled, users may link external services such as Dropbox, Box, Google Drive, Facebook, or OneDrive Personal to access and interact with files directly within the Outlook web interface. Although this can enhance user productivity, it also introduces third-party services that Microsoft does not govern, meaning their terms of use, privacy policies, and security practices are outside the organization’s control. To mitigate potential risks, it is recommended to restrict or disable access to additional storage providers, limiting file access to only trusted organizational sources.", "AdditionalInformation": "Allowing connections to external storage providers from within Outlook on the Web significantly increases the risk of data leakage and malware exposure. Users may inadvertently upload or download sensitive organizational data to or from non-sanctioned storage platforms, where proper security controls and compliance measures may not be in place. Additionally, files retrieved from these services could serve as vectors for malware or phishing payloads, especially if users are unaware of their origin or if access controls on those platforms are weak. Restricting access to third-party storage providers helps enforce data governance policies, reduces the organization’s attack surface, and ensures that sensitive communications and files remain within controlled and monitored environments. This is especially important in industries with regulatory or compliance obligations.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -851,7 +900,8 @@ "SubSection": "2.2 Storage", "AttributeDescription": "Microsoft Teams facilitates collaboration by enabling users to share and access files within chat, meetings, and channels. By default, file sharing in Teams is integrated with SharePoint Online for team channels and OneDrive for Business for private chats. However, the platform also supports third-party cloud storage providers such as Dropbox, Box, and Google Drive, which can be made available within the Teams interface. Administrators have the ability to configure and restrict which external storage providers are accessible to end users. This helps align file-sharing capabilities with organizational data governance and compliance requirements. Note: While Skype for Business was officially deprecated on July 31, 2021, some configuration settings inherited from its infrastructure may still apply for a limited time. Refer to Microsoft’s official documentation for ongoing support timelines.", "AdditionalInformation": "Allowing unrestricted access to third-party cloud storage providers within Microsoft Teams can undermine an organization’s data protection and compliance efforts. Users may unintentionally store or share sensitive information using non-sanctioned platforms that fall outside of the organization’s control, monitoring, or security policies. By restricting file-sharing capabilities to only approved storage providers, organizations can ensure that collaboration remains within trusted ecosystems. This reduces the risk of data leakage, non-compliant data transfers, and unauthorized access, while also reinforcing secure and consistent file management practices across the collaboration environment.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -868,7 +918,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "The Common Attachment Types Filter allows users to block both well-known and custom-defined malicious file types from being attached to email messages.", "AdditionalInformation": "By blocking commonly exploited file types, this filter helps prevent the delivery of malware-laden attachments, reducing the risk of endpoint compromise and broader system infection.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -885,7 +936,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "The Common Attachment Types Filter allows users to block both known and custom-defined malicious file types from being attached to email messages. While Microsoft provides a default policy that blocks 53 high-risk file extensions, organizations can extend this protection by defining their own custom list. This recommendation includes a broader set of 186 potentially dangerous file extensions, offering a more robust safeguard. Although comprehensive, the list is not exhaustive and should be tailored to fit organizational needs.", "AdditionalInformation": "Blocking file types commonly associated with malware helps prevent the delivery of malicious payloads that can compromise hosts, exfiltrate data, or facilitate phishing attacks. By enforcing a strict attachment policy, organizations reduce their exposure to threats delivered through legacy formats, binary executables, and compressed archives. Allow-listing only those file types necessary for business operations and blocking all others is an effective strategy for mitigating risks such as Business Email Compromise (BEC) and enhancing overall email security posture.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -902,7 +954,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "In Exchange Online, administrators have the ability to control who can install and manage Outlook add-ins within the Microsoft 365 environment. By default, end users are allowed to install third-party or custom add-ins directly in their Outlook desktop client, which can access data within the application such as emails, calendar events, and contacts. To enhance security and reduce potential risks, it is recommended to restrict add-in management privileges to a limited set of trusted administrators and users. This can be configured via the Microsoft 365 admin center or PowerShell, providing centralized control over which add-ins are allowed and who can deploy them.", "AdditionalInformation": "Allowing end users to install Outlook add-ins introduces a potential attack surface, especially if the add-ins are vulnerable, poorly maintained, or intentionally malicious. Threat actors can exploit this capability to gain unauthorized access to sensitive mailbox data or to execute malicious code within the Outlook client. By disabling or restricting user-installed add-ins, organizations can significantly reduce the risk of data exfiltration, phishing, and privilege abuse. Managing add-ins centrally ensures that only vetted and trusted integrations are used, aligning with best practices for securing email clients and minimizing exposure to third-party threats.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -919,7 +972,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "This meeting policy setting in Microsoft Teams governs whether users in your organization can read or write messages in meeting chats hosted by external, untrusted organizations. If the external meeting is hosted by an organization that has been explicitly designated as trusted, this restriction does not apply.", "AdditionalInformation": "Allowing unrestricted chat participation in meetings hosted by untrusted external organizations increases the risk of exposure to malicious content, including links, files, or payloads designed to exploit user behavior or application vulnerabilities.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -937,7 +991,8 @@ "SubSection": "2.3 Application", "AttributeDescription": "User reporting settings in Microsoft Teams and Microsoft 365 allow end users to report messages they believe to be malicious or suspicious, enabling quicker response and investigation by security teams. To ensure the reporting feature functions as intended, this recommendation encompasses three distinct but interdependent settings that must all be correctly configured: 1. Teams Admin Center – User Reporting: This setting controls whether users can report messages directly from the Teams interface. It is enabled by default for new tenants. If disabled, users cannot report messages in Teams, and downstream settings in Microsoft Defender will not have any effect. 2. Microsoft 365 Defender Portal – User Reporting Integration: Also enabled by default in new tenants, this setting must be explicitly enabled for existing tenants. It ensures that messages reported from Teams are properly surfaced on the “User reported” tab of the Submissions page in Microsoft 365 Defender. 3. Defender – Report Message Destinations: This broader configuration applies to multiple Microsoft 365 services, including Teams. It allows organizations to control where reported messages are routed, such as keeping them within the organization or forwarding them to Microsoft for deeper analysis. Given its influence on how user submissions are processed, it is included as a required configuration in this assessment.", "AdditionalInformation": "Enabling user reporting equips employees with a straightforward mechanism to flag suspicious or potentially malicious content in Teams, acting as a critical early warning system for security teams. This improves organizational responsiveness to phishing, social engineering, or targeted attacks that may initially evade automated detection.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] }, @@ -954,7 +1009,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "The setting “Mailbox auditing on by default” determines whether mailbox auditing is automatically enabled across all mailboxes in the organization, regardless of their individual auditing configuration. When this setting is configured as False, it enables auditing at the organization level, overriding the AuditEnabled property for individual mailboxes—even if it is explicitly set to False. With this setting enabled, default audit actions are automatically recorded for all mailboxes without requiring manual configuration. Conversely, disabling this setting (True) effectively turns off mailbox auditing across the organization and overrides any mailbox-level auditing settings. The consequences of disabling this setting include: • Mailbox auditing is completely disabled organization-wide. • No mailbox actions are logged, even if AuditEnabled is set to True for individual mailboxes. • New mailboxes do not inherit auditing, and setting AuditEnabled=True has no effect. • Bypass audit rules set via Set-MailboxAuditBypassAssociation are ignored. • Existing audit records remain in place until they expire based on the audit log retention policy. The recommended configuration is to set this value to False at the organization level to ensure auditing is enforced consistently.", "AdditionalInformation": "Enforcing mailbox auditing by default ensures that audit logging cannot be unintentionally or maliciously disabled on individual mailboxes. This setting provides vital visibility for forensic investigations and incident response (IR) teams, allowing them to trace suspicious or malicious activity—such as unauthorized inbox access, message deletion, or rule manipulation—that may signal account compromise. Consistent auditing across all mailboxes is critical for detecting threat actor behaviors (TTPs) and correlating events across users. While organizations without Microsoft 365 E5 licenses are limited to 90 days of audit log retention, enabling this setting still significantly improves detection and accountability within that window.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -971,7 +1027,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "As of January 2019, Microsoft enables mailbox audit logging by default across all organizations. This feature ensures that specific actions performed by mailbox owners, delegates, and administrators are automatically captured and recorded. These audit records can then be searched by administrators through the mailbox audit log in Microsoft 365. Each mailbox type—whether user, shared, resource, or public folder—can have tailored audit settings to track activities that are most relevant to the organization. While audit logging is enabled by default at the organizational level, it is important to explicitly configure the AuditEnabled property to True on all user mailboxes, and to expand the list of audited actions beyond the Microsoft defaults to meet specific visibility or compliance needs. Note: This recommendation is particularly relevant to users with Microsoft 365 E3 licenses, where audit actions differ slightly from the default configurations in E5.", "AdditionalInformation": "Mailbox auditing plays a critical role in supporting both regulatory compliance and security monitoring. Whether investigating unauthorized configuration changes, potential account compromise, or insider threats, detailed mailbox audit logs provide essential evidence for security operations, forensic analysis, and general administrative oversight. While mailbox auditing is enabled by default for most user mailboxes, certain mailbox types—such as Resource Mailboxes, Public Folder Mailboxes, and the DiscoverySearch Mailbox—do not inherit the organizational auditing default. For these mailboxes, AuditEnabled must be manually set to True to ensure relevant activities are captured. Note: Organizations without Microsoft 365 E5 licenses are subject to a 90-day audit log retention limit, but enabling comprehensive mailbox auditing remains a best practice for operational readiness and incident response.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -988,7 +1045,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "Since January 2019, mailbox audit logging has been enabled by default in all Microsoft 365 organizations. This feature ensures that specific actions performed by mailbox owners, delegates, and administrators are automatically captured and stored as audit records. These logs are accessible to administrators through the Microsoft 365 mailbox audit log, enabling visibility into key mailbox-level activity. Although logging is enabled by default, each mailbox—particularly user and shared mailboxes—can have custom audit actions assigned to capture the specific types of events deemed valuable by the organization. For environments with Microsoft 365 E5 licenses or the advanced auditing add-on, it is recommended to explicitly set AuditEnabled to True on all user mailboxes and to configure additional audit actions beyond Microsoft’s default settings for enhanced visibility. Note: This recommendation specifically applies to E5 or equivalent auditing-enabled license holders, as the available audit depth and event coverage differ from E3.", "AdditionalInformation": "Mailbox audit logging is essential for supporting security investigations, regulatory compliance, and operational forensics in Microsoft 365. Whether you’re tracking unauthorized changes, detecting suspicious access, or conducting post-incident analysis, having a complete and accurate mailbox audit trail is critical. While audit logging is broadly applied by default, certain mailbox types bypass the organizational setting and require manual configuration to enable auditing. These include: • Resource Mailboxes • Public Folder Mailboxes • DiscoverySearch Mailboxes For these mailbox types, the AuditEnabled property must be explicitly set to True to ensure that audit events are captured. Important: Without advanced auditing (included in E5 or via add-on), mailbox audit logs are retained for only 90 days, limiting the historical window for investigations. Nonetheless, enabling detailed auditing remains a key best practice for maintaining strong visibility and compliance readiness.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1005,7 +1063,8 @@ "SubSection": "3.1 Logging", "AttributeDescription": "The AuditBypassEnabled setting in Microsoft 365 allows specific user or computer accounts to bypass mailbox audit logging, meaning that any actions they perform on mailboxes will not be recorded in the audit logs. This includes actions such as reading, deleting, moving, or modifying messages.", "AdditionalInformation": "Allowing an account to bypass mailbox audit logging creates a blind spot in security monitoring. If the account is compromised, misused, or maliciously configured, it can access and interact with mailboxes without leaving any trace in the logs. This significantly undermines the organization’s ability to conduct forensic investigations, detect insider threats, or comply with audit requirements.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1022,7 +1081,8 @@ "SubSection": "3.2 Retention", "AttributeDescription": "Audit log search in the Microsoft Purview compliance portal allows organizations to track and retain user and administrator activities across Microsoft 365 services. When enabled, audit events—such as sign-ins, file access, configuration changes, and other operational actions—are captured and stored for up to 90 days by default. While some organizations may choose to integrate auditing data with third-party Security Information and Event Management (SIEM) systems, audit log search in Microsoft Purview remains a critical native capability for centralized visibility and incident response. Although global administrators have the ability to disable audit log search, it is generally recommended to keep it enabled to maintain full visibility into user and system activity.", "AdditionalInformation": "Activating audit log search provides essential forensic and compliance value. It enables organizations to detect anomalous behavior, investigate potential security incidents, and demonstrate adherence to regulatory and legal requirements. In addition, it supports operational monitoring, internal audits, and proactive threat detection. By retaining and centralizing audit data within the Microsoft 365 ecosystem, security and compliance teams gain faster access to actionable insights, reducing response times and strengthening the organization’s overall security posture.", - "LevelOfRisk": 5 + "LevelOfRisk": 5, + "Weight": 1000 } ] }, @@ -1039,7 +1099,8 @@ "SubSection": "3.3 Monitoring", "AttributeDescription": "Exchange Online Protection (EOP) is Microsoft’s cloud-based email filtering service designed to safeguard organizations against spam, malware, and other email-borne threats. It is included by default in all Microsoft 365 tenants with Exchange Online mailboxes. EOP provides customizable anti-malware policies that allow administrators to define protection settings and configure alerts for detected malicious activity.", "AdditionalInformation": "Enabling notifications for malware detections ensures that administrators are alerted when an internal user sends a message containing malware. Such incidents may signal a compromised user account or infected device, requiring immediate investigation to mitigate potential security breaches.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1056,7 +1117,8 @@ "SubSection": "4.1 In-Transit", "AttributeDescription": "DomainKeys Identified Mail (DKIM) is one of the three key email authentication technologies—alongside SPF and DMARC—used to protect domains from being spoofed by malicious actors. DKIM allows organizations to apply a cryptographic digital signature to the header of outbound email messages. When properly configured, DKIM enables a domain to associate its identity with the message, allowing recipient email systems to verify the authenticity of the sender. Combined with SPF and DMARC, DKIM helps prevent unauthorized use of your domain in phishing or spoofing attacks.", "AdditionalInformation": "Enabling DKIM in Office 365 ensures that all outbound emails sent via Exchange Online are cryptographically signed. This enables recipient mail servers to verify that messages originate from an authorized source, significantly reducing the risk of email spoofing and reinforcing trust in the organization’s email communications.", - "LevelOfRisk": 4 + "LevelOfRisk": 4, + "Weight": 100 } ] }, @@ -1073,7 +1135,8 @@ "SubSection": "4.1 In-Transit", "AttributeDescription": "Password Hash Synchronization (PHS) is a hybrid identity sign-in method that enables secure synchronization of user credentials from an on-premises Active Directory to Microsoft Entra ID. Microsoft Entra Connect performs this by syncing a hash of the password hash, ensuring credentials are not exposed in transit or storage. Note: This recommendation applies only to Microsoft 365 tenants configured with Entra Connect synchronization in a hybrid environment. It does not apply to tenants using federated domain configurations.", "AdditionalInformation": "PHS simplifies the user experience by allowing a single password to be used across both on-premises and cloud resources. It also enables leaked credential detection via Microsoft Entra ID Protection, helping identify compromised accounts when passwords appear in known data breaches or public forums. Compared to other synchronization methods like federation, PHS offers greater resilience—users can still sign in to Microsoft 365 even if connectivity to the on-premises environment is temporarily unavailable.", - "LevelOfRisk": 2 + "LevelOfRisk": 2, + "Weight": 8 } ] } diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index c4bcd40b8e..1385ffef4f 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -515,3 +515,8 @@ m365: # m365.exchange_mailbox_properties_auditing_enabled # Maximum number of days to keep audit logs audit_log_age: 90 + +# GitHub Configuration +github: + # github.repository_inactive_not_archived --> CIS recommends 180 days (6 months) + inactive_not_archived_days_threshold: 180 diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py index 92bfaec168..5e01e590f3 100644 --- a/prowler/lib/check/compliance_models.py +++ b/prowler/lib/check/compliance_models.py @@ -193,6 +193,7 @@ class Prowler_ThreatScore_Requirement_Attribute(BaseModel): AttributeDescription: str AdditionalInformation: str LevelOfRisk: int + Weight: int # Base Compliance Model diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index 0ef3195b2a..9c8ee40eb6 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -329,9 +329,8 @@ class CheckMetadata(BaseModel): checks = set() if service: - # This is a special case for the AWS provider since `lambda` is a reserved keyword in Python - if service == "awslambda": - service = "lambda" + if service == "lambda": + service = "awslambda" checks = { check_name for check_name, check_metadata in bulk_checks_metadata.items() @@ -548,7 +547,7 @@ class CheckReportGithub(Check_Report): resource_name: str resource_id: str - repository: str + owner: str def __init__( self, @@ -556,7 +555,7 @@ class CheckReportGithub(Check_Report): resource: Any, resource_name: str = None, resource_id: str = None, - repository: str = "global", + owner: str = None, ) -> None: """Initialize the GitHub Check's finding information. @@ -565,12 +564,16 @@ class CheckReportGithub(Check_Report): resource: Basic information about the resource. Defaults to None. resource_name: The name of the resource related with the finding. resource_id: The id of the resource related with the finding. - repository: The repository of the resource related with the finding. + owner: The owner of the resource related with the finding. """ super().__init__(metadata, resource) self.resource_name = resource_name or getattr(resource, "name", "") self.resource_id = resource_id or getattr(resource, "id", "") - self.repository = repository or getattr(resource, "repository", "") + self.owner = ( + owner + or getattr(resource, "owner", "") # For Repositories + or getattr(resource, "name", "") # For Organizations + ) @dataclass diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 1990c7f90a..746ec4e92a 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -26,7 +26,7 @@ class ProwlerArgumentParser: self.parser = argparse.ArgumentParser( prog="prowler", formatter_class=RawTextHelpFormatter, - usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,nhn,dashboard} ...", + usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,dashboard} ...", epilog=""" Available Cloud Providers: {aws,azure,gcp,kubernetes,m365,nhn} @@ -34,8 +34,8 @@ Available Cloud Providers: azure Azure Provider gcp GCP Provider kubernetes Kubernetes Provider - github GitHub Provider m365 Microsoft 365 Provider + github GitHub Provider nhn NHN Provider (Unofficial) Available components: diff --git a/prowler/lib/mutelist/mutelist.py b/prowler/lib/mutelist/mutelist.py index 70f74a2ff5..4940d0202f 100644 --- a/prowler/lib/mutelist/mutelist.py +++ b/prowler/lib/mutelist/mutelist.py @@ -98,7 +98,6 @@ class Mutelist(ABC): mutelist_file_path: Property that returns the mutelist file path. is_finding_muted: Abstract method to check if a finding is muted. get_mutelist_file_from_local_file: Retrieves the mutelist file from a local file. - validate_mutelist: Validates the mutelist against a schema. is_muted: Checks if a finding is muted for the audited account, check, region, resource, and tags. is_muted_in_check: Checks if a check is muted. is_excepted: Checks if the account, region, resource, and tags are excepted based on the exceptions. @@ -119,7 +118,7 @@ class Mutelist(ABC): self._mutelist = mutelist_content if self._mutelist: - self.validate_mutelist() + self._mutelist = Mutelist.validate_mutelist(self._mutelist) @property def mutelist(self) -> dict: @@ -142,17 +141,6 @@ class Mutelist(ABC): f"{error.__class__.__name__} -- {error}[{error.__traceback__.tb_lineno}]" ) - def validate_mutelist(self) -> bool: - try: - validate(self._mutelist, schema=mutelist_schema) - return True - except Exception as error: - logger.error( - f"{error.__class__.__name__} -- Mutelist YAML is malformed - {error}[{error.__traceback__.tb_lineno}]" - ) - self._mutelist = {} - return False - def is_muted( self, audited_account: str, @@ -449,3 +437,23 @@ class Mutelist(ABC): f"{error.__class__.__name__} -- {error}[{error.__traceback__.tb_lineno}]" ) return False + + @staticmethod + def validate_mutelist(mutelist: dict) -> dict: + """ + Validate the mutelist against the schema. + + Args: + mutelist (dict): The mutelist to be validated. + + Returns: + dict: The mutelist itself. + """ + try: + validate(mutelist, schema=mutelist_schema) + return mutelist + except Exception as error: + logger.error( + f"{error.__class__.__name__} -- Mutelist YAML is malformed - {error}[{error.__traceback__.tb_lineno}]" + ) + return {} diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/models.py b/prowler/lib/outputs/compliance/prowler_threatscore/models.py index f1908b8de3..c8ac0dd783 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/models.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/models.py @@ -21,6 +21,7 @@ class ProwlerThreatScoreAWSModel(BaseModel): Requirements_Attributes_AttributeDescription: str Requirements_Attributes_AdditionalInformation: str Requirements_Attributes_LevelOfRisk: int + Requirements_Attributes_Weight: int Status: str StatusExtended: str ResourceId: str @@ -47,6 +48,7 @@ class ProwlerThreatScoreAzureModel(BaseModel): Requirements_Attributes_AttributeDescription: str Requirements_Attributes_AdditionalInformation: str Requirements_Attributes_LevelOfRisk: int + Requirements_Attributes_Weight: int Status: str StatusExtended: str ResourceId: str @@ -73,6 +75,7 @@ class ProwlerThreatScoreGCPModel(BaseModel): Requirements_Attributes_AttributeDescription: str Requirements_Attributes_AdditionalInformation: str Requirements_Attributes_LevelOfRisk: int + Requirements_Attributes_Weight: int Status: str StatusExtended: str ResourceId: str @@ -99,6 +102,7 @@ class ProwlerThreatScoreM365Model(BaseModel): Requirements_Attributes_AttributeDescription: str Requirements_Attributes_AdditionalInformation: str Requirements_Attributes_LevelOfRisk: int + Requirements_Attributes_Weight: int Status: str StatusExtended: str ResourceId: str diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py index e041e3e7f3..eb5194074e 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore.py @@ -24,7 +24,8 @@ def get_prowler_threatscore_table( muted_count = [] pillars = {} score_per_pillar = {} - number_findings_per_pillar = {} + max_score_per_pillar = {} + counted_findings = [] for index, finding in enumerate(findings): check = bulk_checks_metadata[finding.check_metadata.CheckID] check_compliances = check.Compliance @@ -34,12 +35,24 @@ def get_prowler_threatscore_table( for attribute in requirement.Attributes: pillar = attribute.Section - if pillar not in score_per_pillar.keys(): + if not any( + [ + pillar in score_per_pillar.keys(), + pillar in max_score_per_pillar.keys(), + ] + ): score_per_pillar[pillar] = 0 - number_findings_per_pillar[pillar] = 0 - if finding.status == "FAIL" and not finding.muted: - score_per_pillar[pillar] += attribute.LevelOfRisk - number_findings_per_pillar[pillar] += 1 + max_score_per_pillar[pillar] = 0 + + if index not in counted_findings: + if finding.status == "PASS": + score_per_pillar[pillar] += ( + attribute.LevelOfRisk * attribute.Weight + ) + max_score_per_pillar[pillar] += ( + attribute.LevelOfRisk * attribute.Weight + ) + counted_findings.append(index) if pillar not in pillars: pillars[pillar] = {"FAIL": 0, "PASS": 0, "Muted": 0} @@ -60,14 +73,9 @@ def get_prowler_threatscore_table( for pillar in pillars: pillar_table["Provider"].append(compliance.Provider) pillar_table["Pillar"].append(pillar) - if number_findings_per_pillar[pillar] == 0: - pillar_table["Score"].append( - f"{Style.BRIGHT}{Fore.GREEN}0{Style.RESET_ALL}" - ) - else: - pillar_table["Score"].append( - f"{Style.BRIGHT}{Fore.RED}{score_per_pillar[pillar] / number_findings_per_pillar[pillar]:.2f}/5{Style.RESET_ALL}" - ) + pillar_table["Score"].append( + f"{Style.BRIGHT}{Fore.RED}{(score_per_pillar[pillar] / max_score_per_pillar[pillar]) * 100:.2f}%{Style.RESET_ALL}" + ) if pillars[pillar]["FAIL"] > 0: pillar_table["Status"].append( f"{Fore.RED}FAIL({pillars[pillar]['FAIL']}){Style.RESET_ALL}" @@ -110,10 +118,10 @@ def get_prowler_threatscore_table( ) print( - f"{Style.BRIGHT}\n=== Risk Score Guide ===\nScore ranges from 1 (lowest risk) to 5 (highest risk), indicating the severity of the potential impact.{Style.RESET_ALL}" + f"{Style.BRIGHT}\n=== Threat Score Guide ===\nThe lower the score, the higher the risk.{Style.RESET_ALL}" ) print( - f"{Style.BRIGHT}(Only sections containing results appear, the score is calculated as the sum of the level of risk of the failed findings divided by the number of failed findings){Style.RESET_ALL}" + f"{Style.BRIGHT}(Only sections containing results appear, the score is calculated as the sum of the level of risk * weight of the passed findings divided by the sum of the risk * weight of all the findings){Style.RESET_ALL}" ) print(f"\nDetailed results of {compliance_framework.upper()} are in:") print( diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws.py index 2d876b402f..b4021646ca 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws.py @@ -55,6 +55,7 @@ class ProwlerThreatScoreAWS(ComplianceOutput): Requirements_Attributes_AttributeDescription=attribute.AttributeDescription, Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, + Requirements_Attributes_Weight=attribute.Weight, Status=finding.status, StatusExtended=finding.status_extended, ResourceId=finding.resource_uid, @@ -81,6 +82,7 @@ class ProwlerThreatScoreAWS(ComplianceOutput): Requirements_Attributes_AttributeDescription=attribute.AttributeDescription, Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, + Requirements_Attributes_Weight=attribute.Weight, Status="MANUAL", StatusExtended="Manual check", ResourceId="manual_check", diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure.py index 01786e2d08..4671666ba0 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure.py @@ -55,6 +55,7 @@ class ProwlerThreatScoreAzure(ComplianceOutput): Requirements_Attributes_AttributeDescription=attribute.AttributeDescription, Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, + Requirements_Attributes_Weight=attribute.Weight, Status=finding.status, StatusExtended=finding.status_extended, ResourceId=finding.resource_uid, @@ -81,6 +82,7 @@ class ProwlerThreatScoreAzure(ComplianceOutput): Requirements_Attributes_AttributeDescription=attribute.AttributeDescription, Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, + Requirements_Attributes_Weight=attribute.Weight, Status="MANUAL", StatusExtended="Manual check", ResourceId="manual_check", diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp.py index 9bf577255f..0d57ce0ba7 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp.py @@ -55,6 +55,7 @@ class ProwlerThreatScoreGCP(ComplianceOutput): Requirements_Attributes_AttributeDescription=attribute.AttributeDescription, Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, + Requirements_Attributes_Weight=attribute.Weight, Status=finding.status, StatusExtended=finding.status_extended, ResourceId=finding.resource_uid, @@ -81,6 +82,7 @@ class ProwlerThreatScoreGCP(ComplianceOutput): Requirements_Attributes_AttributeDescription=attribute.AttributeDescription, Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, + Requirements_Attributes_Weight=attribute.Weight, Status="MANUAL", StatusExtended="Manual check", ResourceId="manual_check", diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365.py b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365.py index 26c5d01af0..f4ff630572 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365.py @@ -55,6 +55,7 @@ class ProwlerThreatScoreM365(ComplianceOutput): Requirements_Attributes_AttributeDescription=attribute.AttributeDescription, Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, + Requirements_Attributes_Weight=attribute.Weight, Status=finding.status, StatusExtended=finding.status_extended, ResourceId=finding.resource_uid, @@ -81,6 +82,7 @@ class ProwlerThreatScoreM365(ComplianceOutput): Requirements_Attributes_AttributeDescription=attribute.AttributeDescription, Requirements_Attributes_AdditionalInformation=attribute.AdditionalInformation, Requirements_Attributes_LevelOfRisk=attribute.LevelOfRisk, + Requirements_Attributes_Weight=attribute.Weight, Status="MANUAL", StatusExtended="Manual check", ResourceId="manual_check", diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index b7547da815..ffa0a2f44e 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -251,7 +251,7 @@ class Finding(BaseModel): output_data["resource_uid"] = check_output.resource_id output_data["account_name"] = provider.identity.account_name output_data["account_uid"] = provider.identity.account_id - output_data["region"] = check_output.repository + output_data["region"] = check_output.owner elif provider.type == "m365": output_data["auth_method"] = ( diff --git a/prowler/lib/outputs/outputs.py b/prowler/lib/outputs/outputs.py index adada2f513..cae67482e7 100644 --- a/prowler/lib/outputs/outputs.py +++ b/prowler/lib/outputs/outputs.py @@ -17,7 +17,7 @@ def stdout_report(finding, color, verbose, status, fix): if finding.check_metadata.Provider == "kubernetes": details = finding.namespace.lower() if finding.check_metadata.Provider == "github": - details = finding.repository + details = finding.owner if finding.check_metadata.Provider == "m365": details = finding.location if finding.check_metadata.Provider == "nhn": diff --git a/prowler/providers/aws/aws_regions_by_service.json b/prowler/providers/aws/aws_regions_by_service.json index 43b4bb7b47..eb2dc553f8 100644 --- a/prowler/providers/aws/aws_regions_by_service.json +++ b/prowler/providers/aws/aws_regions_by_service.json @@ -17,6 +17,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -101,6 +102,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -147,6 +149,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -193,6 +196,7 @@ "aws": [ "ap-south-1", "ap-southeast-2", + "eu-west-1", "eu-west-2", "us-east-1", "us-east-2", @@ -343,6 +347,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -445,6 +450,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -466,6 +472,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -512,6 +519,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -817,17 +825,24 @@ "aps": { "regions": { "aws": [ + "af-south-1", + "ap-east-1", "ap-northeast-1", "ap-northeast-2", "ap-south-1", "ap-southeast-1", "ap-southeast-2", + "ap-southeast-5", + "ap-southeast-7", "ca-central-1", "eu-central-1", + "eu-central-2", "eu-north-1", + "eu-south-1", "eu-west-1", "eu-west-2", "eu-west-3", + "me-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -842,6 +857,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -888,6 +904,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -1017,6 +1034,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -1063,6 +1081,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -1120,6 +1139,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -1177,6 +1197,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -1255,6 +1276,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -1713,6 +1735,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -1842,6 +1865,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -1888,6 +1912,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -2025,6 +2050,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -2094,6 +2120,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -2661,6 +2688,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -2994,6 +3022,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -3188,6 +3217,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -3249,6 +3279,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -3295,6 +3326,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -3459,6 +3491,11 @@ "dsql": { "regions": { "aws": [ + "ap-northeast-1", + "ap-northeast-3", + "eu-west-1", + "eu-west-2", + "eu-west-3", "us-east-1", "us-east-2", "us-west-2" @@ -3472,6 +3509,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -3518,6 +3556,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -3564,6 +3603,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -3610,6 +3650,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -3656,6 +3697,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -3712,6 +3754,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -3758,6 +3801,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -3804,6 +3848,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -3850,6 +3895,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -3893,6 +3939,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -3992,6 +4039,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -4038,6 +4086,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -4084,6 +4133,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -4230,6 +4280,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -4276,6 +4327,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -4322,6 +4374,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -4385,6 +4438,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -4480,6 +4534,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -5178,6 +5233,7 @@ "cn-northwest-1" ], "aws-us-gov": [ + "us-gov-east-1", "us-gov-west-1" ] } @@ -5187,6 +5243,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -5322,6 +5379,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -5992,6 +6050,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -6113,6 +6172,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -6205,6 +6265,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -6553,6 +6614,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -6827,6 +6889,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -7437,6 +7500,7 @@ "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", + "ap-southeast-5", "ca-central-1", "ca-west-1", "eu-central-1", @@ -7475,8 +7539,10 @@ "ap-southeast-1", "ap-southeast-2", "ap-southeast-3", + "ap-southeast-4", "ap-southeast-5", "ca-central-1", + "ca-west-1", "eu-central-1", "eu-north-1", "eu-south-2", @@ -7701,6 +7767,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -7751,8 +7818,11 @@ "eu-central-1", "eu-north-1", "eu-west-1", + "eu-west-2", + "eu-west-3", "us-east-1", "us-east-2", + "us-west-1", "us-west-2" ], "aws-cn": [], @@ -7779,6 +7849,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -7828,6 +7899,7 @@ "ap-northeast-2", "ap-northeast-3", "ap-south-1", + "ap-south-2", "ap-southeast-1", "ap-southeast-2", "ca-central-1", @@ -7851,19 +7923,6 @@ ] } }, - "opsworks": { - "regions": { - "aws": [ - "ap-southeast-1", - "eu-central-1", - "eu-west-1", - "us-east-1", - "us-west-2" - ], - "aws-cn": [], - "aws-us-gov": [] - } - }, "opsworkschefautomate": { "regions": { "aws": [ @@ -7903,6 +7962,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -8070,6 +8130,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -8161,6 +8222,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -8381,6 +8443,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -8549,6 +8612,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -8595,6 +8659,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -8641,6 +8706,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -8718,6 +8784,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -8960,6 +9027,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -9006,6 +9074,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -9069,6 +9138,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -9153,6 +9223,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -9199,6 +9270,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -9245,6 +9317,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -9305,6 +9378,8 @@ "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-7", "ca-central-1", "ca-west-1", "eu-central-1", @@ -9318,6 +9393,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -9339,6 +9415,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -9421,6 +9498,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -9773,6 +9851,7 @@ "ap-southeast-3", "ap-southeast-4", "ca-central-1", + "ca-west-1", "eu-central-1", "eu-central-2", "eu-north-1", @@ -9790,7 +9869,10 @@ "us-west-1", "us-west-2" ], - "aws-cn": [], + "aws-cn": [ + "cn-north-1", + "cn-northwest-1" + ], "aws-us-gov": [] } }, @@ -9854,6 +9936,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -10029,6 +10112,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -10157,6 +10241,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -10471,6 +10556,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -10541,6 +10627,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -10587,6 +10674,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -10785,6 +10873,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -10877,6 +10966,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -10923,6 +11013,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -10980,6 +11071,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -11026,6 +11118,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -11299,6 +11392,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -11371,6 +11465,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -11537,6 +11632,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -11619,6 +11715,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -11973,6 +12070,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", diff --git a/prowler/providers/aws/lib/mutelist/mutelist.py b/prowler/providers/aws/lib/mutelist/mutelist.py index 3302a13f5d..c914400fb7 100644 --- a/prowler/providers/aws/lib/mutelist/mutelist.py +++ b/prowler/providers/aws/lib/mutelist/mutelist.py @@ -41,7 +41,7 @@ class AWSMutelist(Mutelist): else: self.get_mutelist_file_from_local_file(mutelist_path) if self._mutelist: - self.validate_mutelist() + self._mutelist = self.validate_mutelist(self._mutelist) def is_finding_muted( self, diff --git a/prowler/providers/aws/services/vpc/vpc_endpoint_for_ec2_enabled/vpc_endpoint_for_ec2_enabled.metadata.json b/prowler/providers/aws/services/vpc/vpc_endpoint_for_ec2_enabled/vpc_endpoint_for_ec2_enabled.metadata.json index 2a12db3c4e..3bd51b2656 100644 --- a/prowler/providers/aws/services/vpc/vpc_endpoint_for_ec2_enabled/vpc_endpoint_for_ec2_enabled.metadata.json +++ b/prowler/providers/aws/services/vpc/vpc_endpoint_for_ec2_enabled/vpc_endpoint_for_ec2_enabled.metadata.json @@ -3,7 +3,7 @@ "CheckID": "vpc_endpoint_for_ec2_enabled", "CheckTitle": "Amazon EC2 should be configured to use VPC endpoints that are created for the Amazon EC2 service.", "CheckType": [], - "ServiceName": "ec2", + "ServiceName": "vpc", "SubServiceName": "", "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", "Severity": "medium", diff --git a/prowler/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured.py b/prowler/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured.py index e942aabc6f..4c1fb89756 100644 --- a/prowler/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured.py +++ b/prowler/providers/azure/services/app/app_function_access_keys_configured/app_function_access_keys_configured.py @@ -11,19 +11,20 @@ class app_function_access_keys_configured(Check): functions, ) in app_client.functions.items(): for function in functions.values(): - report = Check_Report_Azure(metadata=self.metadata(), resource=function) - report.subscription = subscription_name - report.status = "FAIL" - report.status_extended = ( - f"Function {function.name} does not have function keys configured." - ) - - if len(function.function_keys) > 0: - report.status = "PASS" - report.status_extended = ( - f"Function {function.name} has function keys configured." + if function.function_keys is not None: + report = Check_Report_Azure( + metadata=self.metadata(), resource=function ) + report.subscription = subscription_name + report.status = "FAIL" + report.status_extended = f"Function {function.name} does not have function keys configured." - findings.append(report) + if len(function.function_keys) > 0: + report.status = "PASS" + report.status_extended = ( + f"Function {function.name} has function keys configured." + ) + + findings.append(report) return findings diff --git a/prowler/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled.py b/prowler/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled.py index 06ce41a2b0..3c5102dbbb 100644 --- a/prowler/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled.py +++ b/prowler/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled.py @@ -14,26 +14,29 @@ class app_function_application_insights_enabled(Check): functions, ) in app_client.functions.items(): for function in functions.values(): - report = Check_Report_Azure(metadata=self.metadata(), resource=function) - report.subscription = subscription_name - report.status = "FAIL" - report.status_extended = ( - f"Function {function.name} is not using Application Insights." - ) - - if function.enviroment_variables.get( - "APPINSIGHTS_INSTRUMENTATIONKEY", "" - ) in [ - component.instrumentation_key - for component in appinsights_client.components[ - subscription_name - ].values() - ]: - report.status = "PASS" + if function.enviroment_variables is not None: + report = Check_Report_Azure( + metadata=self.metadata(), resource=function + ) + report.subscription = subscription_name + report.status = "FAIL" report.status_extended = ( - f"Function {function.name} is using Application Insights." + f"Function {function.name} is not using Application Insights." ) - findings.append(report) + if function.enviroment_variables.get( + "APPINSIGHTS_INSTRUMENTATIONKEY", "" + ) in [ + component.instrumentation_key + for component in appinsights_client.components[ + subscription_name + ].values() + ]: + report.status = "PASS" + report.status_extended = ( + f"Function {function.name} is using Application Insights." + ) + + findings.append(report) return findings diff --git a/prowler/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version.py b/prowler/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version.py index 180434ec21..3cd8d349b4 100644 --- a/prowler/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version.py +++ b/prowler/providers/azure/services/app/app_function_latest_runtime_version/app_function_latest_runtime_version.py @@ -11,20 +11,25 @@ class app_function_latest_runtime_version(Check): functions, ) in app_client.functions.items(): for function in functions.values(): - report = Check_Report_Azure(metadata=self.metadata(), resource=function) - report.subscription = subscription_name - report.status = "PASS" - report.status_extended = ( - f"Function {function.name} is using the latest runtime." - ) + if function.enviroment_variables is not None: + report = Check_Report_Azure( + metadata=self.metadata(), resource=function + ) + report.subscription = subscription_name + report.status = "PASS" + report.status_extended = ( + f"Function {function.name} is using the latest runtime." + ) - if ( - function.enviroment_variables.get("FUNCTIONS_EXTENSION_VERSION", "") - != "~4" - ): - report.status = "FAIL" - report.status_extended = f"Function {function.name} is not using the latest runtime. The current runtime is '{function.enviroment_variables.get('FUNCTIONS_EXTENSION_VERSION', '')}' and should be '~4'." + if ( + function.enviroment_variables.get( + "FUNCTIONS_EXTENSION_VERSION", "" + ) + != "~4" + ): + report.status = "FAIL" + report.status_extended = f"Function {function.name} is not using the latest runtime. The current runtime is '{function.enviroment_variables.get('FUNCTIONS_EXTENSION_VERSION', '')}' and should be '~4'." - findings.append(report) + findings.append(report) return findings diff --git a/prowler/providers/azure/services/app/app_service.py b/prowler/providers/azure/services/app/app_service.py index fc2ee5cf13..584555e2ad 100644 --- a/prowler/providers/azure/services/app/app_service.py +++ b/prowler/providers/azure/services/app/app_service.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import Dict, List +from typing import Dict, List, Optional from azure.mgmt.web import WebSiteManagementClient @@ -124,14 +124,16 @@ class App(AzureService): # Filter function apps if getattr(function, "kind", "").startswith("functionapp"): # List host keys - host_keys = client.web_apps.list_host_keys( - resource_group_name=function.resource_group, - name=function.name, - ) # Need to add role 'Logic App Contributor' to the service principal to get the host keys or add to the reader role the permission 'Microsoft.Web/sites/host/listkeys' + host_keys = self._get_function_host_keys( + subscription_name, function.resource_group, function.name + ) + if host_keys is not None: + function_keys = getattr(host_keys, "function_keys", {}) + else: + function_keys = None - function_config = client.web_apps.get_configuration( - resource_group_name=function.resource_group, - name=function.name, + function_config = self._get_function_config( + subscription_name, function.resource_group, function.name ) functions[subscription_name].update( @@ -141,16 +143,9 @@ class App(AzureService): name=function.name, location=function.location, kind=function.kind, - function_keys=getattr( - host_keys, "function_keys", {} - ), + function_keys=function_keys, enviroment_variables=getattr( - client.web_apps.list_application_settings( - resource_group_name=function.resource_group, - name=function.name, - ), - "properties", - {}, + function_config, "properties", None ), identity=getattr(function, "identity", None), public_access=( @@ -167,7 +162,7 @@ class App(AzureService): "", ), ftps_state=getattr( - function_config, "ftps_state", "" + function_config, "ftps_state", None ), ) } @@ -209,6 +204,30 @@ class App(AzureService): ) return monitor_diagnostics_settings + def _get_function_host_keys(self, subscription, resource_group, name): + try: + return self.clients[subscription].web_apps.list_host_keys( + resource_group_name=resource_group, + name=name, + ) + except Exception as error: + logger.error( + f"Error getting host keys for {name} in {resource_group}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + + def _get_function_config(self, subscription, resource_group, name): + try: + return self.clients[subscription].web_apps.list_application_settings( + resource_group_name=resource_group, + name=name, + ) + except Exception as error: + logger.error( + f"Error getting configuration for {name} in {resource_group}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + @dataclass class ManagedServiceIdentity: @@ -250,9 +269,9 @@ class FunctionApp: name: str location: str kind: str - function_keys: Dict[str, str] - enviroment_variables: Dict[str, str] + function_keys: Optional[Dict[str, str]] + enviroment_variables: Optional[Dict[str, str]] identity: ManagedServiceIdentity public_access: bool vnet_subnet_id: str - ftps_state: str + ftps_state: Optional[str] diff --git a/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.metadata.json b/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.metadata.json index 20fc3d837c..d8a4f1812b 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.metadata.json +++ b/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.metadata.json @@ -1,16 +1,16 @@ { "Provider": "azure", "CheckID": "defender_ensure_notify_alerts_severity_is_high", - "CheckTitle": "Ensure That 'Notify about alerts with the following severity' is Set to 'High'", + "CheckTitle": "Ensure that email notifications are configured for alerts with a minimum severity of 'High' or lower", "CheckType": [], "ServiceName": "defender", "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", "ResourceType": "AzureEmailNotifications", - "Description": "Microsoft Defender for Cloud emails the subscription owners whenever a high-severity alert is triggered for their subscription. You should provide a security contact email address as an additional email address.", - "Risk": "Microsoft Defender for Cloud emails the Subscription Owner to notify them about security alerts. Adding your Security Contact's email address to the 'Additional email addresses' field ensures that your organization's Security Team is included in these alerts. This ensures that the proper people are aware of any potential compromise in order to mitigate the risk in a timely fashion.", - "RelatedUrl": "https://docs.microsoft.com/en-us/azure/security-center/security-center-provide-security-contact-details", + "Description": "Microsoft Defender for Cloud sends email notifications when alerts of a certain severity level or higher are triggered. By setting the minimum severity to 'High', 'Medium', or even 'Low', you ensure that alerts with equal or greater severity (e.g., High or Critical) are still delivered. Selecting a lower threshold like 'Low' results in more comprehensive alert coverage.", + "Risk": "If this setting is too restrictive (e.g., set to 'Critical' only), important security alerts with 'High' or 'Medium' severity might be missed. Ensuring that 'High' or a lower threshold is configured helps security teams stay informed about significant threats and respond in a timely manner.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/defender-for-cloud/email-notifications-alerts#manage-notifications-on-email", "Remediation": { "Code": { "CLI": "", @@ -19,7 +19,7 @@ "Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/bc_azr_general_4#terraform" }, "Recommendation": { - "Text": "1. From Azure Home select the Portal Menu 2. Select Microsoft Defender for Cloud 3. Click on Environment Settings 4. Click on the appropriate Management Group, Subscription, or Workspace 5. Click on Email notifications 6. Enter a valid security contact email address (or multiple addresses separated by commas) in the Additional email addresses field 7. Click Save", + "Text": "1. From Azure Home select the Portal Menu. 2. Select Microsoft Defender for Cloud. 3. Click on Environment Settings. 4. Click on the appropriate Management Group, Subscription, or Workspace. 5. Click on Email notifications. 6. Under 'Notify about alerts with the following severity (or higher)', select at least 'High' (or optionally 'Medium' or 'Low' for broader coverage). 7. Click Save.", "Url": "https://docs.microsoft.com/en-us/rest/api/securitycenter/securitycontacts/list" } }, diff --git a/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.py b/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.py index b7154e99aa..34eaab9624 100644 --- a/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.py +++ b/prowler/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high.py @@ -13,12 +13,15 @@ class defender_ensure_notify_alerts_severity_is_high(Check): for contact in security_contacts.values(): report = Check_Report_Azure(metadata=self.metadata(), resource=contact) report.subscription = subscription_name - report.status = "PASS" - report.status_extended = f"Notifiy alerts are enabled for severity high in subscription {subscription_name}." + report.status = "FAIL" + report.status_extended = f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {subscription_name}." - if contact.alert_notifications_minimal_severity != "High": - report.status = "FAIL" - report.status_extended = f"Notifiy alerts are not enabled for severity high in subscription {subscription_name}." + if ( + contact.alert_notifications_minimal_severity != "Critical" + and contact.alert_notifications_minimal_severity != "" + ): + report.status = "PASS" + report.status_extended = f"Notifications are enabled for alerts with a minimum severity of high or lower ({contact.alert_notifications_minimal_severity}) in subscription {subscription_name}." findings.append(report) diff --git a/prowler/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled.py b/prowler/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled.py index 5461cd45e4..f9c7237302 100644 --- a/prowler/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled.py +++ b/prowler/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled.py @@ -12,7 +12,7 @@ class sqlserver_auditing_enabled(Check): ) report.subscription = subscription report.status = "PASS" - report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has a auditing policy configured." + report.status_extended = f"SQL Server {sql_server.name} from subscription {subscription} has an auditing policy configured." for auditing_policy in sql_server.auditing_policies: if auditing_policy.state == "Disabled": report.status = "FAIL" diff --git a/prowler/providers/azure/services/storage/storage_account_key_access_disabled/__init__.py b/prowler/providers/azure/services/storage/storage_account_key_access_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled.metadata.json b/prowler/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled.metadata.json new file mode 100644 index 0000000000..4cdb8e26c9 --- /dev/null +++ b/prowler/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled.metadata.json @@ -0,0 +1,32 @@ +{ + "Provider": "azure", + "CheckID": "storage_account_key_access_disabled", + "CheckTitle": "Ensure allow storage account key access is disabled", + "CheckType": [], + "ServiceName": "storage", + "SubServiceName": "account", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AzureStorageAccount", + "Description": "Ensures that access to Azure Storage Accounts using account keys is disabled, enforcing the use of Microsoft Entra ID (formerly Azure AD) for authentication.", + "Risk": "Using Shared Key authorization poses a security risk due to the high privileges associated with storage account keys and the difficulty in auditing such access. Disabling Shared Key access helps enforce identity-based authentication via Microsoft Entra ID, enhancing security and traceability.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/storage/common/shared-key-authorization-prevent", + "Remediation": { + "Code": { + "CLI": "az storage account update --name --resource-group --allow-shared-key-access false", + "NativeIaC": "", + "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/StorageAccounts/disable-shared-key-authorization.html", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable Shared Key authorization on storage accounts to enforce the use of Microsoft Entra ID for secure, auditable access.", + "Url": "https://learn.microsoft.com/en-us/azure/storage/common/shared-key-authorization-prevent" + } + }, + "Categories": [ + "e3" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled.py b/prowler/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled.py new file mode 100644 index 0000000000..c4b946c7cf --- /dev/null +++ b/prowler/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled.py @@ -0,0 +1,34 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.storage.storage_client import storage_client + + +class storage_account_key_access_disabled(Check): + """Check if storage account key access is disabled. + + Attributes: + metadata: Metadata associated with the check (inherited from Check). + """ + + def execute(self) -> Check_Report_Azure: + """Execute the check for storage account key access. + + This method checks if storage account key access is disabled. If it is, the check passes; otherwise, it fails. + + Returns: + Check_Report_Azure: A report containing the result of the check. + """ + findings = [] + for subscription, storage_accounts in storage_client.storage_accounts.items(): + for storage_account in storage_accounts: + report = Check_Report_Azure( + metadata=self.metadata(), resource=storage_account + ) + report.subscription = subscription + if not storage_account.allow_shared_key_access: + report.status = "PASS" + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has shared key access disabled." + else: + report.status = "FAIL" + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has shared key access enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/__init__.py b/prowler/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled.metadata.json b/prowler/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled.metadata.json new file mode 100644 index 0000000000..6746a0c63d --- /dev/null +++ b/prowler/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "storage_ensure_file_shares_soft_delete_is_enabled", + "CheckTitle": "Ensure soft delete for Azure File Shares is enabled", + "CheckType": [], + "ServiceName": "storage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "", + "Description": "Ensure that soft delete is enabled for Azure File Shares to protect against accidental or malicious deletion of important data. This feature allows deleted file shares to be retained for a specified period, during which they can be recovered before permanent deletion occurs.", + "Risk": "Without soft delete enabled, accidental or malicious deletions of file shares result in permanent data loss, making recovery impossible unless a separate backup mechanism is in place.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/storage/files/storage-files-prevent-file-share-deletion?tabs=azure-portal", + "Remediation": { + "Code": { + "CLI": "az storage account file-service-properties update --account-name --enable-delete-retention true --delete-retention-days ", + "NativeIaC": "", + "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/StorageAccounts/enable-soft-delete-for-file-shares.html", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable soft delete for file shares on your Azure Storage Account to allow recovery of deleted shares within a configured retention period.", + "Url": "https://learn.microsoft.com/en-us/azure/storage/files/storage-files-prevent-file-share-deletion?tabs=azure-portal" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled.py b/prowler/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled.py new file mode 100644 index 0000000000..9a08809c8e --- /dev/null +++ b/prowler/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled.py @@ -0,0 +1,35 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.storage.storage_client import storage_client + + +class storage_ensure_file_shares_soft_delete_is_enabled(Check): + def execute(self) -> list: + findings = [] + for subscription, storage_accounts in storage_client.storage_accounts.items(): + for storage_account in storage_accounts: + if ( + hasattr(storage_account, "file_shares") + and storage_account.file_shares + ): + for file_share in storage_account.file_shares: + report = Check_Report_Azure( + metadata=self.metadata(), resource=storage_account + ) + report.subscription = subscription + report.resource_id = file_share.name + if file_share.soft_delete_enabled: + report.status = "PASS" + report.status_extended = ( + f"File share {file_share.name} in storage account {storage_account.name} " + f"from subscription {subscription} has soft delete enabled with a retention period of " + f"{file_share.retention_days} days." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"File share {file_share.name} in storage account {storage_account.name} " + f"from subscription {subscription} does not have soft delete enabled or has an invalid " + f"retention period." + ) + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/storage/storage_service.py b/prowler/providers/azure/services/storage/storage_service.py index b0aac96ec0..02533b503e 100644 --- a/prowler/providers/azure/services/storage/storage_service.py +++ b/prowler/providers/azure/services/storage/storage_service.py @@ -13,6 +13,7 @@ class Storage(AzureService): super().__init__(StorageManagementClient, provider) self.storage_accounts = self._get_storage_accounts() self._get_blob_properties() + self._get_file_share_properties() def _get_storage_accounts(self): logger.info("Storage - Getting storage accounts...") @@ -67,6 +68,9 @@ class Storage(AzureService): ], key_expiration_period_in_days=key_expiration_period_in_days, location=storage_account.location, + allow_shared_key_access=getattr( + storage_account, "allow_shared_key_access", True + ), ) ) except Exception as error: @@ -122,6 +126,54 @@ class Storage(AzureService): f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + def _get_file_share_properties(self): + logger.info("Storage - Getting file share properties...") + for subscription, accounts in self.storage_accounts.items(): + client = self.clients[subscription] + for account in accounts: + try: + service_properties = client.file_services.get_service_properties( + account.resouce_group_name, account.name + ) + soft_delete_enabled = False + retention_days = 0 + if ( + hasattr(service_properties, "share_delete_retention_policy") + and service_properties.share_delete_retention_policy + ): + soft_delete_enabled = getattr( + service_properties.share_delete_retention_policy, + "enabled", + False, + ) + retention_days = ( + getattr( + service_properties.share_delete_retention_policy, + "days", + 0, + ) + if soft_delete_enabled + else 0 + ) + + file_shares = client.file_shares.list( + account.resouce_group_name, account.name + ) + account.file_shares = [] + for file_share in file_shares: + account.file_shares.append( + FileShare( + id=file_share.id, + name=file_share.name, + soft_delete_enabled=soft_delete_enabled, + retention_days=retention_days, + ) + ) + except Exception as error: + logger.error( + f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + @dataclass class DeleteRetentionPolicy: @@ -165,4 +217,14 @@ class Account: private_endpoint_connections: List[PrivateEndpointConnection] key_expiration_period_in_days: str location: str + allow_shared_key_access: bool = True blob_properties: Optional[BlobProperties] = None + file_shares: list = None + + +@dataclass +class FileShare: + id: str + name: str + soft_delete_enabled: bool + retention_days: int diff --git a/prowler/providers/gcp/gcp_provider.py b/prowler/providers/gcp/gcp_provider.py index 789c434bb4..63c1c6d51d 100644 --- a/prowler/providers/gcp/gcp_provider.py +++ b/prowler/providers/gcp/gcp_provider.py @@ -515,9 +515,9 @@ class GcpProvider(Provider): credentials=session, ) - # Test the connection using the Service Usage API since it is enabled by default - client = discovery.build("serviceusage", "v1", credentials=session) - request = client.services().list(parent=f"projects/{project_id}") + # Test the connection using OAuth2 API to verify token validity + client = discovery.build("oauth2", "v2", credentials=session) + request = client.tokeninfo() request.execute() return Connection(is_connected=True) diff --git a/prowler/providers/github/github_provider.py b/prowler/providers/github/github_provider.py index d34b2b3e66..f217066929 100644 --- a/prowler/providers/github/github_provider.py +++ b/prowler/providers/github/github_provider.py @@ -4,6 +4,7 @@ from typing import Union from colorama import Fore, Style from github import Auth, Github, GithubIntegration +from github.GithubRetry import GithubRetry from prowler.config.config import ( default_config_file_path, @@ -300,9 +301,10 @@ class GithubProvider(Provider): credentials = self.session try: + retry_config = GithubRetry(total=3) if credentials.token: auth = Auth.Token(credentials.token) - g = Github(auth=auth) + g = Github(auth=auth, retry=retry_config) try: identity = GithubIdentityInfo( account_id=g.get_user().id, @@ -318,7 +320,7 @@ class GithubProvider(Provider): elif credentials.id != 0 and credentials.key: auth = Auth.AppAuth(credentials.id, credentials.key) - gi = GithubIntegration(auth=auth) + gi = GithubIntegration(auth=auth, retry=retry_config) try: identity = GithubAppIdentityInfo(app_id=gi.get_app().id) return identity diff --git a/prowler/providers/github/lib/service/service.py b/prowler/providers/github/lib/service/service.py index b9e4bf2974..911a22b975 100644 --- a/prowler/providers/github/lib/service/service.py +++ b/prowler/providers/github/lib/service/service.py @@ -1,4 +1,5 @@ from github import Auth, Github, GithubIntegration +from github.GithubRetry import GithubRetry from prowler.lib.logger import logger from prowler.providers.github.github_provider import GithubProvider @@ -20,16 +21,17 @@ class GithubService: def __set_clients__(self, session): clients = [] try: + retry_config = GithubRetry(total=3) if session.token: auth = Auth.Token(session.token) - clients = [Github(auth=auth)] + clients = [Github(auth=auth, retry=retry_config)] elif session.key and session.id: auth = Auth.AppAuth( session.id, session.key, ) - gi = GithubIntegration(auth=auth) + gi = GithubIntegration(auth=auth, retry=retry_config) for installation in gi.get_installations(): clients.append(installation.get_github_for_installation()) diff --git a/prowler/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled.py b/prowler/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled.py index 5b561927c3..145e4261e1 100644 --- a/prowler/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled.py +++ b/prowler/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled.py @@ -22,19 +22,13 @@ class repository_branch_delete_on_merge_enabled(Check): """ findings = [] for repo in repository_client.repositories.values(): - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" - report.status_extended = ( - f"Repository {repo.name} does not delete branches on merge." - ) + report.status_extended = f"Repository {repo.name} does not delete branches on merge in default branch ({repo.default_branch.name})." if repo.delete_branch_on_merge: report.status = "PASS" - report.status_extended = ( - f"Repository {repo.name} does delete branches on merge." - ) + report.status_extended = f"Repository {repo.name} does delete branches on merge in default branch ({repo.default_branch.name})." findings.append(report) diff --git a/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py b/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py index 12d504a926..bf615a21b6 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py +++ b/prowler/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled.py @@ -22,16 +22,14 @@ class repository_default_branch_deletion_disabled(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.default_branch_deletion is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + if repo.default_branch.branch_deletion is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = ( f"Repository {repo.name} does allow default branch deletion." ) - if not repo.default_branch_deletion: + if not repo.default_branch.branch_deletion: report.status = "PASS" report.status_extended = ( f"Repository {repo.name} does deny default branch deletion." diff --git a/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py b/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py index 2183b84a79..16eb7fa794 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py +++ b/prowler/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push.py @@ -22,20 +22,14 @@ class repository_default_branch_disallows_force_push(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.allow_force_pushes is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + if repo.default_branch.allow_force_pushes is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" - report.status_extended = ( - f"Repository {repo.name} does allow force push." - ) + report.status_extended = f"Repository {repo.name} does allow force pushes on default branch ({repo.default_branch.name})." - if not repo.allow_force_pushes: + if not repo.default_branch.allow_force_pushes: report.status = "PASS" - report.status_extended = ( - f"Repository {repo.name} does deny force push." - ) + report.status_extended = f"Repository {repo.name} does deny force pushes on default branch ({repo.default_branch.name})." findings.append(report) diff --git a/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py b/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py index 916ddd35c5..0a70cf0aea 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py +++ b/prowler/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins.py @@ -22,14 +22,12 @@ class repository_default_branch_protection_applies_to_admins(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.enforce_admins is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + if repo.default_branch.enforce_admins is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not enforce administrators to be subject to the same branch protection rules as other users." - if repo.enforce_admins: + if repo.default_branch.enforce_admins: report.status = "PASS" report.status_extended = f"Repository {repo.name} does enforce administrators to be subject to the same branch protection rules as other users." diff --git a/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py b/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py index a6c9be5a37..1348018ee1 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py +++ b/prowler/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled.py @@ -22,16 +22,14 @@ class repository_default_branch_protection_enabled(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.default_branch_protection is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + if repo.default_branch.protected is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" - report.status_extended = f"Repository {repo.name} does not enforce branch protection on default branch ({repo.default_branch})." + report.status_extended = f"Repository {repo.name} does not enforce branch protection on default branch ({repo.default_branch.name})." - if repo.default_branch_protection: + if repo.default_branch.protected: report.status = "PASS" - report.status_extended = f"Repository {repo.name} does enforce branch protection on default branch ({repo.default_branch})." + report.status_extended = f"Repository {repo.name} does enforce branch protection on default branch ({repo.default_branch.name})." findings.append(report) diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/__init__.py b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json new file mode 100644 index 0000000000..8c19b24818 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "github", + "CheckID": "repository_default_branch_requires_codeowners_review", + "CheckTitle": "Check if code owner approval is required for changes to owned code", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "github:user-id:repository/repository-name", + "Severity": "high", + "ResourceType": "GitHubRepository", + "Description": "Ensure that code owners are required to review and approve any proposed changes that affect their respective areas of ownership in the code base.", + "Risk": "If code owner approval is not required, unauthorized or unqualified individuals may merge changes to sensitive or critical areas of the codebase, increasing the risk of security vulnerabilities, bugs, or malicious modifications.", + "RelatedUrl": "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#requiring-code-owner-review", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "To require code owner review, navigate to the repository settings, click on 'Branches', add or edit a branch protection rule, and enable 'Require review from Code Owners'.", + "Url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-review-from-code-owners" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py new file mode 100644 index 0000000000..4c5b75010a --- /dev/null +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review.py @@ -0,0 +1,36 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.repository.repository_client import ( + repository_client, +) + + +class repository_default_branch_requires_codeowners_review(Check): + """Check if code owner approval is required for changes to owned code + + This class verifies whether each repository requires code owner review for changes to code they own. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the Github Repository Code Owner Approval Requirement check + + Iterates over all repositories and checks if they require code owner review for changes. + + Returns: + List[CheckReportGithub]: A list of reports for each repository + """ + findings = [] + for repo in repository_client.repositories.values(): + if repo.default_branch.require_code_owner_reviews is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) + if repo.default_branch.require_code_owner_reviews: + report.status = "PASS" + report.status_extended = f"Repository {repo.name} requires code owner approval for changes to owned code." + else: + report.status = "FAIL" + report.status_extended = f"Repository {repo.name} does not require code owner approval for changes to owned code." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py b/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py index 1bee931478..da33754cf5 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution.py @@ -22,20 +22,14 @@ class repository_default_branch_requires_conversation_resolution(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.conversation_resolution is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + if repo.default_branch.conversation_resolution is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" - report.status_extended = ( - f"Repository {repo.name} does not require conversation resolution." - ) + report.status_extended = f"Repository {repo.name} does not require conversation resolution on default branch ({repo.default_branch.name})." - if repo.conversation_resolution: + if repo.default_branch.conversation_resolution: report.status = "PASS" - report.status_extended = ( - f"Repository {repo.name} does require conversation resolution." - ) + report.status_extended = f"Repository {repo.name} does require conversation resolution on default branch ({repo.default_branch.name})." findings.append(report) diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py b/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py index a06c8b4a3f..29a0e51b51 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history.py @@ -22,16 +22,14 @@ class repository_default_branch_requires_linear_history(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.required_linear_history is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + if repo.default_branch.required_linear_history is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" - report.status_extended = f"Repository {repo.name} does not require linear history on default branch ({repo.default_branch})." + report.status_extended = f"Repository {repo.name} does not require linear history on default branch ({repo.default_branch.name})." - if repo.required_linear_history: + if repo.default_branch.required_linear_history: report.status = "PASS" - report.status_extended = f"Repository {repo.name} does require linear history on default branch ({repo.default_branch})." + report.status_extended = f"Repository {repo.name} does require linear history on default branch ({repo.default_branch.name})." findings.append(report) diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py b/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py index ac75115010..6312b98d02 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals.py @@ -22,14 +22,12 @@ class repository_default_branch_requires_multiple_approvals(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.approval_count is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + if repo.default_branch.approval_count is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = f"Repository {repo.name} does not enforce at least 2 approvals for code changes." - if repo.approval_count >= 2: + if repo.default_branch.approval_count >= 2: report.status = "PASS" report.status_extended = f"Repository {repo.name} does enforce at least 2 approvals for code changes." diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/__init__.py b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json new file mode 100644 index 0000000000..2b0ddb8c8a --- /dev/null +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "github", + "CheckID": "repository_default_branch_requires_signed_commits", + "CheckTitle": "Check if repository requires signed commits", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "github:user-id:repository/repository-name", + "Severity": "medium", + "ResourceType": "GitHubRepository", + "Description": "Ensure that every commit in a pull request is signed and verified before merging to the default branch.", + "Risk": "If repositories do not require signed commits, there is no way to verify the authenticity and integrity of code changes. This could allow malicious actors to impersonate legitimate contributors and introduce unauthorized or harmful changes to the codebase.", + "RelatedUrl": "https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable the 'Require signed commits' option in branch protection rules to ensure that all commits are cryptographically signed and verified before they can be merged.", + "Url": "https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-signed-commits" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py new file mode 100644 index 0000000000..4b6aa3ae4c --- /dev/null +++ b/prowler/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits.py @@ -0,0 +1,36 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.repository.repository_client import ( + repository_client, +) + + +class repository_default_branch_requires_signed_commits(Check): + """Check if a repository requires signed commits + + This class verifies whether each repository requires signed commits for the default branch. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the Github Repository Requires Signed Commits check + + Iterates over all repositories and checks if they require signed commits. + + Returns: + List[CheckReportGithub]: A list of reports for each repository + """ + findings = [] + for repo in repository_client.repositories.values(): + if repo.default_branch.require_signed_commits is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) + report.status = "FAIL" + report.status_extended = f"Repository {repo.name} does not require signed commits on default branch ({repo.default_branch.name})." + + if repo.default_branch.require_signed_commits: + report.status = "PASS" + report.status_extended = f"Repository {repo.name} does require signed commits on default branch ({repo.default_branch.name})." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py b/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py index 21d6b173da..e67b9def2c 100644 --- a/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py +++ b/prowler/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required.py @@ -22,16 +22,14 @@ class repository_default_branch_status_checks_required(Check): """ findings = [] for repo in repository_client.repositories.values(): - if repo.status_checks is not None: - report = CheckReportGithub( - self.metadata(), resource=repo, repository=repo.name - ) + if repo.default_branch.status_checks is not None: + report = CheckReportGithub(self.metadata(), resource=repo) report.status = "FAIL" report.status_extended = ( f"Repository {repo.name} does not enforce status checks." ) - if repo.status_checks: + if repo.default_branch.status_checks: report.status = "PASS" report.status_extended = ( f"Repository {repo.name} does enforce status checks." diff --git a/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/__init__.py b/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled.metadata.json b/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled.metadata.json new file mode 100644 index 0000000000..c245667ed1 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "github", + "CheckID": "repository_dependency_scanning_enabled", + "CheckTitle": "Check if package vulnerability scanning is enabled for dependencies in the repository", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "github:user-id:repository/repository-name", + "Severity": "high", + "ResourceType": "GitHubRepository", + "Description": "Implement scanning tools to detect, prevent, and monitor known open-source vulnerabilities in packages used within the organization's projects. This check verifies that dependency/package vulnerability scanning (e.g., Dependabot alerts) is enabled for the repository.", + "Risk": "If package vulnerability scanning is not enabled, known vulnerabilities in dependencies may go undetected, increasing the risk of exploitation and security breaches.", + "RelatedUrl": "https://docs.github.com/en/code-security/dependabot/dependabot-alerts/about-dependabot-alerts", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable Dependabot alerts or another package vulnerability scanner in the repository settings to automatically detect and alert on vulnerable dependencies.", + "Url": "https://docs.github.com/en/code-security/dependabot/dependabot-alerts/about-dependabot-alerts" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled.py b/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled.py new file mode 100644 index 0000000000..f983d7f901 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled.py @@ -0,0 +1,36 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.repository.repository_client import ( + repository_client, +) + + +class repository_dependency_scanning_enabled(Check): + """Check if package vulnerability scanning (Dependabot alerts) is enabled for dependencies in the repository + + This class verifies whether each repository has package vulnerability scanning enabled. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the Github Repository Package Vulnerabilities Scanner check + + Iterates over all repositories and checks if package vulnerability scanning is enabled. + + Returns: + List[CheckReportGithub]: A list of reports for each repository + """ + findings = [] + for repo in repository_client.repositories.values(): + if repo.dependabot_alerts_enabled is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) + if repo.dependabot_alerts_enabled: + report.status = "PASS" + report.status_extended = f"Repository {repo.name} has package vulnerability scanning (Dependabot alerts) enabled." + else: + report.status = "FAIL" + report.status_extended = f"Repository {repo.name} does not have package vulnerability scanning (Dependabot alerts) enabled." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/repository/repository_has_codeowners_file/__init__.py b/prowler/providers/github/services/repository/repository_has_codeowners_file/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.metadata.json b/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.metadata.json new file mode 100644 index 0000000000..58cb6ce8d2 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "github", + "CheckID": "repository_has_codeowners_file", + "CheckTitle": "Check if repositories have a CODEOWNERS file", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "github:user-id:repository/repository-name", + "Severity": "high", + "ResourceType": "GitHubRepository", + "Description": "Ensure that repositories have a CODEOWNERS file.", + "Risk": "Not having a CODEOWNERS file in a repository may lead to unclear code ownership and review responsibilities, increasing the risk of unreviewed or unauthorized changes.", + "RelatedUrl": "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Add a CODEOWNERS file to the root, .github/, or docs/ directory of the repository. The file should specify code owners for files and directories as appropriate for your organization.", + "Url": "https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py b/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py new file mode 100644 index 0000000000..7120e0411a --- /dev/null +++ b/prowler/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file.py @@ -0,0 +1,40 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.repository.repository_client import ( + repository_client, +) + + +class repository_has_codeowners_file(Check): + """Check if a repository has a CODEOWNERS file + + This class verifies whether each repository has a CODEOWNERS file. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the Github Repository CODEOWNERS file existence check + + Iterates over all repositories and checks if they have a CODEOWNERS file. + + Returns: + List[CheckReportGithub]: A list of reports for each repository + """ + findings = [] + for repo in repository_client.repositories.values(): + if repo.codeowners_exists is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) + if repo.codeowners_exists: + report.status = "PASS" + report.status_extended = ( + f"Repository {repo.name} does have a CODEOWNERS file." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Repository {repo.name} does not have a CODEOWNERS file." + ) + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/repository/repository_inactive_not_archived/__init__.py b/prowler/providers/github/services/repository/repository_inactive_not_archived/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived.metadata.json b/prowler/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived.metadata.json new file mode 100644 index 0000000000..07395fe6b7 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "github", + "CheckID": "repository_inactive_not_archived", + "CheckTitle": "Check for inactive repositories that are not archived", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "GitHubRepository", + "Description": "Ensure that repositories with no activity are reviewed and considered for archival. Inactive repositories may have outdated dependencies or security configurations that could pose security risks.", + "Risk": "Inactive repositories that are not archived may contain outdated dependencies, unpatched vulnerabilities, or misconfigured security settings. These repositories increase the attack surface and could be targeted by malicious actors.", + "RelatedUrl": "https://docs.github.com/en/repositories/archiving-a-github-repository/archiving-repositories", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Review inactive repositories and either: 1) Archive them if they are no longer needed, 2) Update their dependencies and security configurations if they are still required, or 3) Delete them if they contain no valuable information.", + "Url": "https://docs.github.com/en/repositories/archiving-a-github-repository/archiving-repositories" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived.py b/prowler/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived.py new file mode 100644 index 0000000000..c6426fa194 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived.py @@ -0,0 +1,43 @@ +from datetime import datetime, timezone +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.repository.repository_client import ( + repository_client, +) + + +class repository_inactive_not_archived(Check): + """Check if unarchived repositories have been inactive for more than 6 months.""" + + def execute(self) -> List[CheckReportGithub]: + findings = [] + + now = datetime.now(timezone.utc) + + days_threshold = repository_client.audit_config.get( + "inactive_not_archived_days_threshold", 180 + ) + + for repo in repository_client.repositories.values(): + report = CheckReportGithub(metadata=self.metadata(), resource=repo) + + if repo.archived: + report.status = "PASS" + report.status_extended = f"Repository {repo.name} is properly archived." + findings.append(report) + continue + + latest_activity = repo.pushed_at + days_inactive = (now - latest_activity).days + + if days_inactive >= days_threshold: + report.status = "FAIL" + report.status_extended = f"Repository {repo.name} has been inactive for {days_inactive} days and is not archived (threshold: {days_threshold} days)." + else: + report.status = "PASS" + report.status_extended = f"Repository {repo.name} has been active within the last {days_threshold} days ({days_inactive} days ago)." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.py b/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.py index c1ec7b51e4..3de966ed25 100644 --- a/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.py +++ b/prowler/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file.py @@ -23,9 +23,7 @@ class repository_public_has_securitymd_file(Check): findings = [] for repo in repository_client.repositories.values(): if not repo.private and repo.securitymd is not None: - report = CheckReportGithub( - metadata=self.metadata(), resource=repo, repository=repo.name - ) + report = CheckReportGithub(metadata=self.metadata(), resource=repo) report.status = "PASS" report.status_extended = ( f"Repository {repo.name} does have a SECURITY.md file." diff --git a/prowler/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled.metadata.json b/prowler/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled.metadata.json new file mode 100644 index 0000000000..4f9f07cc26 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "github", + "CheckID": "repository_secret_scanning_enabled", + "CheckTitle": "Check if secret scanning is enabled to detect sensitive data in the repository", + "CheckType": [], + "ServiceName": "repository", + "SubServiceName": "", + "ResourceIdTemplate": "github:user-id:repository/repository-name", + "Severity": "high", + "ResourceType": "GitHubRepository", + "Description": "Ensure that scanners are in place to detect and prevent sensitive data, such as confidential ID numbers, passwords, and other sensitive information, from being committed in the source code. This check verifies that secret scanning is enabled to identify and prevent sensitive data from being included in the repository.", + "Risk": "If secret scanning is not enabled, sensitive data may be inadvertently committed to the repository, increasing the risk of data breaches and exploitation by attackers.", + "RelatedUrl": "https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable secret scanning in the repository settings to automatically detect and prevent sensitive data from being committed to the codebase.", + "Url": "https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled.py b/prowler/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled.py new file mode 100644 index 0000000000..57d09be486 --- /dev/null +++ b/prowler/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled.py @@ -0,0 +1,36 @@ +from typing import List + +from prowler.lib.check.models import Check, CheckReportGithub +from prowler.providers.github.services.repository.repository_client import ( + repository_client, +) + + +class repository_secret_scanning_enabled(Check): + """Check if secret scanning is enabled to detect sensitive data in the repository + + This class verifies whether each repository has secret scanning enabled. + """ + + def execute(self) -> List[CheckReportGithub]: + """Execute the Github Repository Secret Scanning check + + Iterates over all repositories and checks if secret scanning is enabled. + + Returns: + List[CheckReportGithub]: A list of reports for each repository + """ + findings = [] + for repo in repository_client.repositories.values(): + if repo.secret_scanning_enabled is not None: + report = CheckReportGithub(metadata=self.metadata(), resource=repo) + if getattr(repo, "secret_scanning_enabled", None): + report.status = "PASS" + report.status_extended = f"Repository {repo.name} has secret scanning enabled to detect sensitive data." + else: + report.status = "FAIL" + report.status_extended = f"Repository {repo.name} does not have secret scanning enabled to detect sensitive data." + + findings.append(report) + + return findings diff --git a/prowler/providers/github/services/repository/repository_service.py b/prowler/providers/github/services/repository/repository_service.py index 571dda3429..6c0101503b 100644 --- a/prowler/providers/github/services/repository/repository_service.py +++ b/prowler/providers/github/services/repository/repository_service.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Optional from pydantic import BaseModel @@ -11,6 +12,19 @@ class Repository(GithubService): super().__init__(__class__.__name__, provider) self.repositories = self._list_repositories() + def _file_exists(self, repo, filename): + """Check if a file exists in the repository. Returns True if exists, False if not, None if error.""" + try: + return repo.get_contents(filename) is not None + except Exception as error: + if "404" in str(error): + return False + else: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + def _list_repositories(self): logger.info("Repository - Listing Repositories...") repos = {} @@ -18,22 +32,28 @@ class Repository(GithubService): for client in self.clients: for repo in client.get_user().get_repos(): default_branch = repo.default_branch + securitymd_exists = self._file_exists(repo, "SECURITY.md") + # CODEOWNERS file can be in .github/, root, or docs/ + # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-location + codeowners_paths = [ + ".github/CODEOWNERS", + "CODEOWNERS", + "docs/CODEOWNERS", + ] + codeowners_files = [ + self._file_exists(repo, path) for path in codeowners_paths + ] + if True in codeowners_files: + codeowners_exists = True + elif all(file is None for file in codeowners_files): + codeowners_exists = None + else: + codeowners_exists = False delete_branch_on_merge = ( repo.delete_branch_on_merge if repo.delete_branch_on_merge is not None else False ) - securitymd_exists = False - try: - securitymd_exists = repo.get_contents("SECURITY.md") is not None - except Exception as error: - if "404" in str(error): - securitymd_exists = False - else: - logger.error( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - securitymd_exists = None require_pr = False approval_cnt = 0 @@ -41,6 +61,8 @@ class Repository(GithubService): required_linear_history = False allow_force_pushes = True branch_deletion = True + require_code_owner_reviews = False + require_signed_commits = False status_checks = False enforce_admins = False conversation_resolution = False @@ -70,6 +92,14 @@ class Repository(GithubService): protection.required_conversation_resolution ) branch_protection = True + require_code_owner_reviews = ( + protection.required_pull_request_reviews.require_code_owner_reviews + if require_pr + else False + ) + require_signed_commits = ( + branch.get_required_signatures() + ) except Exception as error: # If the branch is not found, it is not protected if "404" in str(error): @@ -84,6 +114,8 @@ class Repository(GithubService): required_linear_history = None allow_force_pushes = None branch_deletion = None + require_code_owner_reviews = None + require_signed_commits = None status_checks = None enforce_admins = None conversation_resolution = None @@ -91,22 +123,68 @@ class Repository(GithubService): f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) + secret_scanning_enabled = False + dependabot_alerts_enabled = False + try: + if ( + repo.security_and_analysis + and repo.security_and_analysis.secret_scanning + ): + secret_scanning_enabled = ( + repo.security_and_analysis.secret_scanning.status + == "enabled" + ) + try: + # Use get_dependabot_alerts to check if Dependabot alerts are enabled + repo.get_dependabot_alerts().totalCount + # If the call succeeds, Dependabot is enabled (even if no alerts) + dependabot_alerts_enabled = True + except Exception as error: + error_str = str(error) + if ( + "403" in error_str + and "Dependabot alerts are disabled for this repository." + in error_str + ): + dependabot_alerts_enabled = False + else: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + dependabot_alerts_enabled = None + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + secret_scanning_enabled = None + dependabot_alerts_enabled = None repos[repo.id] = Repo( id=repo.id, name=repo.name, + owner=repo.owner.login, full_name=repo.full_name, - default_branch=repo.default_branch, + default_branch=Branch( + name=default_branch, + protected=branch_protection, + default_branch=True, + require_pull_request=require_pr, + approval_count=approval_cnt, + required_linear_history=required_linear_history, + allow_force_pushes=allow_force_pushes, + branch_deletion=branch_deletion, + status_checks=status_checks, + enforce_admins=enforce_admins, + conversation_resolution=conversation_resolution, + require_code_owner_reviews=require_code_owner_reviews, + require_signed_commits=require_signed_commits, + ), private=repo.private, + archived=repo.archived, + pushed_at=repo.pushed_at, securitymd=securitymd_exists, - require_pull_request=require_pr, - approval_count=approval_cnt, - required_linear_history=required_linear_history, - allow_force_pushes=allow_force_pushes, - default_branch_deletion=branch_deletion, - status_checks=status_checks, - enforce_admins=enforce_admins, - conversation_resolution=conversation_resolution, - default_branch_protection=branch_protection, + codeowners_exists=codeowners_exists, + secret_scanning_enabled=secret_scanning_enabled, + dependabot_alerts_enabled=dependabot_alerts_enabled, delete_branch_on_merge=delete_branch_on_merge, ) @@ -117,22 +195,37 @@ class Repository(GithubService): return repos +class Branch(BaseModel): + """Model for Github Branch""" + + name: str + protected: bool + default_branch: bool + require_pull_request: Optional[bool] + approval_count: Optional[int] + required_linear_history: Optional[bool] + allow_force_pushes: Optional[bool] + branch_deletion: Optional[bool] + status_checks: Optional[bool] + enforce_admins: Optional[bool] + require_code_owner_reviews: Optional[bool] + require_signed_commits: Optional[bool] + conversation_resolution: Optional[bool] + + class Repo(BaseModel): """Model for Github Repository""" id: int name: str + owner: str full_name: str - default_branch_protection: Optional[bool] - default_branch: str + default_branch: Branch private: bool + archived: bool + pushed_at: datetime securitymd: Optional[bool] - require_pull_request: Optional[bool] - required_linear_history: Optional[bool] - allow_force_pushes: Optional[bool] - default_branch_deletion: Optional[bool] - status_checks: Optional[bool] - enforce_admins: Optional[bool] - approval_count: Optional[int] + codeowners_exists: Optional[bool] + secret_scanning_enabled: Optional[bool] + dependabot_alerts_enabled: Optional[bool] delete_branch_on_merge: Optional[bool] - conversation_resolution: Optional[bool] diff --git a/prowler/providers/kubernetes/kubernetes_provider.py b/prowler/providers/kubernetes/kubernetes_provider.py index 20b019780a..2572b5be88 100644 --- a/prowler/providers/kubernetes/kubernetes_provider.py +++ b/prowler/providers/kubernetes/kubernetes_provider.py @@ -262,6 +262,12 @@ class KubernetesProvider(Provider): context = context_item else: context = config_data.get("contexts", [])[0] + + return KubernetesSession( + api_client=ApiClient(KubernetesProvider.set_proxy_settings()), + context=context, + ) + else: logger.info(f"Using kubeconfig file: {kubeconfig_file}...") kubeconfig_file = ( @@ -287,19 +293,10 @@ class KubernetesProvider(Provider): "user": "service-account-name", }, } - # Ensure proxy settings are respected - configuration = Configuration.get_default_copy() - proxy = os.environ.get("HTTPS_PROXY") or os.environ.get( - "https_proxy" - ) - if proxy: - configuration.proxy = proxy - # Prevent SSL verification issues with internal proxies - if os.environ.get("K8S_SKIP_TLS_VERIFY", "false").lower() == "true": - configuration.verify_ssl = False return KubernetesSession( - api_client=ApiClient(configuration), context=context + api_client=ApiClient(KubernetesProvider.set_proxy_settings()), + context=context, ) if context: @@ -644,3 +641,18 @@ class KubernetesProvider(Provider): f"{Style.BRIGHT}Using the Kubernetes credentials below:{Style.RESET_ALL}" ) print_boxes(report_lines, report_title) + + @staticmethod + def set_proxy_settings() -> Configuration: + """ + Returns the proxy settings respecting client's configuration from HTTPS_PROXY or K8S_SKIP_TLS_VERIFY. + """ + configuration = Configuration.get_default_copy() + proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy") + if proxy: + configuration.proxy = proxy + # Prevent SSL verification issues with internal proxies + if os.environ.get("K8S_SKIP_TLS_VERIFY", "false").lower() == "true": + configuration.verify_ssl = False + + return configuration diff --git a/prowler/providers/kubernetes/services/apiserver/apiserver_strong_ciphers_only/apiserver_strong_ciphers_only.py b/prowler/providers/kubernetes/services/apiserver/apiserver_strong_ciphers_only/apiserver_strong_ciphers_only.py index afe2420ce2..781e895324 100644 --- a/prowler/providers/kubernetes/services/apiserver/apiserver_strong_ciphers_only/apiserver_strong_ciphers_only.py +++ b/prowler/providers/kubernetes/services/apiserver/apiserver_strong_ciphers_only/apiserver_strong_ciphers_only.py @@ -23,16 +23,14 @@ class apiserver_strong_ciphers_only(Check): # Check if strong ciphers are set in "--tls-cipher-suites" for command in container.command: if command.startswith("--tls-cipher-suites"): - if ( - command.split("=")[1] - .split(",") - .issubset( - apiserver_client.audit_config.get( - "apiserver_strong_ciphers", - default_apiserver_strong_ciphers, - ) + configured_ciphers = set(command.split("=")[1].split(",")) + allowed_ciphers = set( + apiserver_client.audit_config.get( + "apiserver_strong_ciphers", + default_apiserver_strong_ciphers, ) - ): + ) + if configured_ciphers.issubset(allowed_ciphers): strong_ciphers_set = True if not strong_ciphers_set: break diff --git a/prowler/providers/m365/exceptions/exceptions.py b/prowler/providers/m365/exceptions/exceptions.py index 7d599386f9..fe2b8b442c 100644 --- a/prowler/providers/m365/exceptions/exceptions.py +++ b/prowler/providers/m365/exceptions/exceptions.py @@ -106,9 +106,9 @@ class M365BaseException(ProwlerException): "message": "The provided User is not valid.", "remediation": "Check the User and ensure it is a valid user.", }, - (6025, "M365NotValidEncryptedPasswordError"): { - "message": "The provided Encrypted Password is not valid.", - "remediation": "Check the Encrypted Password and ensure it is a valid password.", + (6025, "M365NotValidPasswordError"): { + "message": "The provided Password is not valid.", + "remediation": "Check the Password and ensure it is a valid password.", }, (6026, "M365UserNotBelongingToTenantError"): { "message": "The provided User does not belong to the specified tenant.", @@ -312,7 +312,7 @@ class M365NotValidUserError(M365CredentialsError): ) -class M365NotValidEncryptedPasswordError(M365CredentialsError): +class M365NotValidPasswordError(M365CredentialsError): def __init__(self, file=None, original_exception=None, message=None): super().__init__( 6025, file=file, original_exception=original_exception, message=message diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py index a91e25d52d..fede39c401 100644 --- a/prowler/providers/m365/lib/powershell/m365_powershell.py +++ b/prowler/providers/m365/lib/powershell/m365_powershell.py @@ -1,4 +1,5 @@ import os +import platform import msal @@ -64,17 +65,59 @@ class M365PowerShell(PowerShellSession): The credentials are sanitized to prevent command injection and stored securely in the PowerShell session. """ + + credentials.encrypted_passwd = self.encrypt_password(credentials.passwd) + # Sanitize user and password - user = self.sanitize(credentials.user) - passwd = self.sanitize(credentials.passwd) + sanitized_user = self.sanitize(credentials.user) + sanitized_encrypted_passwd = self.sanitize(credentials.encrypted_passwd) # Securely convert encrypted password to SecureString - self.execute(f'$user = "{user}"') - self.execute(f'$secureString = "{passwd}" | ConvertTo-SecureString') + self.execute(f'$user = "{sanitized_user}"') + self.execute( + f'$secureString = "{sanitized_encrypted_passwd}" | ConvertTo-SecureString' + ) self.execute( "$credential = New-Object System.Management.Automation.PSCredential ($user, $secureString)" ) + def encrypt_password(self, password: str) -> str: + """ + Encrypts a password using Windows CryptProtectData on Windows systems + or UTF-16LE encoding on other systems. + + Args: + password (str): The password to encrypt + + Returns: + str: The encrypted password in hexadecimal format + + Raises: + ValueError: If password is None or empty + """ + try: + if platform.system() == "Windows": + import win32crypt + + encrypted_blob = win32crypt.CryptProtectData( + password.encode("utf-16le"), None, None, None, None, 0 + ) + + encrypted_bytes = encrypted_blob + if isinstance(encrypted_blob, tuple): + encrypted_bytes = encrypted_blob[1] + elif hasattr(encrypted_blob, "data"): + encrypted_bytes = encrypted_blob.data + + return encrypted_bytes.hex() + + else: + return password.encode("utf-16le").hex() + except Exception as error: + raise Exception( + f"[{os.path.basename(__file__)}] Error encrypting password: {str(error)}" + ) + def test_credentials(self, credentials: M365Credentials) -> bool: """ Test Microsoft 365 credentials by attempting to authenticate against Entra ID. @@ -87,14 +130,11 @@ class M365PowerShell(PowerShellSession): bool: True if credentials are valid and authentication succeeds, False otherwise. """ self.execute( - f'$securePassword = "{self.sanitize(credentials.passwd)}" | ConvertTo-SecureString' + f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' # encrypted password already sanitized ) self.execute( f'$credential = New-Object System.Management.Automation.PSCredential("{self.sanitize(credentials.user)}", $securePassword)' ) - decrypted_password = self.execute( - 'Write-Output "$($credential.GetNetworkCredential().Password)"' - ) # Validate user belongs to tenant user_domain = credentials.user.split("@")[1] @@ -116,7 +156,7 @@ class M365PowerShell(PowerShellSession): # Validate credentials result = app.acquire_token_by_username_password( username=credentials.user, - password=decrypted_password, # Needs to be in plain text + password=credentials.passwd, scopes=["https://graph.microsoft.com/.default"], ) diff --git a/prowler/providers/m365/m365_provider.py b/prowler/providers/m365/m365_provider.py index a4135b0692..cf647ce978 100644 --- a/prowler/providers/m365/m365_provider.py +++ b/prowler/providers/m365/m365_provider.py @@ -43,7 +43,7 @@ from prowler.providers.m365.exceptions.exceptions import ( M365NotTenantIdButClientIdAndClientSecretError, M365NotValidClientIdError, M365NotValidClientSecretError, - M365NotValidEncryptedPasswordError, + M365NotValidPasswordError, M365NotValidTenantIdError, M365NotValidUserError, M365SetUpRegionConfigError, @@ -117,7 +117,7 @@ class M365Provider(Provider): client_id: str = None, client_secret: str = None, user: str = None, - encrypted_password: str = None, + password: str = None, init_modules: bool = False, region: str = "M365Global", config_content: dict = None, @@ -164,7 +164,7 @@ class M365Provider(Provider): client_id, client_secret, user, - encrypted_password, + password, ) logger.info("Checking if region is different than default one") @@ -172,13 +172,13 @@ class M365Provider(Provider): # Get the dict from the static credentials m365_credentials = None - if tenant_id and client_id and client_secret and user and encrypted_password: + if tenant_id and client_id and client_secret and user and password: m365_credentials = self.validate_static_credentials( tenant_id=tenant_id, client_id=client_id, client_secret=client_secret, user=user, - encrypted_password=encrypted_password, + password=password, ) # Set up the M365 session @@ -282,7 +282,7 @@ class M365Provider(Provider): client_id: str, client_secret: str, user: str, - encrypted_password: str, + password: str, ): """ Validates the authentication arguments for the M365 provider. @@ -296,7 +296,7 @@ class M365Provider(Provider): client_id (str): The M365 Client ID. client_secret (str): The M365 Client Secret. user (str): The M365 User Account. - encrpted_password (str): The M365 Encrypted Password. + password (str): The M365 User Password. Raises: M365BrowserAuthNoTenantIDError: If browser authentication is enabled but the tenant ID is not found. @@ -324,10 +324,10 @@ class M365Provider(Provider): message="M365 Tenant ID (--tenant-id) is required for browser authentication mode", ) elif env_auth: - if not user or not encrypted_password or not tenant_id: + if not user or not password or not tenant_id: raise M365MissingEnvironmentCredentialsError( file=os.path.basename(__file__), - message="M365 provider requires AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID, M365_USER and M365_ENCRYPTED_PASSWORD environment variables to be set when using --env-auth", + message="M365 provider requires AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID, M365_USER and M365_PASSWORD environment variables to be set when using --env-auth", ) else: if not tenant_id: @@ -396,7 +396,7 @@ class M365Provider(Provider): if m365_credentials: credentials = M365Credentials( user=m365_credentials.get("user", ""), - passwd=m365_credentials.get("encrypted_password", ""), + passwd=m365_credentials.get("password", ""), client_id=m365_credentials.get("client_id", ""), client_secret=m365_credentials.get("client_secret", ""), tenant_id=m365_credentials.get("tenant_id", ""), @@ -404,18 +404,18 @@ class M365Provider(Provider): ) elif env_auth: m365_user = getenv("M365_USER") - m365_password = getenv("M365_ENCRYPTED_PASSWORD") + m365_password = getenv("M365_PASSWORD") client_id = getenv("AZURE_CLIENT_ID") client_secret = getenv("AZURE_CLIENT_SECRET") tenant_id = getenv("AZURE_TENANT_ID") if not m365_user or not m365_password: logger.critical( - "M365 provider: Missing M365_USER or M365_ENCRYPTED_PASSWORD environment variables needed for credentials authentication" + "M365 provider: Missing M365_USER or M365_PASSWORD environment variables needed for credentials authentication" ) raise M365MissingEnvironmentCredentialsError( file=os.path.basename(__file__), - message="Missing M365_USER or M365_ENCRYPTED_PASSWORD environment variables required for credentials authentication.", + message="Missing M365_USER or M365_PASSWORD environment variables required for credentials authentication.", ) credentials = M365Credentials( client_id=client_id, @@ -494,7 +494,7 @@ class M365Provider(Provider): - client_id: The M365 client ID. - client_secret: The M365 client secret - user: The M365 user email - - encrypted_password: The M365 encrypted password + - password: The M365 user password - provider_id: The M365 provider ID (in this case the Tenant ID). region_config (M365RegionConfig): The region configuration object. @@ -621,7 +621,7 @@ class M365Provider(Provider): client_id: str = None, client_secret: str = None, user: str = None, - encrypted_password: str = None, + password: str = None, provider_id: str = None, ) -> Connection: """Test connection to M365 tenant and PowerShell modules. @@ -640,7 +640,7 @@ class M365Provider(Provider): client_id (str): The M365 client ID. client_secret (str): The M365 client secret. user (str): The M365 user email. - encrypted_password (str): The M365 encrypted_password. + password (str): The M365 password. provider_id (str): The M365 provider ID (in this case the Tenant ID). @@ -674,20 +674,20 @@ class M365Provider(Provider): client_id, client_secret, user, - encrypted_password, + password, ) region_config = M365Provider.setup_region_config(region) # Get the dict from the static credentials m365_credentials = None if tenant_id and client_id and client_secret: - if not user and not encrypted_password: + if not user and not password: m365_credentials = M365Provider.validate_static_credentials( tenant_id=tenant_id, client_id=client_id, client_secret=client_secret, user="user", - encrypted_password="encrypted_password", + password="password", ) else: m365_credentials = M365Provider.validate_static_credentials( @@ -695,7 +695,7 @@ class M365Provider(Provider): client_id=client_id, client_secret=client_secret, user=user, - encrypted_password=encrypted_password, + password=password, ) # Set up the M365 session @@ -733,7 +733,7 @@ class M365Provider(Provider): ) # Set up PowerShell credentials - if user and encrypted_password: + if user and password: M365Provider.setup_powershell( env_auth, m365_credentials, @@ -975,7 +975,7 @@ class M365Provider(Provider): client_id: str = None, client_secret: str = None, user: str = None, - encrypted_password: str = None, + password: str = None, ) -> dict: """ Validates the static credentials for the M365 provider. @@ -985,7 +985,7 @@ class M365Provider(Provider): client_id (str): The M365 client ID. client_secret (str): The M365 client secret. user (str): The M365 user email. - encrypted_password (str): The M365 encrypted password. + password (str): The M365 user password. Raises: M365NotValidTenantIdError: If the provided M365 Tenant ID is not valid. @@ -1030,11 +1030,11 @@ class M365Provider(Provider): message="The provided User is not valid.", ) - # Validate the Encrypted Password - if not encrypted_password: - raise M365NotValidEncryptedPasswordError( + # Validate the Password + if not password: + raise M365NotValidPasswordError( file=os.path.basename(__file__), - message="The provided Encrypted Password is not valid.", + message="The provided Password is not valid.", ) try: @@ -1044,7 +1044,7 @@ class M365Provider(Provider): "client_id": client_id, "client_secret": client_secret, "user": user, - "encrypted_password": encrypted_password, + "password": password, } except M365NotValidTenantIdError as tenant_id_error: logger.error( diff --git a/prowler/providers/m365/models.py b/prowler/providers/m365/models.py index d0d73263b5..f77bc8ee4c 100644 --- a/prowler/providers/m365/models.py +++ b/prowler/providers/m365/models.py @@ -22,12 +22,13 @@ class M365RegionConfig(BaseModel): class M365Credentials(BaseModel): + user: str = "" + passwd: str = "" + encrypted_passwd: str = "" client_id: str = "" client_secret: str = "" tenant_id: str = "" tenant_domains: list[str] = [] - user: str = "" - passwd: str = "" class M365OutputOptions(ProviderOutputOptions): diff --git a/prowler/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility.py b/prowler/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility.py index d61fdec68d..f38f94f64f 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility.py +++ b/prowler/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility.py @@ -36,7 +36,7 @@ class admincenter_groups_not_public_visibility(Check): report.status = "FAIL" report.status_extended = f"Group {group.name} has {group.visibility} visibility and should be Private." - if group.visibility != "Public": + if group.visibility and group.visibility != "Public": report.status = "PASS" report.status_extended = ( f"Group {group.name} has {group.visibility} visibility." diff --git a/prowler/providers/m365/services/admincenter/admincenter_service.py b/prowler/providers/m365/services/admincenter/admincenter_service.py index bb1cad48ba..32b2b12805 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_service.py +++ b/prowler/providers/m365/services/admincenter/admincenter_service.py @@ -11,6 +11,7 @@ from prowler.providers.m365.m365_provider import M365Provider class AdminCenter(M365Service): def __init__(self, provider: M365Provider): super().__init__(provider) + self.organization_config = None self.sharing_policy = None if self.powershell: @@ -200,7 +201,7 @@ class DirectoryRole(BaseModel): class Group(BaseModel): id: str name: str - visibility: str + visibility: Optional[str] class Domain(BaseModel): diff --git a/prowler/providers/m365/services/admincenter/admincenter_users_admins_reduced_license_footprint/admincenter_users_admins_reduced_license_footprint.py b/prowler/providers/m365/services/admincenter/admincenter_users_admins_reduced_license_footprint/admincenter_users_admins_reduced_license_footprint.py index 7497feb305..e97f42cd80 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_users_admins_reduced_license_footprint/admincenter_users_admins_reduced_license_footprint.py +++ b/prowler/providers/m365/services/admincenter/admincenter_users_admins_reduced_license_footprint/admincenter_users_admins_reduced_license_footprint.py @@ -21,7 +21,7 @@ class admincenter_users_admins_reduced_license_footprint(Check): """Execute the check for users with administrative roles and their licenses. This method iterates over all users and checks if those with administrative roles - have an allowed license. If a user has a valid license (AAD_PREMIUM or AAD_PREMIUM_P2), + have an allowed license. If a user has a valid license (AAD_PREMIUM or AAD_PREMIUM_P2) or no license, the check passes; otherwise, it fails. Returns: @@ -45,7 +45,7 @@ class admincenter_users_admins_reduced_license_footprint(Check): resource_name=user.name, resource_id=user.id, ) - report.status = "FAIL" + report.status = "PASS" report.status_extended = f"User {user.name} has administrative roles {admin_roles} and does not have a license." if user.license: diff --git a/prowler/providers/m365/services/entra/entra_identity_protection_sign_in_risk_enabled/entra_identity_protection_sign_in_risk_enabled.py b/prowler/providers/m365/services/entra/entra_identity_protection_sign_in_risk_enabled/entra_identity_protection_sign_in_risk_enabled.py index c9878c8315..6b8db3230f 100644 --- a/prowler/providers/m365/services/entra/entra_identity_protection_sign_in_risk_enabled/entra_identity_protection_sign_in_risk_enabled.py +++ b/prowler/providers/m365/services/entra/entra_identity_protection_sign_in_risk_enabled/entra_identity_protection_sign_in_risk_enabled.py @@ -9,13 +9,13 @@ from prowler.providers.m365.services.entra.entra_service import ( class entra_identity_protection_sign_in_risk_enabled(Check): - """Check if at least one Conditional Access policy is a Identity Protection sign-in risk policy. + """Check if at least one Conditional Access policy is an Identity Protection sign-in risk policy. - This check ensures that at least one Conditional Access policy is a Identity Protection sign-in risk policy. + This check ensures that at least one Conditional Access policy is an Identity Protection sign-in risk policy. """ def execute(self) -> list[CheckReportM365]: - """Execute the check to ensure that at least one Conditional Access policy is a Identity Protection sign-in risk policy. + """Execute the check to ensure that at least one Conditional Access policy is an Identity Protection sign-in risk policy. Returns: list[CheckReportM365]: A list containing the results of the check. diff --git a/prowler/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled.py b/prowler/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled.py index 8864663e68..b27f6dcf3e 100644 --- a/prowler/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled.py +++ b/prowler/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled.py @@ -9,13 +9,13 @@ from prowler.providers.m365.services.entra.entra_service import ( class entra_identity_protection_user_risk_enabled(Check): - """Check if at least one Conditional Access policy is a Identity Protection user risk policy. + """Check if at least one Conditional Access policy is an Identity Protection user risk policy. - This check ensures that at least one Conditional Access policy is a Identity Protection user risk policy. + This check ensures that at least one Conditional Access policy is an Identity Protection user risk policy. """ def execute(self) -> list[CheckReportM365]: - """Execute the check to ensure that at least one Conditional Access policy is a Identity Protection user risk policy. + """Execute the check to ensure that at least one Conditional Access policy is an Identity Protection user risk policy. Returns: list[CheckReportM365]: A list containing the results of the check. @@ -29,7 +29,7 @@ class entra_identity_protection_user_risk_enabled(Check): resource_id="conditionalAccessPolicies", ) report.status = "FAIL" - report.status_extended = "No Conditional Access Policy is an user risk based Identity Protection Policy." + report.status_extended = "No Conditional Access Policy is a user risk based Identity Protection Policy." for policy in entra_client.conditional_access_policies.values(): if policy.state == ConditionalAccessPolicyState.DISABLED: @@ -62,13 +62,13 @@ class entra_identity_protection_user_risk_enabled(Check): ) if RiskLevel.HIGH not in policy.conditions.user_risk_levels: report.status = "FAIL" - report.status_extended = f"Conditional Access Policy '{policy.display_name}' is an user risk based Identity Protection Policy but does not protect against high risk potential account compromises." + report.status_extended = f"Conditional Access Policy '{policy.display_name}' is a user risk based Identity Protection Policy but does not protect against high risk potential account compromises." elif policy.state == ConditionalAccessPolicyState.ENABLED_FOR_REPORTING: report.status = "FAIL" - report.status_extended = f"Conditional Access Policy '{policy.display_name}' is an user risk based Identity Protection Policy and reports high risk potential account compromises, but does not protect against them." + report.status_extended = f"Conditional Access Policy '{policy.display_name}' is a user risk based Identity Protection Policy and reports high risk potential account compromises, but does not protect against them." else: report.status = "PASS" - report.status_extended = f"Conditional Access Policy '{policy.display_name}' is an user risk based Identity Protection Policy and does protect against high risk potential account compromises." + report.status_extended = f"Conditional Access Policy '{policy.display_name}' is a user risk based Identity Protection Policy and does protect against high risk potential account compromises." break findings.append(report) diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index 3e548afdba..05e152bf2d 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -14,6 +14,8 @@ from prowler.providers.m365.m365_provider import M365Provider class Entra(M365Service): def __init__(self, provider: M365Provider): super().__init__(provider) + if self.powershell: + self.powershell.close() loop = get_event_loop() self.tenant_domain = provider.identity.tenant_domain @@ -491,6 +493,7 @@ class ConditionalAccessGrantControl(Enum): BLOCK = "block" DOMAIN_JOINED_DEVICE = "domainJoinedDevice" PASSWORD_CHANGE = "passwordChange" + COMPLIANT_DEVICE = "compliantDevice" class GrantControlOperator(Enum): diff --git a/prowler/providers/m365/services/sharepoint/sharepoint_service.py b/prowler/providers/m365/services/sharepoint/sharepoint_service.py index 02a701821f..244f27521f 100644 --- a/prowler/providers/m365/services/sharepoint/sharepoint_service.py +++ b/prowler/providers/m365/services/sharepoint/sharepoint_service.py @@ -13,6 +13,9 @@ from prowler.providers.m365.m365_provider import M365Provider class SharePoint(M365Service): def __init__(self, provider: M365Provider): super().__init__(provider) + if self.powershell: + self.powershell.close() + loop = get_event_loop() self.tenant_domain = provider.identity.tenant_domain attributes = loop.run_until_complete( diff --git a/prowler/providers/nhn/services/compute/compute_instance_login_user/compute_instance_login_user.py b/prowler/providers/nhn/services/compute/compute_instance_login_user/compute_instance_login_user.py index 95f575f83c..8330dc4730 100644 --- a/prowler/providers/nhn/services/compute/compute_instance_login_user/compute_instance_login_user.py +++ b/prowler/providers/nhn/services/compute/compute_instance_login_user/compute_instance_login_user.py @@ -12,7 +12,7 @@ class compute_instance_login_user(Check): ) report.status = "PASS" report.status_extended = ( - f"VM Instance {instance.name} has a appropriate login user." + f"VM Instance {instance.name} has an appropriate login user." ) if instance.login_user: report.status = "FAIL" diff --git a/tests/lib/check/models_test.py b/tests/lib/check/models_test.py index 414de90288..fdbb8b6d70 100644 --- a/tests/lib/check/models_test.py +++ b/tests/lib/check/models_test.py @@ -37,7 +37,7 @@ mock_metadata_lambda = CheckMetadata( CheckID="awslambda_function_url_public", CheckTitle="Check 1", CheckType=["type1"], - ServiceName="lambda", + ServiceName="awslambda", SubServiceName="subservice1", ResourceIdTemplate="template1", Severity="high", diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py index 1c72443baf..2707d180af 100644 --- a/tests/lib/cli/parser_test.py +++ b/tests/lib/cli/parser_test.py @@ -17,13 +17,11 @@ prowler_command = "prowler" # capsys # https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html -prowler_default_usage_error = ( - "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,nhn,dashboard} ..." -) +prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,dashboard} ..." def mock_get_available_providers(): - return ["aws", "azure", "gcp", "kubernetes", "m365", "nhn"] + return ["aws", "azure", "gcp", "kubernetes", "m365", "github", "nhn"] @pytest.mark.arg_parser diff --git a/tests/lib/outputs/compliance/fixtures.py b/tests/lib/outputs/compliance/fixtures.py index 94c9604cf3..c656165195 100644 --- a/tests/lib/outputs/compliance/fixtures.py +++ b/tests/lib/outputs/compliance/fixtures.py @@ -857,7 +857,6 @@ KISA_ISMSP_AWS = Compliance( ], ) -PROWLER_THREATSCORE_AWS_NAME = "prowler_threatscore_aws" PROWLER_THREATSCORE_AWS = Compliance( Framework="ProwlerThreatScore", Version="1.0", @@ -875,6 +874,7 @@ PROWLER_THREATSCORE_AWS = Compliance( AttributeDescription="The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.", AdditionalInformation="Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.", LevelOfRisk=5, + Weight=1000, ) ], Checks=[ @@ -892,6 +892,7 @@ PROWLER_THREATSCORE_AWS = Compliance( AttributeDescription="The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.", AdditionalInformation="A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.", LevelOfRisk=3, + Weight=10, ) ], Checks=[], @@ -899,7 +900,6 @@ PROWLER_THREATSCORE_AWS = Compliance( ], ) -PROWLER_THREATSCORE_AZURE_NAME = "prowler_threatscore_azure" PROWLER_THREATSCORE_AZURE = Compliance( Framework="ProwlerThreatScore", Version="1.0", @@ -917,6 +917,7 @@ PROWLER_THREATSCORE_AZURE = Compliance( AttributeDescription="The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.", AdditionalInformation="Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.", LevelOfRisk=5, + Weight=1000, ) ], Checks=[ @@ -934,6 +935,7 @@ PROWLER_THREATSCORE_AZURE = Compliance( AttributeDescription="The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.", AdditionalInformation="A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.", LevelOfRisk=3, + Weight=10, ) ], Checks=[], @@ -941,7 +943,6 @@ PROWLER_THREATSCORE_AZURE = Compliance( ], ) -PROWLER_THREATSCORE_GCP_NAME = "prowler_threatscore_gcp" PROWLER_THREATSCORE_GCP = Compliance( Framework="ProwlerThreatScore", Version="1.0", @@ -959,6 +960,7 @@ PROWLER_THREATSCORE_GCP = Compliance( AttributeDescription="The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.", AdditionalInformation="Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.", LevelOfRisk=5, + Weight=1000, ) ], Checks=[ @@ -976,6 +978,50 @@ PROWLER_THREATSCORE_GCP = Compliance( AttributeDescription="The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.", AdditionalInformation="A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.", LevelOfRisk=3, + Weight=10, + ) + ], + Checks=[], + ), + ], +) + +PROWLER_THREATSCORE_M365 = Compliance( + Framework="ProwlerThreatScore", + Version="1.0", + Provider="M365", + Description="Prowler ThreatScore Compliance Framework for M365 ensures that the M365 account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption", + Requirements=[ + Compliance_Requirement( + Id="1.1.1", + Description="Ensure MFA is enabled for the 'root' user account", + Attributes=[ + Prowler_ThreatScore_Requirement_Attribute( + Title="MFA enabled for 'root'", + Section="1. IAM", + SubSection="1.1 Authentication", + AttributeDescription="The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.", + AdditionalInformation="Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.", + LevelOfRisk=5, + Weight=1000, + ) + ], + Checks=[ + "iam_root_mfa_enabled", + ], + ), + Compliance_Requirement( + Id="1.1.2", + Description="Ensure hardware MFA is enabled for the 'root' user account", + Attributes=[ + Prowler_ThreatScore_Requirement_Attribute( + Title="CloudTrail logging enabled", + Section="1. IAM", + SubSection="1.1 Authentication", + AttributeDescription="The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.", + AdditionalInformation="A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.", + LevelOfRisk=3, + Weight=10, ) ], Checks=[], diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws_test.py index 28cf82ab44..bae12554ee 100644 --- a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws_test.py +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws_test.py @@ -10,10 +10,7 @@ from prowler.lib.outputs.compliance.prowler_threatscore.models import ( from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_aws import ( ProwlerThreatScoreAWS, ) -from tests.lib.outputs.compliance.fixtures import ( - PROWLER_THREATSCORE_AWS, - PROWLER_THREATSCORE_AWS_NAME, -) +from tests.lib.outputs.compliance.fixtures import PROWLER_THREATSCORE_AWS from tests.lib.outputs.fixtures.fixtures import generate_finding_output from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER, AWS_REGION_EU_WEST_1 @@ -24,9 +21,7 @@ class TestProwlerThreatScoreAWS: generate_finding_output(compliance={"ProwlerThreatScore-1.0": "1.1.1"}) ] - output = ProwlerThreatScoreAWS( - findings, PROWLER_THREATSCORE_AWS, PROWLER_THREATSCORE_AWS_NAME - ) + output = ProwlerThreatScoreAWS(findings, PROWLER_THREATSCORE_AWS) output_data = output.data[0] assert isinstance(output_data, ProwlerThreatScoreAWSModel) assert output_data.Provider == "aws" @@ -66,6 +61,10 @@ class TestProwlerThreatScoreAWS: output_data.Requirements_Attributes_LevelOfRisk == PROWLER_THREATSCORE_AWS.Requirements[0].Attributes[0].LevelOfRisk ) + assert ( + output_data.Requirements_Attributes_Weight + == PROWLER_THREATSCORE_AWS.Requirements[0].Attributes[0].Weight + ) assert output_data.Status == "PASS" assert output_data.StatusExtended == "" assert output_data.ResourceId == "" @@ -113,6 +112,10 @@ class TestProwlerThreatScoreAWS: output_data_manual.Requirements_Attributes_LevelOfRisk == PROWLER_THREATSCORE_AWS.Requirements[1].Attributes[0].LevelOfRisk ) + assert ( + output_data_manual.Requirements_Attributes_Weight + == PROWLER_THREATSCORE_AWS.Requirements[1].Attributes[0].Weight + ) assert output_data_manual.Status == "MANUAL" assert output_data_manual.StatusExtended == "Manual check" assert output_data_manual.ResourceId == "manual_check" @@ -126,9 +129,7 @@ class TestProwlerThreatScoreAWS: findings = [ generate_finding_output(compliance={"ProwlerThreatScore-1.0": "1.1.1"}) ] - output = ProwlerThreatScoreAWS( - findings, PROWLER_THREATSCORE_AWS, PROWLER_THREATSCORE_AWS_NAME - ) + output = ProwlerThreatScoreAWS(findings, PROWLER_THREATSCORE_AWS) output._file_descriptor = mock_file with patch.object(mock_file, "close", return_value=None): @@ -136,5 +137,5 @@ class TestProwlerThreatScoreAWS: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_TITLE;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_ATTRIBUTEDESCRIPTION;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;Prowler ThreatScore Compliance Framework for AWS ensures that the AWS account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;123456789012;eu-west-1;{datetime.now()};1.1.1;Ensure MFA is enabled for the 'root' user account;MFA enabled for 'root';1. IAM;1.1 Authentication;The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.;5;PASS;;;;test-check-id;False\r\naws;Prowler ThreatScore Compliance Framework for AWS ensures that the AWS account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;;;{datetime.now()};1.1.2;Ensure hardware MFA is enabled for the 'root' user account;CloudTrail logging enabled;1. IAM;1.1 Authentication;The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.;3;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;ACCOUNTID;REGION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_TITLE;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_ATTRIBUTEDESCRIPTION;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;REQUIREMENTS_ATTRIBUTES_WEIGHT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;Prowler ThreatScore Compliance Framework for AWS ensures that the AWS account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;123456789012;eu-west-1;{datetime.now()};1.1.1;Ensure MFA is enabled for the 'root' user account;MFA enabled for 'root';1. IAM;1.1 Authentication;The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.;5;1000;PASS;;;;test-check-id;False\r\naws;Prowler ThreatScore Compliance Framework for AWS ensures that the AWS account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;;;{datetime.now()};1.1.2;Ensure hardware MFA is enabled for the 'root' user account;CloudTrail logging enabled;1. IAM;1.1 Authentication;The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.;3;10;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure_test.py index 0dec129f06..d259641967 100644 --- a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure_test.py +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure_test.py @@ -10,10 +10,7 @@ from prowler.lib.outputs.compliance.prowler_threatscore.models import ( from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_azure import ( ProwlerThreatScoreAzure, ) -from tests.lib.outputs.compliance.fixtures import ( - PROWLER_THREATSCORE_AZURE, - PROWLER_THREATSCORE_AZURE_NAME, -) +from tests.lib.outputs.compliance.fixtures import PROWLER_THREATSCORE_AZURE from tests.lib.outputs.fixtures.fixtures import generate_finding_output from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, @@ -33,9 +30,7 @@ class TestProwlerThreatScoreAzure: ) ] - output = ProwlerThreatScoreAzure( - findings, PROWLER_THREATSCORE_AZURE, PROWLER_THREATSCORE_AZURE_NAME - ) + output = ProwlerThreatScoreAzure(findings, PROWLER_THREATSCORE_AZURE) output_data = output.data[0] assert isinstance(output_data, ProwlerThreatScoreAzureModel) assert output_data.Provider == "azure" @@ -77,6 +72,10 @@ class TestProwlerThreatScoreAzure: output_data.Requirements_Attributes_LevelOfRisk == PROWLER_THREATSCORE_AZURE.Requirements[0].Attributes[0].LevelOfRisk ) + assert ( + output_data.Requirements_Attributes_Weight + == PROWLER_THREATSCORE_AZURE.Requirements[0].Attributes[0].Weight + ) assert output_data.Status == "PASS" assert output_data.StatusExtended == "" assert output_data.ResourceId == "" @@ -124,6 +123,10 @@ class TestProwlerThreatScoreAzure: output_data_manual.Requirements_Attributes_LevelOfRisk == PROWLER_THREATSCORE_AZURE.Requirements[1].Attributes[0].LevelOfRisk ) + assert ( + output_data_manual.Requirements_Attributes_Weight + == PROWLER_THREATSCORE_AZURE.Requirements[1].Attributes[0].Weight + ) assert output_data_manual.Status == "MANUAL" assert output_data_manual.StatusExtended == "Manual check" assert output_data_manual.ResourceId == "manual_check" @@ -136,9 +139,7 @@ class TestProwlerThreatScoreAzure: findings = [ generate_finding_output(compliance={"ProwlerThreatScore-1.0": "1.1.1"}) ] - output = ProwlerThreatScoreAzure( - findings, PROWLER_THREATSCORE_AZURE, PROWLER_THREATSCORE_AZURE_NAME - ) + output = ProwlerThreatScoreAzure(findings, PROWLER_THREATSCORE_AZURE) output._file_descriptor = mock_file with patch.object(mock_file, "close", return_value=None): @@ -146,5 +147,5 @@ class TestProwlerThreatScoreAzure: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;SUBSCRIPTIONID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_TITLE;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_ATTRIBUTEDESCRIPTION;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;Prowler ThreatScore Compliance Framework for Azure ensures that the Azure account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;123456789012;eu-west-1;{datetime.now()};1.1.1;Ensure MFA is enabled for the 'root' user account;MFA enabled for 'root';1. IAM;1.1 Authentication;The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.;5;PASS;;;;test-check-id;False\r\nazure;Prowler ThreatScore Compliance Framework for Azure ensures that the Azure account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;;;{datetime.now()};1.1.2;Ensure hardware MFA is enabled for the 'root' user account;CloudTrail logging enabled;1. IAM;1.1 Authentication;The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.;3;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;SUBSCRIPTIONID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_TITLE;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_ATTRIBUTEDESCRIPTION;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;REQUIREMENTS_ATTRIBUTES_WEIGHT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;Prowler ThreatScore Compliance Framework for Azure ensures that the Azure account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;123456789012;eu-west-1;{datetime.now()};1.1.1;Ensure MFA is enabled for the 'root' user account;MFA enabled for 'root';1. IAM;1.1 Authentication;The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.;5;1000;PASS;;;;test-check-id;False\r\nazure;Prowler ThreatScore Compliance Framework for Azure ensures that the Azure account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;;;{datetime.now()};1.1.2;Ensure hardware MFA is enabled for the 'root' user account;CloudTrail logging enabled;1. IAM;1.1 Authentication;The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.;3;10;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp_test.py index 40a5e60494..5318392ea3 100644 --- a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp_test.py +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp_test.py @@ -10,10 +10,7 @@ from prowler.lib.outputs.compliance.prowler_threatscore.models import ( from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_gcp import ( ProwlerThreatScoreGCP, ) -from tests.lib.outputs.compliance.fixtures import ( - PROWLER_THREATSCORE_GCP, - PROWLER_THREATSCORE_GCP_NAME, -) +from tests.lib.outputs.compliance.fixtures import PROWLER_THREATSCORE_GCP from tests.lib.outputs.fixtures.fixtures import generate_finding_output from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID @@ -30,9 +27,7 @@ class TestProwlerThreatScoreGCP: ) ] - output = ProwlerThreatScoreGCP( - findings, PROWLER_THREATSCORE_GCP, PROWLER_THREATSCORE_GCP_NAME - ) + output = ProwlerThreatScoreGCP(findings, PROWLER_THREATSCORE_GCP) output_data = output.data[0] assert isinstance(output_data, ProwlerThreatScoreGCPModel) assert output_data.Provider == "gcp" @@ -72,6 +67,10 @@ class TestProwlerThreatScoreGCP: output_data.Requirements_Attributes_LevelOfRisk == PROWLER_THREATSCORE_GCP.Requirements[0].Attributes[0].LevelOfRisk ) + assert ( + output_data.Requirements_Attributes_Weight + == PROWLER_THREATSCORE_GCP.Requirements[0].Attributes[0].Weight + ) assert output_data.Status == "PASS" assert output_data.StatusExtended == "" assert output_data.ResourceId == "" @@ -119,6 +118,10 @@ class TestProwlerThreatScoreGCP: output_data_manual.Requirements_Attributes_LevelOfRisk == PROWLER_THREATSCORE_GCP.Requirements[1].Attributes[0].LevelOfRisk ) + assert ( + output_data_manual.Requirements_Attributes_Weight + == PROWLER_THREATSCORE_GCP.Requirements[1].Attributes[0].Weight + ) assert output_data_manual.Status == "MANUAL" assert output_data_manual.StatusExtended == "Manual check" assert output_data_manual.ResourceId == "manual_check" @@ -132,9 +135,7 @@ class TestProwlerThreatScoreGCP: findings = [ generate_finding_output(compliance={"ProwlerThreatScore-1.0": "1.1.1"}) ] - output = ProwlerThreatScoreGCP( - findings, PROWLER_THREATSCORE_GCP, PROWLER_THREATSCORE_GCP_NAME - ) + output = ProwlerThreatScoreGCP(findings, PROWLER_THREATSCORE_GCP) output._file_descriptor = mock_file with patch.object(mock_file, "close", return_value=None): @@ -142,6 +143,6 @@ class TestProwlerThreatScoreGCP: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;PROJECTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_TITLE;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_ATTRIBUTEDESCRIPTION;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;Prowler ThreatScore Compliance Framework for GCP ensures that the GCP account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;123456789012;eu-west-1;{datetime.now()};1.1.1;Ensure MFA is enabled for the 'root' user account;MFA enabled for 'root';1. IAM;1.1 Authentication;The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.;5;PASS;;;;test-check-id;False\r\ngcp;Prowler ThreatScore Compliance Framework for GCP ensures that the GCP account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;;;{datetime.now()};1.1.2;Ensure hardware MFA is enabled for the 'root' user account;CloudTrail logging enabled;1. IAM;1.1 Authentication;The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.;3;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;PROJECTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_TITLE;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_ATTRIBUTEDESCRIPTION;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;REQUIREMENTS_ATTRIBUTES_WEIGHT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;Prowler ThreatScore Compliance Framework for GCP ensures that the GCP account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;123456789012;eu-west-1;{datetime.now()};1.1.1;Ensure MFA is enabled for the 'root' user account;MFA enabled for 'root';1. IAM;1.1 Authentication;The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.;5;1000;PASS;;;;test-check-id;False\r\ngcp;Prowler ThreatScore Compliance Framework for GCP ensures that the GCP account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;;;{datetime.now()};1.1.2;Ensure hardware MFA is enabled for the 'root' user account;CloudTrail logging enabled;1. IAM;1.1 Authentication;The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.;3;10;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365_test.py new file mode 100644 index 0000000000..fa43a336ec --- /dev/null +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365_test.py @@ -0,0 +1,150 @@ +from datetime import datetime +from io import StringIO + +from freezegun import freeze_time +from mock import patch + +from prowler.lib.outputs.compliance.prowler_threatscore.models import ( + ProwlerThreatScoreM365Model, +) +from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_m365 import ( + ProwlerThreatScoreM365, +) +from tests.lib.outputs.compliance.fixtures import PROWLER_THREATSCORE_M365 +from tests.lib.outputs.fixtures.fixtures import generate_finding_output +from tests.providers.m365.m365_fixtures import TENANT_ID + + +class TestProwlerThreatScoreM365: + def test_output_transform(self): + findings = [ + generate_finding_output( + compliance={"ProwlerThreatScore-1.0": "1.1.1"}, + provider="m365", + account_name=TENANT_ID, + account_uid=TENANT_ID, + region="", + ) + ] + + output = ProwlerThreatScoreM365(findings, PROWLER_THREATSCORE_M365) + output_data = output.data[0] + assert isinstance(output_data, ProwlerThreatScoreM365Model) + assert output_data.Provider == "m365" + assert output_data.Description == PROWLER_THREATSCORE_M365.Description + assert output_data.TenantId == TENANT_ID + assert output_data.Location == "" + assert ( + output_data.Requirements_Id == PROWLER_THREATSCORE_M365.Requirements[0].Id + ) + assert ( + output_data.Requirements_Description + == PROWLER_THREATSCORE_M365.Requirements[0].Description + ) + assert ( + output_data.Requirements_Attributes_Title + == PROWLER_THREATSCORE_M365.Requirements[0].Attributes[0].Title + ) + assert ( + output_data.Requirements_Attributes_Section + == PROWLER_THREATSCORE_M365.Requirements[0].Attributes[0].Section + ) + assert ( + output_data.Requirements_Attributes_SubSection + == PROWLER_THREATSCORE_M365.Requirements[0].Attributes[0].SubSection + ) + assert ( + output_data.Requirements_Attributes_AttributeDescription + == PROWLER_THREATSCORE_M365.Requirements[0] + .Attributes[0] + .AttributeDescription + ) + assert ( + output_data.Requirements_Attributes_AdditionalInformation + == PROWLER_THREATSCORE_M365.Requirements[0] + .Attributes[0] + .AdditionalInformation + ) + assert ( + output_data.Requirements_Attributes_LevelOfRisk + == PROWLER_THREATSCORE_M365.Requirements[0].Attributes[0].LevelOfRisk + ) + assert ( + output_data.Requirements_Attributes_Weight + == PROWLER_THREATSCORE_M365.Requirements[0].Attributes[0].Weight + ) + assert output_data.Status == "PASS" + assert output_data.StatusExtended == "" + assert output_data.ResourceId == "" + assert output_data.ResourceName == "" + assert output_data.CheckId == "test-check-id" + assert not output_data.Muted + # Test manual check + output_data_manual = output.data[1] + assert output_data_manual.Provider == "m365" + assert output_data_manual.TenantId == "" + assert output_data_manual.Location == "" + assert ( + output_data_manual.Requirements_Id + == PROWLER_THREATSCORE_M365.Requirements[1].Id + ) + assert ( + output_data_manual.Requirements_Description + == PROWLER_THREATSCORE_M365.Requirements[1].Description + ) + assert ( + output_data_manual.Requirements_Attributes_Title + == PROWLER_THREATSCORE_M365.Requirements[1].Attributes[0].Title + ) + assert ( + output_data_manual.Requirements_Attributes_Section + == PROWLER_THREATSCORE_M365.Requirements[1].Attributes[0].Section + ) + assert ( + output_data_manual.Requirements_Attributes_SubSection + == PROWLER_THREATSCORE_M365.Requirements[1].Attributes[0].SubSection + ) + assert ( + output_data_manual.Requirements_Attributes_AttributeDescription + == PROWLER_THREATSCORE_M365.Requirements[1] + .Attributes[0] + .AttributeDescription + ) + assert ( + output_data_manual.Requirements_Attributes_AdditionalInformation + == PROWLER_THREATSCORE_M365.Requirements[1] + .Attributes[0] + .AdditionalInformation + ) + assert ( + output_data_manual.Requirements_Attributes_LevelOfRisk + == PROWLER_THREATSCORE_M365.Requirements[1].Attributes[0].LevelOfRisk + ) + assert ( + output_data_manual.Requirements_Attributes_Weight + == PROWLER_THREATSCORE_M365.Requirements[1].Attributes[0].Weight + ) + assert output_data_manual.Status == "MANUAL" + assert output_data_manual.StatusExtended == "Manual check" + assert output_data_manual.ResourceId == "manual_check" + assert output_data_manual.ResourceName == "Manual check" + assert output_data_manual.CheckId == "manual" + assert not output_data_manual.Muted + + @freeze_time(datetime.now()) + def test_batch_write_data_to_file(self): + mock_file = StringIO() + findings = [ + generate_finding_output(compliance={"ProwlerThreatScore-1.0": "1.1.1"}) + ] + output = ProwlerThreatScoreM365(findings, PROWLER_THREATSCORE_M365) + output._file_descriptor = mock_file + + with patch.object(mock_file, "close", return_value=None): + output.batch_write_data_to_file() + + mock_file.seek(0) + content = mock_file.read() + expected_csv = f"PROVIDER;DESCRIPTION;TENANTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_TITLE;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_ATTRIBUTEDESCRIPTION;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_LEVELOFRISK;REQUIREMENTS_ATTRIBUTES_WEIGHT;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\naws;Prowler ThreatScore Compliance Framework for M365 ensures that the M365 account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;123456789012;eu-west-1;{datetime.now()};1.1.1;Ensure MFA is enabled for the 'root' user account;MFA enabled for 'root';1. IAM;1.1 Authentication;The root user account holds the highest level of privileges within an AWS account. Enabling Multi-Factor Authentication (MFA) enhances security by adding an additional layer of protection beyond just a username and password. With MFA activated, users must provide their credentials (username and password) along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;Enabling MFA enhances console security by requiring the authenticating user to both possess a time-sensitive key-generating device and have knowledge of their credentials.;5;1000;PASS;;;;test-check-id;False\r\nm365;Prowler ThreatScore Compliance Framework for M365 ensures that the M365 account is compliant taking into account four main pillars: Identity and Access Management, Attack Surface, Forensic Readiness and Encryption;;;{datetime.now()};1.1.2;Ensure hardware MFA is enabled for the 'root' user account;CloudTrail logging enabled;1. IAM;1.1 Authentication;The root user account in AWS has the highest level of privileges. Multi-Factor Authentication (MFA) enhances security by adding an extra layer of protection beyond a username and password. When MFA is enabled, users must enter their credentials along with a unique authentication code generated by their AWS MFA device when signing into an AWS website.;A hardware MFA has a smaller attack surface compared to a virtual MFA. Unlike a virtual MFA, which relies on a mobile device that may be vulnerable to malware or compromise, a hardware MFA operates independently, reducing exposure to potential security threats.;3;10;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" + + assert content == expected_csv diff --git a/tests/providers/aws/aws_provider_test.py b/tests/providers/aws/aws_provider_test.py index 36ffcfe5db..56804de4bd 100644 --- a/tests/providers/aws/aws_provider_test.py +++ b/tests/providers/aws/aws_provider_test.py @@ -1712,13 +1712,13 @@ aws: assert not recovered_regions def test_get_regions_all_count(self): - assert len(AwsProvider.get_regions(partition=None)) == 36 + assert len(AwsProvider.get_regions(partition=None)) == 37 def test_get_regions_cn_count(self): assert len(AwsProvider.get_regions("aws-cn")) == 2 def test_get_regions_aws_count(self): - assert len(AwsProvider.get_regions(partition="aws")) == 32 + assert len(AwsProvider.get_regions(partition="aws")) == 33 def test_get_all_regions(self): with patch( diff --git a/tests/providers/aws/lib/mutelist/aws_mutelist_test.py b/tests/providers/aws/lib/mutelist/aws_mutelist_test.py index 51e6890852..29a987e752 100644 --- a/tests/providers/aws/lib/mutelist/aws_mutelist_test.py +++ b/tests/providers/aws/lib/mutelist/aws_mutelist_test.py @@ -304,7 +304,7 @@ class TestAWSMutelist: mutelist = AWSMutelist(mutelist_content=mutelist_fixture) - assert mutelist.validate_mutelist() + assert len(mutelist.validate_mutelist(mutelist_fixture)) > 0 assert mutelist.mutelist == mutelist_fixture def test_validate_mutelist_not_valid_key(self): @@ -317,7 +317,7 @@ class TestAWSMutelist: mutelist = AWSMutelist(mutelist_content=mutelist_fixture) - assert not mutelist.validate_mutelist() + assert len(mutelist.validate_mutelist(mutelist_fixture)) == 0 assert mutelist.mutelist == {} assert mutelist.mutelist_file_path is None diff --git a/tests/providers/azure/lib/mutelist/azure_mutelist_test.py b/tests/providers/azure/lib/mutelist/azure_mutelist_test.py index 3488beb54f..7852aa54d2 100644 --- a/tests/providers/azure/lib/mutelist/azure_mutelist_test.py +++ b/tests/providers/azure/lib/mutelist/azure_mutelist_test.py @@ -36,7 +36,7 @@ class TestAzureMutelist: mutelist = AzureMutelist(mutelist_content=mutelist_fixture) - assert not mutelist.validate_mutelist() + assert len(mutelist.validate_mutelist(mutelist_fixture)) == 0 assert mutelist.mutelist == {} assert mutelist.mutelist_file_path is None diff --git a/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py b/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py index a2764139c6..23aadef795 100644 --- a/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_notify_alerts_severity_is_high/defender_ensure_notify_alerts_severity_is_high_test.py @@ -31,7 +31,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: result = check.execute() assert len(result) == 0 - def test_defender_severity_alerts_low(self): + def test_defender_severity_alerts_critical(self): resource_id = str(uuid4()) defender_client = mock.MagicMock defender_client.security_contacts = { @@ -41,7 +41,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: name="default", emails="", phone="", - alert_notifications_minimal_severity="Low", + alert_notifications_minimal_severity="Critical", alert_notifications_state="On", notified_roles=["Contributor"], notified_roles_state="On", @@ -69,7 +69,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Notifiy alerts are not enabled for severity high in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {AZURE_SUBSCRIPTION_ID}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" @@ -113,7 +113,51 @@ class Test_defender_ensure_notify_alerts_severity_is_high: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Notifiy alerts are enabled for severity high in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Notifications are enabled for alerts with a minimum severity of high or lower (High) in subscription {AZURE_SUBSCRIPTION_ID}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "default" + assert result[0].resource_id == resource_id + + def test_defender_severity_alerts_low(self): + resource_id = str(uuid4()) + defender_client = mock.MagicMock + defender_client.security_contacts = { + AZURE_SUBSCRIPTION_ID: { + resource_id: SecurityContacts( + resource_id=resource_id, + name="default", + emails="", + phone="", + alert_notifications_minimal_severity="Low", + alert_notifications_state="On", + notified_roles=["Contributor"], + notified_roles_state="On", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_ensure_notify_alerts_severity_is_high.defender_ensure_notify_alerts_severity_is_high.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_notify_alerts_severity_is_high.defender_ensure_notify_alerts_severity_is_high import ( + defender_ensure_notify_alerts_severity_is_high, + ) + + check = defender_ensure_notify_alerts_severity_is_high() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Notifications are enabled for alerts with a minimum severity of high or lower (Low) in subscription {AZURE_SUBSCRIPTION_ID}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" @@ -156,7 +200,7 @@ class Test_defender_ensure_notify_alerts_severity_is_high: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Notifiy alerts are not enabled for severity high in subscription {AZURE_SUBSCRIPTION_ID}." + == f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {AZURE_SUBSCRIPTION_ID}." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == "default" diff --git a/tests/providers/azure/services/entra/entra_service_test.py b/tests/providers/azure/services/entra/entra_service_test.py index b09b6edaa4..11cd49b40c 100644 --- a/tests/providers/azure/services/entra/entra_service_test.py +++ b/tests/providers/azure/services/entra/entra_service_test.py @@ -93,7 +93,7 @@ async def mock_entra_get_conditional_access_policy(_): "include": ["797f4846-ba00-4fd7-ba43-dac1f8f63013"], "exclude": [], }, - access_controls={"grant": ["MFA"], "block": []}, + access_controls={"grant": ["MFA", "compliantDevice"], "block": []}, ) } } @@ -216,7 +216,7 @@ class Test_Entra_Service: ) assert entra_client.conditional_access_policy[DOMAIN]["id-1"].access_controls[ "grant" - ] == ["MFA"] + ] == ["MFA", "compliantDevice"] assert ( entra_client.conditional_access_policy[DOMAIN]["id-1"].access_controls[ "block" diff --git a/tests/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled_test.py b/tests/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled_test.py index 20c18ea998..e11294a778 100644 --- a/tests/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled_test.py +++ b/tests/providers/azure/services/sqlserver/sqlserver_auditing_enabled/sqlserver_auditing_enabled_test.py @@ -122,7 +122,7 @@ class Test_sqlserver_auditing_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has a auditing policy configured." + == f"SQL Server {sql_server_name} from subscription {AZURE_SUBSCRIPTION_ID} has an auditing policy configured." ) assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].resource_name == sql_server_name diff --git a/tests/providers/azure/services/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 new file mode 100644 index 0000000000..c235a50d32 --- /dev/null +++ b/tests/providers/azure/services/storage/storage_account_key_access_disabled/storage_account_key_access_disabled_test.py @@ -0,0 +1,134 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.storage.storage_service import Account +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_storage_account_key_access_disabled: + 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_account_key_access_disabled.storage_account_key_access_disabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_account_key_access_disabled.storage_account_key_access_disabled import ( + storage_account_key_access_disabled, + ) + + check = storage_account_key_access_disabled() + result = check.execute() + assert len(result) == 0 + + def test_storage_account_shared_key_access_enabled(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=None, + enable_https_traffic_only=False, + infrastructure_encryption=False, + allow_blob_public_access=None, + network_rule_set=None, + encryption_type=None, + minimum_tls_version=None, + key_expiration_period_in_days=None, + location="westeurope", + private_endpoint_connections=None, + allow_shared_key_access=True, + ) + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_account_key_access_disabled.storage_account_key_access_disabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_account_key_access_disabled.storage_account_key_access_disabled import ( + storage_account_key_access_disabled, + ) + + check = storage_account_key_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has shared key access enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == storage_account_name + assert result[0].resource_id == storage_account_id + assert result[0].location == "westeurope" + + def test_storage_account_shared_key_access_disabled(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=None, + enable_https_traffic_only=False, + infrastructure_encryption=False, + allow_blob_public_access=None, + network_rule_set=None, + encryption_type=None, + minimum_tls_version=None, + key_expiration_period_in_days=None, + location="westeurope", + private_endpoint_connections=None, + allow_shared_key_access=False, + ) + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_account_key_access_disabled.storage_account_key_access_disabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_account_key_access_disabled.storage_account_key_access_disabled import ( + storage_account_key_access_disabled, + ) + + check = storage_account_key_access_disabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has shared key access disabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == storage_account_name + assert result[0].resource_id == storage_account_id + assert result[0].location == "westeurope" 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 new file mode 100644 index 0000000000..219673e8f0 --- /dev/null +++ b/tests/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled_test.py @@ -0,0 +1,188 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.storage.storage_service import Account, FileShare +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_storage_ensure_file_shares_soft_delete_is_enabled: + 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_ensure_file_shares_soft_delete_is_enabled.storage_ensure_file_shares_soft_delete_is_enabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_ensure_file_shares_soft_delete_is_enabled.storage_ensure_file_shares_soft_delete_is_enabled import ( + storage_ensure_file_shares_soft_delete_is_enabled, + ) + + check = storage_ensure_file_shares_soft_delete_is_enabled() + result = check.execute() + assert len(result) == 0 + + def test_no_file_shares(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=None, + enable_https_traffic_only=False, + infrastructure_encryption=False, + allow_blob_public_access=None, + network_rule_set=None, + encryption_type="None", + minimum_tls_version=None, + key_expiration_period_in_days=None, + location="westeurope", + private_endpoint_connections=None, + file_shares=[], + ) + ] + } + + 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_ensure_file_shares_soft_delete_is_enabled.storage_ensure_file_shares_soft_delete_is_enabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_ensure_file_shares_soft_delete_is_enabled.storage_ensure_file_shares_soft_delete_is_enabled import ( + storage_ensure_file_shares_soft_delete_is_enabled, + ) + + check = storage_ensure_file_shares_soft_delete_is_enabled() + result = check.execute() + assert len(result) == 0 + + def test_file_share_soft_delete_disabled(self): + storage_account_id = str(uuid4()) + storage_account_name = "Test Storage Account" + file_share = FileShare( + id="fs1", + name="share1", + soft_delete_enabled=False, + retention_days=0, + ) + storage_client = mock.MagicMock + storage_client.storage_accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id=storage_account_id, + name=storage_account_name, + resouce_group_name=None, + enable_https_traffic_only=False, + infrastructure_encryption=False, + allow_blob_public_access=None, + network_rule_set=None, + encryption_type="None", + minimum_tls_version=None, + key_expiration_period_in_days=None, + location="westeurope", + private_endpoint_connections=None, + file_shares=[file_share], + ) + ] + } + + 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_ensure_file_shares_soft_delete_is_enabled.storage_ensure_file_shares_soft_delete_is_enabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_ensure_file_shares_soft_delete_is_enabled.storage_ensure_file_shares_soft_delete_is_enabled import ( + storage_ensure_file_shares_soft_delete_is_enabled, + ) + + check = storage_ensure_file_shares_soft_delete_is_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"File share {file_share.name} in storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} does not have soft delete enabled or has an invalid retention period." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_id == file_share.name + assert result[0].location == "westeurope" + assert result[0].resource_name == storage_account_name + + def test_file_share_soft_delete_enabled(self): + storage_account_id = str(uuid4()) + storage_account_name = "Test Storage Account" + file_share = FileShare( + id="fs2", + name="share2", + soft_delete_enabled=True, + retention_days=7, + ) + storage_client = mock.MagicMock + storage_client.storage_accounts = { + AZURE_SUBSCRIPTION_ID: [ + Account( + id=storage_account_id, + name=storage_account_name, + resouce_group_name=None, + enable_https_traffic_only=False, + infrastructure_encryption=False, + allow_blob_public_access=None, + network_rule_set=None, + encryption_type="None", + minimum_tls_version=None, + key_expiration_period_in_days=None, + location="westeurope", + private_endpoint_connections=None, + file_shares=[file_share], + ) + ] + } + + 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_ensure_file_shares_soft_delete_is_enabled.storage_ensure_file_shares_soft_delete_is_enabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_ensure_file_shares_soft_delete_is_enabled.storage_ensure_file_shares_soft_delete_is_enabled import ( + storage_ensure_file_shares_soft_delete_is_enabled, + ) + + check = storage_ensure_file_shares_soft_delete_is_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"File share {file_share.name} in storage account {storage_account_name} from subscription {AZURE_SUBSCRIPTION_ID} has soft delete enabled with a retention period of {file_share.retention_days} days." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_id == file_share.name + assert result[0].location == "westeurope" + assert result[0].resource_name == storage_account_name diff --git a/tests/providers/azure/services/storage/storage_service_test.py b/tests/providers/azure/services/storage/storage_service_test.py index 9ea5a57ad5..09057b0463 100644 --- a/tests/providers/azure/services/storage/storage_service_test.py +++ b/tests/providers/azure/services/storage/storage_service_test.py @@ -3,6 +3,7 @@ from unittest.mock import patch from prowler.providers.azure.services.storage.storage_service import ( Account, BlobProperties, + FileShare, Storage, ) from tests.providers.azure.azure_fixtures import ( @@ -19,6 +20,10 @@ def mock_storage_get_storage_accounts(_): default_service_version=None, container_delete_retention_policy=None, ) + file_shares = [ + FileShare(id="fs1", name="share1", soft_delete_enabled=True, retention_days=7), + FileShare(id="fs2", name="share2", soft_delete_enabled=False, retention_days=0), + ] return { AZURE_SUBSCRIPTION_ID: [ Account( @@ -35,6 +40,8 @@ def mock_storage_get_storage_accounts(_): private_endpoint_connections=None, location="westeurope", blob_properties=blob_properties, + allow_shared_key_access=True, + file_shares=file_shares, ) ] } @@ -110,6 +117,10 @@ class Test_Storage_Service: default_service_version=None, container_delete_retention_policy=None, ) + assert ( + storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0].allow_shared_key_access + is True + ) def test_get_blob_properties(self): storage = Storage(set_mocked_azure_provider()) @@ -143,3 +154,15 @@ class Test_Storage_Service: ].blob_properties.container_delete_retention_policy is None ) + + def test_get_file_shares_properties(self): + storage = Storage(set_mocked_azure_provider()) + account = storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0] + assert hasattr(account, "file_shares") + assert len(account.file_shares) == 2 + assert account.file_shares[0].name == "share1" + assert account.file_shares[0].soft_delete_enabled is True + assert account.file_shares[0].retention_days == 7 + assert account.file_shares[1].name == "share2" + assert account.file_shares[1].soft_delete_enabled is False + assert account.file_shares[1].retention_days == 0 diff --git a/tests/providers/gcp/lib/mutelist/gcp_mutelist_test.py b/tests/providers/gcp/lib/mutelist/gcp_mutelist_test.py index 0471c5f0f1..01d3d1abfa 100644 --- a/tests/providers/gcp/lib/mutelist/gcp_mutelist_test.py +++ b/tests/providers/gcp/lib/mutelist/gcp_mutelist_test.py @@ -34,7 +34,7 @@ class TestGCPMutelist: mutelist = GCPMutelist(mutelist_content=mutelist_fixture) - assert not mutelist.validate_mutelist() + assert len(mutelist.validate_mutelist(mutelist_fixture)) == 0 assert mutelist.mutelist == {} assert mutelist.mutelist_file_path is None diff --git a/tests/providers/github/github_provider_test.py b/tests/providers/github/github_provider_test.py index 30d770ef3b..60b705b629 100644 --- a/tests/providers/github/github_provider_test.py +++ b/tests/providers/github/github_provider_test.py @@ -59,7 +59,9 @@ class TestGitHubProvider: account_id=ACCOUNT_ID, account_url=ACCOUNT_URL, ) - assert provider._audit_config == {} + assert provider._audit_config == { + "inactive_not_archived_days_threshold": 180, + } assert provider._fixer_config == fixer_config def test_github_provider_OAuth(self): @@ -99,7 +101,9 @@ class TestGitHubProvider: account_id=ACCOUNT_ID, account_url=ACCOUNT_URL, ) - assert provider._audit_config == {} + assert provider._audit_config == { + "inactive_not_archived_days_threshold": 180, + } assert provider._fixer_config == fixer_config def test_github_provider_App(self): @@ -133,5 +137,7 @@ class TestGitHubProvider: assert provider._type == "github" assert provider.session == GithubSession(token="", id=APP_ID, key=APP_KEY) assert provider.identity == GithubAppIdentityInfo(app_id=APP_ID) - assert provider._audit_config == {} + assert provider._audit_config == { + "inactive_not_archived_days_threshold": 180, + } assert provider._fixer_config == fixer_config diff --git a/tests/providers/github/lib/mutelist/github_mutelist_test.py b/tests/providers/github/lib/mutelist/github_mutelist_test.py index 8b8cc803fd..b29db60c07 100644 --- a/tests/providers/github/lib/mutelist/github_mutelist_test.py +++ b/tests/providers/github/lib/mutelist/github_mutelist_test.py @@ -36,7 +36,7 @@ class TestGithubMutelist: mutelist = GithubMutelist(mutelist_content=mutelist_fixture) - assert not mutelist.validate_mutelist() + assert len(mutelist.validate_mutelist(mutelist_fixture)) == 0 assert mutelist.mutelist == {} assert mutelist.mutelist_file_path is None diff --git a/tests/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled_test.py b/tests/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled_test.py index fc9a5c3ddd..e10050e0cc 100644 --- a/tests/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled_test.py +++ b/tests/providers/github/services/repository/repository_branch_delete_on_merge_enabled/repository_branch_delete_on_merge_enabled_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -29,15 +30,36 @@ class Test_repository_branch_delete_on_merge_enabled_test: def test_branch_deletion_disabled(self): repository_client = mock.MagicMock - repo_name = "repo1" + repo_name = "repo2" + default_branch = "main" repository_client.repositories = { - 1: Repo( - id=1, + 2: Repo( + id=2, name=repo_name, - full_name="account-name/repo1", - default_branch="main", + owner="account-name", + full_name="account-name/repo2", + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, delete_branch_on_merge=False, ), } @@ -59,25 +81,46 @@ class Test_repository_branch_delete_on_merge_enabled_test: check = repository_branch_delete_on_merge_enabled() result = check.execute() assert len(result) == 1 - assert result[0].resource_id == 1 - assert result[0].resource_name == "repo1" + assert result[0].resource_id == 2 + assert result[0].resource_name == repo_name assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Repository {repo_name} does not delete branches on merge." + == f"Repository {repo_name} does not delete branches on merge in default branch ({default_branch})." ) def test_branch_deletion_enabled(self): repository_client = mock.MagicMock repo_name = "repo1" + default_branch = "main" repository_client.repositories = { 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch="main", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, delete_branch_on_merge=True, ), } @@ -100,9 +143,9 @@ class Test_repository_branch_delete_on_merge_enabled_test: result = check.execute() assert len(result) == 1 assert result[0].resource_id == 1 - assert result[0].resource_name == "repo1" + assert result[0].resource_name == repo_name assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Repository {repo_name} does delete branches on merge." + == f"Repository {repo_name} does delete branches on merge in default branch ({default_branch})." ) diff --git a/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py b/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py index 825e4bdc6e..b4d385fdf1 100644 --- a/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_deletion_disabled/repository_default_branch_deletion_disabled_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -30,16 +31,39 @@ class Test_repository_default_branch_deletion_disabled_test: def test_allow_branch_deletion_enabled(self): repository_client = mock.MagicMock repo_name = "repo1" - default_branch = "main" + default_branch = Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ) + now = datetime.now(timezone.utc) + repository_client.repositories = { 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", default_branch=default_branch, - default_branch_deletion=True, private=False, + archived=False, + pushed_at=now, + default_branch_deletion=True, securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -61,7 +85,7 @@ class Test_repository_default_branch_deletion_disabled_test: result = check.execute() assert len(result) == 1 assert result[0].resource_id == 1 - assert result[0].resource_name == "repo1" + assert result[0].resource_name == repo_name assert result[0].status == "FAIL" assert ( result[0].status_extended @@ -71,16 +95,39 @@ class Test_repository_default_branch_deletion_disabled_test: def test_allow_branch_deletion_disabled(self): repository_client = mock.MagicMock repo_name = "repo1" - default_branch = "main" + default_branch = Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=False, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ) + now = datetime.now(timezone.utc) + repository_client.repositories = { 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - private=False, default_branch=default_branch, + private=False, + archived=False, + pushed_at=now, default_branch_deletion=False, - securitymd=True, + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -102,7 +149,7 @@ class Test_repository_default_branch_deletion_disabled_test: result = check.execute() assert len(result) == 1 assert result[0].resource_id == 1 - assert result[0].resource_name == "repo1" + assert result[0].resource_name == repo_name assert result[0].status == "PASS" assert ( result[0].status_extended diff --git a/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py b/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py index a9da35d0fe..b35393764e 100644 --- a/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_disallows_force_push/repository_default_branch_disallows_force_push_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -27,7 +28,7 @@ class Test_repository_default_branch_disallows_force_push_test: result = check.execute() assert len(result) == 0 - def test_allow_force_push_enabled(self): + def test_force_push_allowed(self): repository_client = mock.MagicMock repo_name = "repo1" default_branch = "main" @@ -35,11 +36,31 @@ class Test_repository_default_branch_disallows_force_push_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch=default_branch, - allow_force_pushes=True, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -65,10 +86,10 @@ class Test_repository_default_branch_disallows_force_push_test: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Repository {repo_name} does allow force push." + == f"Repository {repo_name} does allow force pushes on default branch ({default_branch})." ) - def test_allow_force_push_disabled(self): + def test_force_push_disallowed(self): repository_client = mock.MagicMock repo_name = "repo1" default_branch = "main" @@ -76,11 +97,31 @@ class Test_repository_default_branch_disallows_force_push_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), private=False, - default_branch=default_branch, - allow_force_pushes=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, ), } @@ -106,5 +147,5 @@ class Test_repository_default_branch_disallows_force_push_test: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Repository {repo_name} does deny force push." + == f"Repository {repo_name} does deny force pushes on default branch ({default_branch})." ) diff --git a/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py b/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py index f85bbdced9..296c3d78e5 100644 --- a/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_protection_applies_to_admins/repository_default_branch_protection_applies_to_admins_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -35,11 +36,31 @@ class Test_repository_default_branch_protection_applies_to_admins_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch=default_branch, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=False, - enforce_admins=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -76,11 +97,31 @@ class Test_repository_default_branch_protection_applies_to_admins_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), private=False, - default_branch=default_branch, - enforce_admins=True, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, ), } diff --git a/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py b/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py index 3723863298..67bce91575 100644 --- a/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_protection_enabled/repository_default_branch_protection_enabled_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -35,11 +36,31 @@ class Test_repository_default_branch_protection_enabled_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch=default_branch, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, - default_branch_protection=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -76,11 +97,31 @@ class Test_repository_default_branch_protection_enabled_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), private=False, - default_branch=default_branch, - default_branch_protection=True, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, ), } diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py new file mode 100644 index 0000000000..ab3d579b75 --- /dev/null +++ b/tests/providers/github/services/repository/repository_default_branch_requires_codeowners_review/repository_default_branch_requires_codeowners_review_test.py @@ -0,0 +1,149 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_repository_default_branch_requires_codeowners_review: + def test_no_repositories(self): + repository_client = mock.MagicMock + repository_client.repositories = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review import ( + repository_default_branch_requires_codeowners_review, + ) + + check = repository_default_branch_requires_codeowners_review() + result = check.execute() + assert len(result) == 0 + + def test_one_repository_no_codeowner_approval(self): + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review import ( + repository_default_branch_requires_codeowners_review, + ) + + check = repository_default_branch_requires_codeowners_review() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == repo_name + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} does not require code owner approval for changes to owned code." + ) + + def test_one_repository_with_codeowner_approval(self): + repository_client = mock.MagicMock + repo_name = "repo2" + repository_client.repositories = { + 2: Repo( + id=2, + name=repo_name, + owner="account-name", + full_name="account-name/repo2", + default_branch=Branch( + name="main", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_codeowners_review.repository_default_branch_requires_codeowners_review import ( + repository_default_branch_requires_codeowners_review, + ) + + check = repository_default_branch_requires_codeowners_review() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 2 + assert result[0].resource_name == repo_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Repository {repo_name} requires code owner approval for changes to owned code." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py index 2fe4b1dbd5..8f5a82bbd8 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_conversation_resolution/repository_default_branch_requires_conversation_resolution_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -35,11 +36,31 @@ class Test_repository_default_branch_requires_conversation_resolution_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch=default_branch, - conversation_resolution=False, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -65,7 +86,7 @@ class Test_repository_default_branch_requires_conversation_resolution_test: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Repository {repo_name} does not require conversation resolution." + == f"Repository {repo_name} does not require conversation resolution on default branch ({default_branch})." ) def test_conversation_resolution_enabled(self): @@ -76,11 +97,31 @@ class Test_repository_default_branch_requires_conversation_resolution_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), private=False, - default_branch=default_branch, - conversation_resolution=True, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, ), } @@ -106,5 +147,5 @@ class Test_repository_default_branch_requires_conversation_resolution_test: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Repository {repo_name} does require conversation resolution." + == f"Repository {repo_name} does require conversation resolution on default branch ({default_branch})." ) diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py index 5759697999..b072396804 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_linear_history/repository_default_branch_requires_linear_history_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -35,11 +36,31 @@ class Test_repository_default_branch_requires_linear_history_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch=default_branch, - required_linear_history=False, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -76,11 +97,31 @@ class Test_repository_default_branch_requires_linear_history_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), private=False, - default_branch=default_branch, - required_linear_history=True, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, ), } diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py index be8905fc7d..ddb9e89df6 100644 --- a/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_requires_multiple_approvals/repository_default_branch_requires_multiple_approvals_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -34,13 +35,31 @@ class Test_repository_default_branch_requires_multiple_approvals: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch_protection=False, - default_branch="main", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=False, - require_pull_request=False, - approval_count=0, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -76,13 +95,31 @@ class Test_repository_default_branch_requires_multiple_approvals: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch_protection=False, - default_branch="master", + default_branch=Branch( + name="master", + protected=False, + default_branch=True, + require_pull_request=True, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=False, - require_pull_request=True, - approval_count=0, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -118,13 +155,31 @@ class Test_repository_default_branch_requires_multiple_approvals: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch_protection=True, - default_branch="master", + default_branch=Branch( + name="master", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=2, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, - require_pull_request=True, - approval_count=2, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } diff --git a/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py b/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py new file mode 100644 index 0000000000..5785dd2a14 --- /dev/null +++ b/tests/providers/github/services/repository/repository_default_branch_requires_signed_commits/repository_default_branch_requires_signed_commits_test.py @@ -0,0 +1,151 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_repository_default_branch_requires_signed_commits: + def test_no_repositories(self): + repository_client = mock.MagicMock + repository_client.repositories = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits import ( + repository_default_branch_requires_signed_commits, + ) + + check = repository_default_branch_requires_signed_commits() + result = check.execute() + assert len(result) == 0 + + def test_signed_commits_not_required(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits import ( + repository_default_branch_requires_signed_commits, + ) + + check = repository_default_branch_requires_signed_commits() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} does not require signed commits on default branch ({default_branch})." + ) + + def test_signed_commits_required(self): + repository_client = mock.MagicMock + repo_name = "repo1" + default_branch = "main" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name=default_branch, + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=1, + required_linear_history=True, + allow_force_pushes=False, + branch_deletion=False, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=True, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_default_branch_requires_signed_commits.repository_default_branch_requires_signed_commits import ( + repository_default_branch_requires_signed_commits, + ) + + check = repository_default_branch_requires_signed_commits() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == "repo1" + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Repository {repo_name} does require signed commits on default branch ({default_branch})." + ) diff --git a/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py b/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py index 0da7d9425f..1ec8acc3e0 100644 --- a/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py +++ b/tests/providers/github/services/repository/repository_default_branch_status_checks_required/repository_default_branch_status_checks_required_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -35,11 +36,32 @@ class Test_repository_default_branch_status_checks_required_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch=default_branch, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), status_checks=False, + archived=False, + pushed_at=datetime.now(timezone.utc), private=False, securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } @@ -76,11 +98,32 @@ class Test_repository_default_branch_status_checks_required_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", private=False, - default_branch=default_branch, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=True, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), status_checks=True, + archived=False, + pushed_at=datetime.now(timezone.utc), securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, ), } diff --git a/tests/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled_test.py b/tests/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled_test.py new file mode 100644 index 0000000000..990886ad00 --- /dev/null +++ b/tests/providers/github/services/repository/repository_dependency_scanning_enabled/repository_dependency_scanning_enabled_test.py @@ -0,0 +1,149 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_repository_dependency_scanning_enabled: + def test_no_repositories(self): + repository_client = mock.MagicMock + repository_client.repositories = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_dependency_scanning_enabled.repository_dependency_scanning_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_dependency_scanning_enabled.repository_dependency_scanning_enabled import ( + repository_dependency_scanning_enabled, + ) + + check = repository_dependency_scanning_enabled() + result = check.execute() + assert len(result) == 0 + + def test_one_repository_no_dependabot(self): + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=True, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_dependency_scanning_enabled.repository_dependency_scanning_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_dependency_scanning_enabled.repository_dependency_scanning_enabled import ( + repository_dependency_scanning_enabled, + ) + + check = repository_dependency_scanning_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == repo_name + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} does not have package vulnerability scanning (Dependabot alerts) enabled." + ) + + def test_one_repository_with_dependabot(self): + repository_client = mock.MagicMock + repo_name = "repo2" + repository_client.repositories = { + 2: Repo( + id=2, + name=repo_name, + owner="account-name", + full_name="account-name/repo2", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=True, + dependabot_alerts_enabled=True, + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_dependency_scanning_enabled.repository_dependency_scanning_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_dependency_scanning_enabled.repository_dependency_scanning_enabled import ( + repository_dependency_scanning_enabled, + ) + + check = repository_dependency_scanning_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 2 + assert result[0].resource_name == repo_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Repository {repo_name} has package vulnerability scanning (Dependabot alerts) enabled." + ) diff --git a/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py b/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py new file mode 100644 index 0000000000..a181ec8404 --- /dev/null +++ b/tests/providers/github/services/repository/repository_has_codeowners_file/repository_has_codeowners_file_test.py @@ -0,0 +1,147 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_repository_has_codeowners_file: + def test_no_repositories(self): + repository_client = mock.MagicMock + repository_client.repositories = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_has_codeowners_file.repository_has_codeowners_file.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_has_codeowners_file.repository_has_codeowners_file import ( + repository_has_codeowners_file, + ) + + check = repository_has_codeowners_file() + result = check.execute() + assert len(result) == 0 + + def test_one_repository_no_codeowners(self): + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_has_codeowners_file.repository_has_codeowners_file.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_has_codeowners_file.repository_has_codeowners_file import ( + repository_has_codeowners_file, + ) + + check = repository_has_codeowners_file() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == repo_name + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} does not have a CODEOWNERS file." + ) + + def test_one_repository_with_codeowners(self): + repository_client = mock.MagicMock + repo_name = "repo2" + repository_client.repositories = { + 2: Repo( + id=2, + name=repo_name, + owner="account-name", + full_name="account-name/repo2", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + securitymd=True, + codeowners_exists=True, + secret_scanning_enabled=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_has_codeowners_file.repository_has_codeowners_file.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_has_codeowners_file.repository_has_codeowners_file import ( + repository_has_codeowners_file, + ) + + check = repository_has_codeowners_file() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 2 + assert result[0].resource_name == repo_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Repository {repo_name} does have a CODEOWNERS file." + ) diff --git a/tests/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived_test.py b/tests/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived_test.py new file mode 100644 index 0000000000..69ff88a93a --- /dev/null +++ b/tests/providers/github/services/repository/repository_inactive_not_archived/repository_inactive_not_archived_test.py @@ -0,0 +1,286 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock + +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_repository_inactive_not_archived: + def test_no_repositories(self): + repository_client = mock.MagicMock + repository_client.repositories = {} + repository_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived import ( + repository_inactive_not_archived, + ) + + check = repository_inactive_not_archived() + result = check.execute() + assert len(result) == 0 + + def test_repository_active_not_archived(self): + repository_client = mock.MagicMock + repo_name = "test-repo" + default_branch = "main" + now = datetime.now(timezone.utc) + recent_activity = now - timedelta(days=30) # 30 days ago + + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/test-repo", + private=False, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + archived=False, + pushed_at=recent_activity, + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + repository_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived import ( + repository_inactive_not_archived, + ) + + check = repository_inactive_not_archived() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == repo_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Repository {repo_name} has been active within the last 180 days (30 days ago)." + ) + + def test_repository_inactive_not_archived(self): + repository_client = mock.MagicMock + repo_name = "test-repo" + default_branch = "main" + now = datetime.now(timezone.utc) + old_activity = now - timedelta(days=200) # 200 days ago + + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/test-repo", + private=False, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + archived=False, + pushed_at=old_activity, + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + repository_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived import ( + repository_inactive_not_archived, + ) + + check = repository_inactive_not_archived() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == repo_name + assert result[0].status == "FAIL" + assert "has been inactive for 200 days" in result[0].status_extended + assert "and is not archived" in result[0].status_extended + + def test_repository_inactive_but_archived(self): + repository_client = mock.MagicMock + repo_name = "test-repo" + default_branch = "main" + now = datetime.now(timezone.utc) + old_activity = now - timedelta(days=200) # 200 days ago + + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/test-repo", + private=False, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + archived=True, + pushed_at=old_activity, + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + repository_client.audit_config = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived import ( + repository_inactive_not_archived, + ) + + check = repository_inactive_not_archived() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == repo_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Repository {repo_name} is properly archived." + ) + + def test_custom_days_threshold(self): + repository_client = mock.MagicMock + repo_name = "test-repo" + default_branch = "main" + now = datetime.now(timezone.utc) + old_activity = now - timedelta(days=50) + + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/test-repo", + private=False, + default_branch=Branch( + name=default_branch, + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + archived=False, + pushed_at=old_activity, + securitymd=False, + codeowners_exists=False, + secret_scanning_enabled=False, + dependabot_alerts_enabled=False, + delete_branch_on_merge=False, + ), + } + repository_client.audit_config = {"inactive_not_archived_days_threshold": 40} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_inactive_not_archived.repository_inactive_not_archived import ( + repository_inactive_not_archived, + ) + + check = repository_inactive_not_archived() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == repo_name + assert result[0].status == "FAIL" + assert "has been inactive for 50 days" in result[0].status_extended + assert "and is not archived" in result[0].status_extended diff --git a/tests/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file_test.py b/tests/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file_test.py index 776d557252..45cf49edd7 100644 --- a/tests/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file_test.py +++ b/tests/providers/github/services/repository/repository_public_has_securitymd_file/repository_public_has_securitymd_file_test.py @@ -1,6 +1,7 @@ +from datetime import datetime, timezone from unittest import mock -from prowler.providers.github.services.repository.repository_service import Repo +from prowler.providers.github.services.repository.repository_service import Branch, Repo from tests.providers.github.github_fixtures import set_mocked_github_provider @@ -34,12 +35,30 @@ class Test_repository_public_has_securitymd_file_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch="main", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, securitymd=False, - require_pull_request=False, - approval_count=0, + codeowners_exists=False, + secret_scanning_enabled=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + delete_branch_on_merge=False, ), } @@ -75,12 +94,30 @@ class Test_repository_public_has_securitymd_file_test: 1: Repo( id=1, name=repo_name, + owner="account-name", full_name="account-name/repo1", - default_branch="main", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), private=False, securitymd=True, - require_pull_request=False, - approval_count=0, + codeowners_exists=False, + secret_scanning_enabled=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + delete_branch_on_merge=False, ), } diff --git a/tests/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled_test.py b/tests/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled_test.py new file mode 100644 index 0000000000..ccdcc4dd25 --- /dev/null +++ b/tests/providers/github/services/repository/repository_secret_scanning_enabled/repository_secret_scanning_enabled_test.py @@ -0,0 +1,147 @@ +from datetime import datetime, timezone +from unittest import mock + +from prowler.providers.github.services.repository.repository_service import Branch, Repo +from tests.providers.github.github_fixtures import set_mocked_github_provider + + +class Test_repository_secret_scanning_enabled: + def test_no_repositories(self): + repository_client = mock.MagicMock + repository_client.repositories = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_secret_scanning_enabled.repository_secret_scanning_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_secret_scanning_enabled.repository_secret_scanning_enabled import ( + repository_secret_scanning_enabled, + ) + + check = repository_secret_scanning_enabled() + result = check.execute() + assert len(result) == 0 + + def test_one_repository_no_secret_scanning(self): + repository_client = mock.MagicMock + repo_name = "repo1" + repository_client.repositories = { + 1: Repo( + id=1, + name=repo_name, + owner="account-name", + full_name="account-name/repo1", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=False, + archived=False, + pushed_at=datetime.now(timezone.utc), + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_secret_scanning_enabled.repository_secret_scanning_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_secret_scanning_enabled.repository_secret_scanning_enabled import ( + repository_secret_scanning_enabled, + ) + + check = repository_secret_scanning_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 1 + assert result[0].resource_name == repo_name + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Repository {repo_name} does not have secret scanning enabled to detect sensitive data." + ) + + def test_one_repository_with_secret_scanning(self): + repository_client = mock.MagicMock + repo_name = "repo2" + repository_client.repositories = { + 2: Repo( + id=2, + name=repo_name, + owner="account-name", + full_name="account-name/repo2", + default_branch=Branch( + name="main", + protected=False, + default_branch=True, + require_pull_request=False, + approval_count=0, + required_linear_history=False, + allow_force_pushes=True, + branch_deletion=True, + status_checks=False, + enforce_admins=False, + require_code_owner_reviews=False, + require_signed_commits=False, + conversation_resolution=False, + ), + private=False, + securitymd=True, + codeowners_exists=False, + secret_scanning_enabled=True, + archived=False, + pushed_at=datetime.now(timezone.utc), + delete_branch_on_merge=False, + ), + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_github_provider(), + ), + mock.patch( + "prowler.providers.github.services.repository.repository_secret_scanning_enabled.repository_secret_scanning_enabled.repository_client", + new=repository_client, + ), + ): + from prowler.providers.github.services.repository.repository_secret_scanning_enabled.repository_secret_scanning_enabled import ( + repository_secret_scanning_enabled, + ) + + check = repository_secret_scanning_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].resource_id == 2 + assert result[0].resource_name == repo_name + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Repository {repo_name} has secret scanning enabled to detect sensitive data." + ) diff --git a/tests/providers/github/services/repository/repository_service_test.py b/tests/providers/github/services/repository/repository_service_test.py index bcc3b19791..487ac41e7c 100644 --- a/tests/providers/github/services/repository/repository_service_test.py +++ b/tests/providers/github/services/repository/repository_service_test.py @@ -1,6 +1,8 @@ -from unittest.mock import patch +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch from prowler.providers.github.services.repository.repository_service import ( + Branch, Repo, Repository, ) @@ -12,20 +14,30 @@ def mock_list_repositories(_): 1: Repo( id=1, name="repo1", + owner="account-name", full_name="account-name/repo1", - default_branch_protection=True, - default_branch="main", + default_branch=Branch( + name="main", + protected=True, + default_branch=True, + require_pull_request=True, + approval_count=2, + required_linear_history=True, + allow_force_pushes=True, + branch_deletion=True, + status_checks=True, + enforce_admins=True, + require_code_owner_reviews=True, + require_signed_commits=True, + conversation_resolution=True, + ), private=False, securitymd=True, - require_pull_request=True, - required_linear_history=True, - allow_force_pushes=True, - default_branch_deletion=True, - status_checks=True, - approval_count=2, - enforce_admins=True, + codeowners_exists=True, + secret_scanning_enabled=True, + archived=False, + pushed_at=datetime.now(timezone.utc), delete_branch_on_merge=True, - conversation_resolution=True, ), } @@ -49,14 +61,48 @@ class Test_Repository_Service: assert repository_service.repositories[1].name == "repo1" assert repository_service.repositories[1].full_name == "account-name/repo1" assert repository_service.repositories[1].private is False - assert repository_service.repositories[1].default_branch == "main" + assert repository_service.repositories[1].default_branch.name == "main" assert repository_service.repositories[1].securitymd - assert repository_service.repositories[1].required_linear_history - assert repository_service.repositories[1].require_pull_request - assert repository_service.repositories[1].allow_force_pushes - assert repository_service.repositories[1].default_branch_deletion - assert repository_service.repositories[1].status_checks - assert repository_service.repositories[1].enforce_admins + assert repository_service.repositories[1].default_branch.required_linear_history + assert repository_service.repositories[1].default_branch.require_pull_request + assert repository_service.repositories[1].default_branch.allow_force_pushes + assert repository_service.repositories[1].default_branch.branch_deletion + assert repository_service.repositories[1].default_branch.status_checks + assert repository_service.repositories[1].default_branch.enforce_admins assert repository_service.repositories[1].delete_branch_on_merge - assert repository_service.repositories[1].conversation_resolution - assert repository_service.repositories[1].approval_count == 2 + assert repository_service.repositories[1].default_branch.conversation_resolution + assert repository_service.repositories[1].default_branch.approval_count == 2 + assert repository_service.repositories[1].codeowners_exists is True + assert ( + repository_service.repositories[1].default_branch.require_code_owner_reviews + is True + ) + assert repository_service.repositories[1].secret_scanning_enabled is True + assert ( + repository_service.repositories[1].default_branch.require_signed_commits + is True + ) + assert repository_service.repositories[1].archived is False + assert repository_service.repositories[1].pushed_at is not None + + +class Test_Repository_FileExists: + def setup_method(self): + self.repository = Repository(set_mocked_github_provider()) + self.mock_repo = MagicMock() + + def test_file_exists_returns_true(self): + self.mock_repo.get_contents.return_value = object() + assert self.repository._file_exists(self.mock_repo, "somefile.txt") is True + + def test_file_not_found_returns_false(self): + self.mock_repo.get_contents.side_effect = Exception("404 Not Found") + assert self.repository._file_exists(self.mock_repo, "nofile.txt") is False + + def test_other_error_returns_none_and_logs(self): + self.mock_repo.get_contents.side_effect = Exception("Some other error") + with patch( + "prowler.providers.github.services.repository.repository_service.logger" + ) as mock_logger: + assert self.repository._file_exists(self.mock_repo, "errorfile.txt") is None + assert mock_logger.error.called diff --git a/tests/providers/kubernetes/kubernetes_provider_test.py b/tests/providers/kubernetes/kubernetes_provider_test.py index d21792aeb0..b15933e848 100644 --- a/tests/providers/kubernetes/kubernetes_provider_test.py +++ b/tests/providers/kubernetes/kubernetes_provider_test.py @@ -1,5 +1,5 @@ from argparse import Namespace -from unittest.mock import MagicMock, patch +from unittest.mock import patch from kubernetes.config.config_exception import ConfigException @@ -354,58 +354,322 @@ class TestKubernetesProvider: assert isinstance(session, KubernetesSession) assert session.context["context"]["cluster"] == "cli-cluster-name" - def test_kubernetes_provider_proxy_from_env(self, monkeypatch): + @patch( + "prowler.providers.kubernetes.kubernetes_provider.client.CoreV1Api.list_namespace" + ) + @patch("kubernetes.config.list_kube_config_contexts") + @patch("kubernetes.config.load_kube_config_from_dict") + def test_kubernetes_provider_proxy_from_env( + self, + mock_load_kube_config_from_dict, + mock_list_kube_config_contexts, + mock_list_namespace, + monkeypatch, + ): + monkeypatch.setenv("HTTPS_PROXY", "http://my.internal.proxy:8888") - captured = {} + mock_load_kube_config_from_dict.return_value = None + mock_list_kube_config_contexts.return_value = ( + [ + { + "name": "example-context", + "context": { + "cluster": "example-cluster", + "user": "example-user", + }, + } + ], + None, + ) + mock_list_namespace.return_value.items = [ + client.V1Namespace(metadata=client.V1ObjectMeta(name="namespace-1")), + ] - def fake_api_client(configuration): - captured["proxy"] = getattr(configuration, "proxy", None) - return MagicMock() + kubeconfig_content = '{"apiVersion": "v1", "clusters": [{"cluster": {"server": "https://kubernetes.example.com"}, "name": "example-cluster"}], "contexts": [{"context": {"cluster": "example-cluster", "user": "example-user"}, "name": "example-context"}], "current-context": "example-context", "kind": "Config", "preferences": {}, "users": [{"name": "example-user", "user": {"token": "EXAMPLE_TOKEN"}}]}' - with ( - patch( - "kubernetes.config.load_kube_config", - side_effect=ConfigException("No kubeconfig"), - ), - patch("kubernetes.config.load_incluster_config", return_value=None), - patch( - "prowler.providers.kubernetes.kubernetes_provider.ApiClient", - side_effect=fake_api_client, - ), - patch( - "prowler.providers.kubernetes.kubernetes_provider.KubernetesProvider.get_all_namespaces", - return_value=["default"], - ), - ): - KubernetesProvider.setup_session() + session = KubernetesProvider.setup_session( + kubeconfig_content=kubeconfig_content, + context="example-context", + ) - assert captured["proxy"] == "http://my.internal.proxy:8888" + assert isinstance(session, KubernetesSession) + assert isinstance(session.api_client, client.ApiClient) + assert isinstance(session.api_client.configuration, client.Configuration) + assert session.api_client.configuration.verify_ssl + assert session.api_client.configuration.proxy == "http://my.internal.proxy:8888" - def test_kubernetes_provider_disable_tls_verification(self, monkeypatch): + @patch( + "prowler.providers.kubernetes.kubernetes_provider.client.CoreV1Api.list_namespace" + ) + @patch("kubernetes.config.list_kube_config_contexts") + @patch("kubernetes.config.load_kube_config_from_dict") + def test_kubernetes_provider_disable_tls_verification( + self, + mock_load_kube_config_from_dict, + mock_list_kube_config_contexts, + mock_list_namespace, + monkeypatch, + ): monkeypatch.setenv("K8S_SKIP_TLS_VERIFY", "true") - captured = {} + mock_load_kube_config_from_dict.return_value = None + mock_list_kube_config_contexts.return_value = ( + [ + { + "name": "example-context", + "context": { + "cluster": "example-cluster", + "user": "example-user", + }, + } + ], + None, + ) + mock_list_namespace.return_value.items = [ + client.V1Namespace(metadata=client.V1ObjectMeta(name="namespace-1")), + ] - def fake_api_client(configuration): - captured["verify_ssl"] = getattr(configuration, "verify_ssl", True) - return MagicMock() + kubeconfig_content = '{"apiVersion": "v1", "clusters": [{"cluster": {"server": "https://kubernetes.example.com"}, "name": "example-cluster"}], "contexts": [{"context": {"cluster": "example-cluster", "user": "example-user"}, "name": "example-context"}], "current-context": "example-context", "kind": "Config", "preferences": {}, "users": [{"name": "example-user", "user": {"token": "EXAMPLE_TOKEN"}}]}' - with ( - patch( - "kubernetes.config.load_kube_config", - side_effect=ConfigException("No kubeconfig"), - ), - patch("kubernetes.config.load_incluster_config", return_value=None), - patch( - "prowler.providers.kubernetes.kubernetes_provider.ApiClient", - side_effect=fake_api_client, - ), - patch( - "prowler.providers.kubernetes.kubernetes_provider.KubernetesProvider.get_all_namespaces", - return_value=["default"], - ), + session = KubernetesProvider.setup_session( + kubeconfig_content=kubeconfig_content, + context="example-context", + ) + + assert isinstance(session, KubernetesSession) + assert isinstance(session.api_client, client.ApiClient) + assert isinstance(session.api_client.configuration, client.Configuration) + assert session.api_client.configuration.verify_ssl is False + assert session.api_client.configuration.proxy is None + + @patch( + "prowler.providers.kubernetes.kubernetes_provider.client.CoreV1Api.list_namespace" + ) + @patch("kubernetes.config.list_kube_config_contexts") + @patch("kubernetes.config.load_kube_config_from_dict") + def test_kubernetes_provider_kubeconfig_content( + self, + mock_load_kube_config_from_dict, + mock_list_kube_config_contexts, + mock_list_namespace, + ): + mock_load_kube_config_from_dict.return_value = None + mock_list_kube_config_contexts.return_value = ( + [ + { + "name": "example-context", + "context": { + "cluster": "example-cluster", + "user": "example-user", + }, + } + ], + None, + ) + mock_list_namespace.return_value.items = [ + client.V1Namespace(metadata=client.V1ObjectMeta(name="namespace-1")), + ] + + kubeconfig_content = '{"apiVersion": "v1", "clusters": [{"cluster": {"server": "https://kubernetes.example.com"}, "name": "example-cluster"}], "contexts": [{"context": {"cluster": "example-cluster", "user": "example-user"}, "name": "example-context"}], "current-context": "example-context", "kind": "Config", "preferences": {}, "users": [{"name": "example-user", "user": {"token": "EXAMPLE_TOKEN"}}]}' + + session = KubernetesProvider.setup_session( + kubeconfig_content=kubeconfig_content, + context="example-context", + ) + + assert isinstance(session, KubernetesSession) + assert isinstance(session.api_client, client.ApiClient) + + assert session.context == { + "name": "example-context", + "context": { + "cluster": "example-cluster", + "user": "example-user", + }, + } + + @patch( + "prowler.providers.kubernetes.kubernetes_provider.client.CoreV1Api.list_namespace" + ) + @patch("kubernetes.config.list_kube_config_contexts") + @patch("kubernetes.config.load_kube_config_from_dict") + def test_kubernetes_provider_kubeconfig_content_proxy_settings( + self, + mock_load_kube_config_from_dict, + mock_list_kube_config_contexts, + mock_list_namespace, + monkeypatch, + ): + monkeypatch.setenv("HTTPS_PROXY", "http://my.internal.proxy:8888") + monkeypatch.setenv("K8S_SKIP_TLS_VERIFY", "true") + + mock_load_kube_config_from_dict.return_value = None + mock_list_kube_config_contexts.return_value = ( + [ + { + "name": "example-context", + "context": { + "cluster": "example-cluster", + "user": "example-user", + }, + } + ], + None, + ) + mock_list_namespace.return_value.items = [ + client.V1Namespace(metadata=client.V1ObjectMeta(name="namespace-1")), + ] + + kubeconfig_content = '{"apiVersion": "v1", "clusters": [{"cluster": {"server": "https://kubernetes.example.com"}, "name": "example-cluster"}], "contexts": [{"context": {"cluster": "example-cluster", "user": "example-user"}, "name": "example-context"}], "current-context": "example-context", "kind": "Config", "preferences": {}, "users": [{"name": "example-user", "user": {"token": "EXAMPLE_TOKEN"}}]}' + + session = KubernetesProvider.setup_session( + kubeconfig_content=kubeconfig_content, + context="example-context", + ) + + assert isinstance(session, KubernetesSession) + assert isinstance(session.api_client, client.ApiClient) + + assert session.context == { + "name": "example-context", + "context": { + "cluster": "example-cluster", + "user": "example-user", + }, + } + + assert session.api_client.configuration.proxy == "http://my.internal.proxy:8888" + assert session.api_client.configuration.verify_ssl is False + + def test_set_proxy_settings_no_proxy_no_tls_skip(self): + """Test set_proxy_settings with no environment variables set.""" + with patch.dict("os.environ", {}, clear=True): + config = KubernetesProvider.set_proxy_settings() + + # Verify it's a Configuration instance from kubernetes.client + from kubernetes.client import Configuration + + assert isinstance(config, Configuration) + + assert hasattr(config, "proxy") + assert config.proxy is None + assert hasattr(config, "verify_ssl") + assert config.verify_ssl is True + + def test_set_proxy_settings_with_https_proxy_uppercase(self): + """Test set_proxy_settings with HTTPS_PROXY environment variable.""" + proxy_url = "http://proxy.example.com:8080" + with patch.dict("os.environ", {"HTTPS_PROXY": proxy_url}, clear=True): + config = KubernetesProvider.set_proxy_settings() + + # Verify it's a Configuration instance from kubernetes.client + from kubernetes.client import Configuration + + assert isinstance(config, Configuration) + + assert config.proxy == proxy_url + assert config.verify_ssl is True + + def test_set_proxy_settings_with_https_proxy_lowercase(self): + """Test set_proxy_settings with https_proxy environment variable.""" + proxy_url = "http://proxy.example.com:3128" + with patch.dict("os.environ", {"https_proxy": proxy_url}, clear=True): + config = KubernetesProvider.set_proxy_settings() + + # Verify it's a Configuration instance from kubernetes.client + from kubernetes.client import Configuration + + assert isinstance(config, Configuration) + + assert config.proxy == proxy_url + assert config.verify_ssl is True + + def test_set_proxy_settings_uppercase_proxy_takes_precedence(self): + """Test that HTTPS_PROXY takes precedence over https_proxy.""" + uppercase_proxy = "http://uppercase.proxy.com:8080" + lowercase_proxy = "http://lowercase.proxy.com:3128" + with patch.dict( + "os.environ", + {"HTTPS_PROXY": uppercase_proxy, "https_proxy": lowercase_proxy}, + clear=True, ): - KubernetesProvider.setup_session() + config = KubernetesProvider.set_proxy_settings() - assert captured["verify_ssl"] is False + # Verify it's a Configuration instance from kubernetes.client + from kubernetes.client import Configuration + + assert isinstance(config, Configuration) + + assert config.proxy == uppercase_proxy + assert config.verify_ssl is True + + def test_set_proxy_settings_with_tls_skip_true(self): + """Test set_proxy_settings with K8S_SKIP_TLS_VERIFY set to true.""" + with patch.dict("os.environ", {"K8S_SKIP_TLS_VERIFY": "true"}, clear=True): + config = KubernetesProvider.set_proxy_settings() + + # Verify it's a Configuration instance from kubernetes.client + from kubernetes.client import Configuration + + assert isinstance(config, Configuration) + + assert config.proxy is None + assert config.verify_ssl is False + + def test_set_proxy_settings_with_tls_skip_true_uppercase(self): + """Test set_proxy_settings with K8S_SKIP_TLS_VERIFY set to TRUE.""" + with patch.dict("os.environ", {"K8S_SKIP_TLS_VERIFY": "TRUE"}, clear=True): + config = KubernetesProvider.set_proxy_settings() + + # Verify it's a Configuration instance from kubernetes.client + from kubernetes.client import Configuration + + assert isinstance(config, Configuration) + + assert config.proxy is None + assert config.verify_ssl is False + + def test_set_proxy_settings_with_tls_skip_false(self): + """Test set_proxy_settings with K8S_SKIP_TLS_VERIFY set to false.""" + with patch.dict("os.environ", {"K8S_SKIP_TLS_VERIFY": "false"}, clear=True): + config = KubernetesProvider.set_proxy_settings() + + # Verify it's a Configuration instance from kubernetes.client + from kubernetes.client import Configuration + + assert isinstance(config, Configuration) + + assert config.proxy is None + assert config.verify_ssl is True + + def test_set_proxy_settings_with_tls_skip_invalid_value(self): + """Test set_proxy_settings with K8S_SKIP_TLS_VERIFY set to invalid value.""" + with patch.dict("os.environ", {"K8S_SKIP_TLS_VERIFY": "invalid"}, clear=True): + config = KubernetesProvider.set_proxy_settings() + + # Verify it's a Configuration instance from kubernetes.client + from kubernetes.client import Configuration + + assert isinstance(config, Configuration) + + assert config.proxy is None + assert config.verify_ssl is True + + def test_set_proxy_settings_with_both_proxy_and_tls_skip(self): + """Test set_proxy_settings with both proxy and TLS skip settings.""" + proxy_url = "http://secure.proxy.com:8080" + with patch.dict( + "os.environ", + {"HTTPS_PROXY": proxy_url, "K8S_SKIP_TLS_VERIFY": "true"}, + clear=True, + ): + config = KubernetesProvider.set_proxy_settings() + + # Verify it's a Configuration instance from kubernetes.client + from kubernetes.client import Configuration + + assert isinstance(config, Configuration) + + assert config.proxy == proxy_url + assert config.verify_ssl is False diff --git a/tests/providers/kubernetes/lib/mutelist/kubernetes_mutelist_test.py b/tests/providers/kubernetes/lib/mutelist/kubernetes_mutelist_test.py index 7cc1ae394d..847c82aae8 100644 --- a/tests/providers/kubernetes/lib/mutelist/kubernetes_mutelist_test.py +++ b/tests/providers/kubernetes/lib/mutelist/kubernetes_mutelist_test.py @@ -36,7 +36,7 @@ class TestKubernetesMutelist: mutelist = KubernetesMutelist(mutelist_content=mutelist_fixture) - assert not mutelist.validate_mutelist() + assert len(mutelist.validate_mutelist(mutelist_fixture)) == 0 assert mutelist.mutelist == {} assert mutelist.mutelist_file_path is None diff --git a/tests/providers/m365/lib/mutelist/m365_mutelist_test.py b/tests/providers/m365/lib/mutelist/m365_mutelist_test.py index 8df9a97292..a079666ce8 100644 --- a/tests/providers/m365/lib/mutelist/m365_mutelist_test.py +++ b/tests/providers/m365/lib/mutelist/m365_mutelist_test.py @@ -34,7 +34,7 @@ class TestM365Mutelist: mutelist = M365Mutelist(mutelist_content=mutelist_fixture) - assert not mutelist.validate_mutelist() + assert len(mutelist.validate_mutelist(mutelist_fixture)) == 0 assert mutelist.mutelist == {} assert mutelist.mutelist_file_path is None diff --git a/tests/providers/m365/lib/powershell/m365_powershell_test.py b/tests/providers/m365/lib/powershell/m365_powershell_test.py index 7bc3236433..739dd7e8ce 100644 --- a/tests/providers/m365/lib/powershell/m365_powershell_test.py +++ b/tests/providers/m365/lib/powershell/m365_powershell_test.py @@ -75,6 +75,7 @@ class Testm365PowerShell: credentials = M365Credentials( user="test@example.com", passwd="test_password", + encrypted_passwd="test_password", client_id="test_client_id", client_secret="test_client_secret", tenant_id="test_tenant_id", @@ -89,13 +90,19 @@ class Testm365PowerShell: ) session = M365PowerShell(credentials, identity) + # Mock encrypt_password to return a known value + session.encrypt_password = MagicMock(return_value="encrypted_password") session.execute = MagicMock() session.init_credential(credentials) + # Verify encrypt_password was called + session.encrypt_password.assert_called_once_with(credentials.passwd) + + # Verify execute was called with the correct commands session.execute.assert_any_call(f'$user = "{credentials.user}"') session.execute.assert_any_call( - f'$secureString = "{credentials.passwd}" | ConvertTo-SecureString' + f'$secureString = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' ) session.execute.assert_any_call( "$credential = New-Object System.Management.Automation.PSCredential ($user, $secureString)" @@ -116,6 +123,7 @@ class Testm365PowerShell: credentials = M365Credentials( user="test@contoso.onmicrosoft.com", passwd="test_password", + encrypted_passwd="test_encrypted_password", client_id="test_client_id", client_secret="test_client_secret", tenant_id="test_tenant_id", @@ -130,11 +138,9 @@ class Testm365PowerShell: ) session = M365PowerShell(credentials, identity) - # Mock read_output to return the decrypted password - session.read_output = MagicMock(return_value="decrypted_password") - - # Mock execute to return the result of read_output - session.execute = MagicMock(side_effect=lambda _: session.read_output()) + # Mock encrypt_password to return a known value + session.encrypt_password = MagicMock(return_value="encrypted_password") + session.execute = MagicMock() # Execute the test result = session.test_credentials(credentials) @@ -142,14 +148,11 @@ class Testm365PowerShell: # Verify execute was called with the correct commands session.execute.assert_any_call( - f'$securePassword = "{credentials.passwd}" | ConvertTo-SecureString' + f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' ) session.execute.assert_any_call( f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)' ) - session.execute.assert_any_call( - 'Write-Output "$($credential.GetNetworkCredential().Password)"' - ) # Verify MSAL was called with the correct parameters mock_msal.assert_called_once_with( @@ -159,7 +162,7 @@ class Testm365PowerShell: ) mock_msal_instance.acquire_token_by_username_password.assert_called_once_with( username="test@contoso.onmicrosoft.com", - password="decrypted_password", + password="test_password", # Original password, not encrypted scopes=["https://graph.microsoft.com/.default"], ) session.close() @@ -229,6 +232,7 @@ class Testm365PowerShell: credentials = M365Credentials( user="test@contoso.onmicrosoft.com", passwd="test_password", + encrypted_passwd="test_encrypted_password", client_id="test_client_id", client_secret="test_client_secret", tenant_id="test_tenant_id", @@ -262,7 +266,7 @@ class Testm365PowerShell: ) mock_msal_instance.acquire_token_by_username_password.assert_called_once_with( username="test@contoso.onmicrosoft.com", - password="decrypted_password", + password="test_password", scopes=["https://graph.microsoft.com/.default"], ) @@ -561,3 +565,55 @@ class Testm365PowerShell: ) # Verify no info messages were logged mock_info.assert_not_called() + + @patch("subprocess.Popen") + def test_encrypt_password(self, mock_popen): + credentials = M365Credentials(user="test@example.com", passwd="test_password") + identity = M365IdentityInfo( + identity_id="test_id", + identity_type="User", + tenant_id="test_tenant", + tenant_domain="example.com", + tenant_domains=["example.com"], + location="test_location", + ) + session = M365PowerShell(credentials, identity) + + # Test non-Windows system (should use utf-16le hex encoding) + from unittest import mock + + with mock.patch("platform.system", return_value="Linux"): + result = session.encrypt_password("password123") + expected = "password123".encode("utf-16le").hex() + assert result == expected + + # Test Windows system with tuple return + with mock.patch("platform.system", return_value="Windows"): + import sys + + win32crypt_mock = mock.MagicMock() + win32crypt_mock.CryptProtectData.return_value = (None, b"encrypted_bytes") + sys.modules["win32crypt"] = win32crypt_mock + + result = session.encrypt_password("password123") + assert result == b"encrypted_bytes".hex() + + # Clean up mock + del sys.modules["win32crypt"] + + # Test error handling + with mock.patch("platform.system", return_value="Windows"): + import sys + + win32crypt_mock = mock.MagicMock() + win32crypt_mock.CryptProtectData.side_effect = Exception("Test error") + sys.modules["win32crypt"] = win32crypt_mock + + with pytest.raises(Exception) as exc_info: + session.encrypt_password("password123") + assert "Error encrypting password: Test error" in str(exc_info.value) + + # Clean up mock + del sys.modules["win32crypt"] + + session.close() diff --git a/tests/providers/m365/m365_provider_test.py b/tests/providers/m365/m365_provider_test.py index 2f30efabde..457173dc44 100644 --- a/tests/providers/m365/m365_provider_test.py +++ b/tests/providers/m365/m365_provider_test.py @@ -24,7 +24,7 @@ from prowler.providers.m365.exceptions.exceptions import ( M365NoAuthenticationMethodError, M365NotValidClientIdError, M365NotValidClientSecretError, - M365NotValidEncryptedPasswordError, + M365NotValidPasswordError, M365NotValidTenantIdError, M365NotValidUserError, M365UserNotBelongingToTenantError, @@ -365,7 +365,7 @@ class TestM365Provider: assert test_connection.is_connected assert test_connection.error is None - def test_test_connection_tenant_id_client_id_client_secret_no_user_encrypted_password( + def test_test_connection_tenant_id_client_id_client_secret_no_user_password( self, ): with patch( @@ -384,26 +384,24 @@ class TestM365Provider: client_id=str(uuid4()), client_secret=str(uuid4()), user=None, - encrypted_password="test_password", + password="test_password", ) assert exception.type == M365NotValidUserError assert "The provided M365 User is not valid." in str(exception.value) - def test_test_connection_tenant_id_client_id_client_secret_user_no_encrypted_password( + def test_test_connection_tenant_id_client_id_client_secret_user_no_password( self, ): with patch( "prowler.providers.m365.m365_provider.M365Provider.validate_static_credentials" ) as mock_validate_static_credentials: - mock_validate_static_credentials.side_effect = ( - M365NotValidEncryptedPasswordError( - file=os.path.basename(__file__), - message="The provided M365 Encrypted Password is not valid.", - ) + mock_validate_static_credentials.side_effect = M365NotValidPasswordError( + file=os.path.basename(__file__), + message="The provided M365 Password is not valid.", ) - with pytest.raises(M365NotValidEncryptedPasswordError) as exception: + with pytest.raises(M365NotValidPasswordError) as exception: M365Provider.test_connection( tenant_id=str(uuid4()), region="M365Global", @@ -411,13 +409,11 @@ class TestM365Provider: client_id=str(uuid4()), client_secret=str(uuid4()), user="test@example.com", - encrypted_password=None, + password=None, ) - assert exception.type == M365NotValidEncryptedPasswordError - assert "The provided M365 Encrypted Password is not valid." in str( - exception.value - ) + assert exception.type == M365NotValidPasswordError + assert "The provided M365 Password is not valid." in str(exception.value) def test_test_connection_with_httpresponseerror(self): with patch( @@ -467,7 +463,7 @@ class TestM365Provider: def test_setup_powershell_valid_credentials(self): credentials_dict = { "user": "test@example.com", - "encrypted_password": "test_password", + "password": "test_password", "client_id": "test_client_id", "tenant_id": "test_tenant_id", "client_secret": "test_client_secret", @@ -492,7 +488,7 @@ class TestM365Provider: ), ) assert result.user == credentials_dict["user"] - assert result.passwd == credentials_dict["encrypted_password"] + assert result.passwd == credentials_dict["password"] def test_setup_powershell_invalid_env_credentials(self): credentials = None @@ -510,7 +506,7 @@ class TestM365Provider: ) assert ( - "Missing M365_USER or M365_ENCRYPTED_PASSWORD environment variables required for credentials authentication" + "Missing M365_USER or M365_PASSWORD environment variables required for credentials authentication" in str(exc_info.value) ) mock_session.test_credentials.assert_not_called() @@ -534,7 +530,7 @@ class TestM365Provider: client_id=str(uuid4()), client_secret=str(uuid4()), user="user@otherdomain.com", - encrypted_password="test_password", + password="test_password", ) assert exception.type == M365UserNotBelongingToTenantError @@ -550,7 +546,7 @@ class TestM365Provider: client_id="12345678-1234-5678-1234-567812345678", client_secret="test_secret", user="test@example.com", - encrypted_password="test_password", + password="test_password", ) assert "The provided Tenant ID is not valid." in str(exception.value) @@ -561,7 +557,7 @@ class TestM365Provider: client_id="", client_secret="test_secret", user="test@example.com", - encrypted_password="test_password", + password="test_password", ) assert "The provided Client ID is not valid." in str(exception.value) @@ -572,7 +568,7 @@ class TestM365Provider: client_id="12345678-1234-5678-1234-567812345678", client_secret="", user="test@example.com", - encrypted_password="test_password", + password="test_password", ) assert "The provided Client Secret is not valid." in str(exception.value) @@ -583,20 +579,20 @@ class TestM365Provider: client_id="12345678-1234-5678-1234-567812345678", client_secret="test_secret", user="", - encrypted_password="test_password", + password="test_password", ) assert "The provided User is not valid." in str(exception.value) - def test_validate_static_credentials_missing_encrypted_password(self): - with pytest.raises(M365NotValidEncryptedPasswordError) as exception: + def test_validate_static_credentials_missing_password(self): + with pytest.raises(M365NotValidPasswordError) as exception: M365Provider.validate_static_credentials( tenant_id="12345678-1234-5678-1234-567812345678", client_id="12345678-1234-5678-1234-567812345678", client_secret="test_secret", user="test@example.com", - encrypted_password="", + password="", ) - assert "The provided Encrypted Password is not valid." in str(exception.value) + assert "The provided Password is not valid." in str(exception.value) def test_validate_arguments_missing_env_credentials(self): with pytest.raises(M365MissingEnvironmentCredentialsError) as exception: @@ -609,11 +605,11 @@ class TestM365Provider: client_id="test_client_id", client_secret="test_secret", user=None, - encrypted_password=None, + password=None, ) assert ( - "M365 provider requires AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID, M365_USER and M365_ENCRYPTED_PASSWORD environment variables to be set when using --env-auth" + "M365 provider requires AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID, M365_USER and M365_PASSWORD environment variables to be set when using --env-auth" in str(exception.value) ) @@ -655,7 +651,7 @@ class TestM365Provider: client_id=str(uuid4()), client_secret=str(uuid4()), user=f"user@{user_domain}", - encrypted_password="test_password", + password="test_password", provider_id=provider_id, ) @@ -669,7 +665,7 @@ class TestM365Provider: """Test that initialize_m365_powershell_modules is not called when init_modules is False""" credentials_dict = { "user": "test@example.com", - "encrypted_password": "test_password", + "password": "test_password", "client_id": "test_client_id", "tenant_id": "test_tenant_id", "client_secret": "test_client_secret", @@ -703,7 +699,7 @@ class TestM365Provider: """Test that initialize_m365_powershell_modules is called when init_modules is True""" credentials_dict = { "user": "test@example.com", - "encrypted_password": "test_password", + "password": "test_password", "client_id": "test_client_id", "tenant_id": "test_tenant_id", "client_secret": "test_client_secret", @@ -737,7 +733,7 @@ class TestM365Provider: """Test that setup_powershell handles initialization failures correctly""" credentials_dict = { "user": "test@example.com", - "encrypted_password": "test_password", + "password": "test_password", "client_id": "test_client_id", "tenant_id": "test_tenant_id", "client_secret": "test_client_secret", @@ -808,7 +804,7 @@ class TestM365Provider: client_id=str(uuid4()), client_secret=str(uuid4()), user="user@contoso.onmicrosoft.com", - encrypted_password="test_password", + password="test_password", provider_id=provider_id, ) diff --git a/tests/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility_test.py b/tests/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility_test.py index 81209d67a4..7391bd102e 100644 --- a/tests/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility_test.py +++ b/tests/providers/m365/services/admincenter/admincenter_groups_not_public_visibility/admincenter_groups_not_public_visibility_test.py @@ -114,3 +114,91 @@ class Test_admincenter_groups_not_public_visibility: assert result[0].resource_name == "Group1" assert result[0].resource_id == id_group1 assert result[0].location == "global" + + def test_admincenter_group_public_visibility(self): + admincenter_client = mock.MagicMock + admincenter_client.audited_tenant = "audited_tenant" + admincenter_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.admincenter.admincenter_groups_not_public_visibility.admincenter_groups_not_public_visibility.admincenter_client", + new=admincenter_client, + ), + ): + from prowler.providers.m365.services.admincenter.admincenter_groups_not_public_visibility.admincenter_groups_not_public_visibility import ( + admincenter_groups_not_public_visibility, + ) + from prowler.providers.m365.services.admincenter.admincenter_service import ( + Group, + ) + + id_group1 = str(uuid4()) + + admincenter_client.groups = { + id_group1: Group(id=id_group1, name="Group1", visibility="Public"), + } + + check = admincenter_groups_not_public_visibility() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Group Group1 has Public visibility and should be Private." + ) + assert result[0].resource == admincenter_client.groups[id_group1].dict() + assert result[0].resource_name == "Group1" + assert result[0].resource_id == id_group1 + assert result[0].location == "global" + + def test_admincenter_group_none_visibility(self): + admincenter_client = mock.MagicMock + admincenter_client.audited_tenant = "audited_tenant" + admincenter_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch( + "prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell.connect_exchange_online" + ), + mock.patch( + "prowler.providers.m365.services.admincenter.admincenter_groups_not_public_visibility.admincenter_groups_not_public_visibility.admincenter_client", + new=admincenter_client, + ), + ): + from prowler.providers.m365.services.admincenter.admincenter_groups_not_public_visibility.admincenter_groups_not_public_visibility import ( + admincenter_groups_not_public_visibility, + ) + from prowler.providers.m365.services.admincenter.admincenter_service import ( + Group, + ) + + id_group1 = str(uuid4()) + + admincenter_client.groups = { + id_group1: Group(id=id_group1, name="Group1", visibility=None), + } + + check = admincenter_groups_not_public_visibility() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "Group Group1 has None visibility and should be Private." + ) + assert result[0].resource == admincenter_client.groups[id_group1].dict() + assert result[0].resource_name == "Group1" + assert result[0].resource_id == id_group1 + assert result[0].location == "global" diff --git a/tests/providers/m365/services/admincenter/admincenter_users_admins_reduced_license_footprint/admincenter_users_admins_reduced_license_footprint_test.py b/tests/providers/m365/services/admincenter/admincenter_users_admins_reduced_license_footprint/admincenter_users_admins_reduced_license_footprint_test.py index 6b93429b3d..a3667af57a 100644 --- a/tests/providers/m365/services/admincenter/admincenter_users_admins_reduced_license_footprint/admincenter_users_admins_reduced_license_footprint_test.py +++ b/tests/providers/m365/services/admincenter/admincenter_users_admins_reduced_license_footprint/admincenter_users_admins_reduced_license_footprint_test.py @@ -210,7 +210,7 @@ class Test_admincenter_users_admins_reduced_license_footprint: check = admincenter_users_admins_reduced_license_footprint() result = check.execute() assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "PASS" assert ( result[0].status_extended == "User User1 has administrative roles Global Administrator and does not have a license." diff --git a/tests/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled_test.py b/tests/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled_test.py index b251199af6..4336820faf 100644 --- a/tests/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled_test.py +++ b/tests/providers/m365/services/entra/entra_identity_protection_user_risk_enabled/entra_identity_protection_user_risk_enabled_test.py @@ -45,7 +45,7 @@ class Test_entra_identity_protection_user_risk_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "No Conditional Access Policy is an user risk based Identity Protection Policy." + == "No Conditional Access Policy is a user risk based Identity Protection Policy." ) assert result[0].resource == {} assert result[0].resource_name == "Conditional Access Policies" @@ -119,7 +119,7 @@ class Test_entra_identity_protection_user_risk_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == "No Conditional Access Policy is an user risk based Identity Protection Policy." + == "No Conditional Access Policy is a user risk based Identity Protection Policy." ) assert result[0].resource == {} assert result[0].resource_name == "Conditional Access Policies" @@ -198,7 +198,7 @@ class Test_entra_identity_protection_user_risk_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Conditional Access Policy '{display_name}' is an user risk based Identity Protection Policy but does not protect against high risk potential account compromises." + == f"Conditional Access Policy '{display_name}' is a user risk based Identity Protection Policy but does not protect against high risk potential account compromises." ) assert ( result[0].resource @@ -280,7 +280,7 @@ class Test_entra_identity_protection_user_risk_enabled: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"Conditional Access Policy '{display_name}' is an user risk based Identity Protection Policy and reports high risk potential account compromises, but does not protect against them." + == f"Conditional Access Policy '{display_name}' is a user risk based Identity Protection Policy and reports high risk potential account compromises, but does not protect against them." ) assert ( result[0].resource @@ -362,7 +362,7 @@ class Test_entra_identity_protection_user_risk_enabled: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"Conditional Access Policy '{display_name}' is an user risk based Identity Protection Policy and does protect against high risk potential account compromises." + == f"Conditional Access Policy '{display_name}' is a user risk based Identity Protection Policy and does protect against high risk potential account compromises." ) assert ( result[0].resource diff --git a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py index 46e92aa1f1..defe476e4b 100644 --- a/tests/providers/m365/services/entra/microsoft365_entra_service_test.py +++ b/tests/providers/m365/services/entra/microsoft365_entra_service_test.py @@ -68,7 +68,10 @@ async def mock_entra_get_conditional_access_policies(_): ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.BLOCK], + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ConditionalAccessGrantControl.COMPLIANT_DEVICE, + ], operator=GrantControlOperator.OR, authentication_strength=AuthenticationStrength.PHISHING_RESISTANT_MFA, ), @@ -211,7 +214,10 @@ class Test_Entra_Service: ), ), grant_controls=GrantControls( - built_in_controls=[ConditionalAccessGrantControl.BLOCK], + built_in_controls=[ + ConditionalAccessGrantControl.BLOCK, + ConditionalAccessGrantControl.COMPLIANT_DEVICE, + ], operator=GrantControlOperator.OR, authentication_strength=AuthenticationStrength.PHISHING_RESISTANT_MFA, ), diff --git a/tests/providers/nhn/lib/mutelist/nhn_mutelist_test.py b/tests/providers/nhn/lib/mutelist/nhn_mutelist_test.py index dcdc17b2d9..48444c622b 100644 --- a/tests/providers/nhn/lib/mutelist/nhn_mutelist_test.py +++ b/tests/providers/nhn/lib/mutelist/nhn_mutelist_test.py @@ -34,7 +34,7 @@ class TestNHNMutelist: mutelist = NHNMutelist(mutelist_content=mutelist_fixture) - assert not mutelist.validate_mutelist() + assert len(mutelist.validate_mutelist(mutelist_fixture)) == 0 assert mutelist.mutelist == {} assert mutelist.mutelist_file_path is None diff --git a/tests/providers/nhn/services/compute/compute_instance_login_user/compute_instance_login_user_test_for_nhn.py b/tests/providers/nhn/services/compute/compute_instance_login_user/compute_instance_login_user_test_for_nhn.py index 5e545d87c3..635347a077 100644 --- a/tests/providers/nhn/services/compute/compute_instance_login_user/compute_instance_login_user_test_for_nhn.py +++ b/tests/providers/nhn/services/compute/compute_instance_login_user/compute_instance_login_user_test_for_nhn.py @@ -67,7 +67,7 @@ class Test_compute_instance_login_user: assert len(result) == 1 assert result[0].status == "PASS" - assert "has a appropriate login user" in result[0].status_extended + assert "has an appropriate login user" in result[0].status_extended assert result[0].resource_name == instance_name assert result[0].resource_id == instance_id diff --git a/ui/.eslintrc.cjs b/ui/.eslintrc.cjs index 8c32ae0804..01d6afe1ac 100644 --- a/ui/.eslintrc.cjs +++ b/ui/.eslintrc.cjs @@ -22,7 +22,8 @@ module.exports = { }, }, rules: { - "no-console": 1, + // console.error are allowed but no console.log + "no-console": ["error", { allow: ["error"] }], eqeqeq: 2, quotes: ["error", "double", "avoid-escape"], "@typescript-eslint/no-explicit-any": "off", diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 39a1fbca22..8fc322b22d 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,28 +4,74 @@ All notable changes to the **Prowler UI** are documented in this file. ## [v1.8.0] (Prowler v5.8.0) – Not released +### 🐞 Fixes + +- Fix sync between filter buttons and URL when filters change. [(#7928)](https://github.com/prowler-cloud/prowler/pull/7928) +- Improve heatmap perfomance. [(#7934)](https://github.com/prowler-cloud/prowler/pull/7934) +- SelectScanProvider warning fixed with empty alias. [(#7998)](https://github.com/prowler-cloud/prowler/pull/7998) ### 🚀 Added - New profile page with details about the user and their roles. [(#7780)](https://github.com/prowler-cloud/prowler/pull/7780) +- Improved `SnippetChip` component and show resource name in new findings table. [(#7813)](https://github.com/prowler-cloud/prowler/pull/7813) +- Possibility to edit the organization name. [(#7829)](https://github.com/prowler-cloud/prowler/pull/7829) +- Add GCP credential method (Account Service Key). [(#7872)](https://github.com/prowler-cloud/prowler/pull/7872) +- Add compliance detail view: ENS [(#7853)](https://github.com/prowler-cloud/prowler/pull/7853) +- Add compliance detail view: ISO [(#7897)](https://github.com/prowler-cloud/prowler/pull/7897) +- Add compliance detail view: CIS [(#7913)](https://github.com/prowler-cloud/prowler/pull/7913) +- Add compliance detail view: AWS Well-Architected Framework [(#7925)](https://github.com/prowler-cloud/prowler/pull/7925) +- Add compliance detail view: KISA [(#7965)](https://github.com/prowler-cloud/prowler/pull/7965) +- Add compliance detail view: ProwlerThreatScore [(#7966)](https://github.com/prowler-cloud/prowler/pull/7966) +- Add compliance detail view: Generic (rest of the compliances) [(#7990)](https://github.com/prowler-cloud/prowler/pull/7990) +- Add compliance detail view: MITRE ATTACK [(#8002)](https://github.com/prowler-cloud/prowler/pull/8002) +- Improve `Scan ID` filter by adding more context and enhancing the UI/UX. [(#7979)](https://github.com/prowler-cloud/prowler/pull/7979) + +### 🔄 Changed + +- Add `Provider UID` filter to scans page. [(#7820)](https://github.com/prowler-cloud/prowler/pull/7820) +- Aligned Next.js version to `v14.2.29` across Prowler and Cloud environments for consistency and improved maintainability. [(#7962)](https://github.com/prowler-cloud/prowler/pull/7962) +- Refactor credentials forms with reusable components and error handling. [(#7988)](https://github.com/prowler-cloud/prowler/pull/7988) + +--- + +## [v1.7.3] (Prowler v5.7.3) + +### 🐞 Fixes + +- Fix encrypted password typo in `formSchemas`. [(#7828)](https://github.com/prowler-cloud/prowler/pull/7828) + +--- + +## [v1.7.2] (Prowler v5.7.2) + +### 🐞 Fixes + +- Download report behaviour updated to show feedback based on API response. [(#7758)](https://github.com/prowler-cloud/prowler/pull/7758) +- Missing KISA and ProwlerThreat icons added to the compliance page. [(#7860)(https://github.com/prowler-cloud/prowler/pull/7860)] +- Retrieve more than 10 scans in /compliance page. [(#7865)](https://github.com/prowler-cloud/prowler/pull/7865) +- Improve CustomDropdownFilter component. [(#7868)(https://github.com/prowler-cloud/prowler/pull/7868)] + +--- + +## [v1.7.1] (Prowler v5.7.1) ### 🐞 Fixes - Added validation to AWS IAM role. [(#7787)](https://github.com/prowler-cloud/prowler/pull/7787) +- Tweak some wording for consistency throughout the app. [(#7794)](https://github.com/prowler-cloud/prowler/pull/7794) - Retrieve more than 10 providers in /scans, /manage-groups and /findings pages. [(#7793)](https://github.com/prowler-cloud/prowler/pull/7793) --- ## [v1.7.0] (Prowler v5.7.0) - ### 🚀 Added - Add a new chart to show the split between passed and failed findings. [(#7680)](https://github.com/prowler-cloud/prowler/pull/7680) - Added `Accordion` component. [(#7700)](https://github.com/prowler-cloud/prowler/pull/7700) - Improve `Provider UID` filter by adding more context and enhancing the UI/UX. [(#7741)](https://github.com/prowler-cloud/prowler/pull/7741) - Added an AWS CloudFormation Quick Link to the IAM Role credentials step [(#7735)](https://github.com/prowler-cloud/prowler/pull/7735) -– Use `getLatestFindings` on findings page when no scan or date filters are applied. [(#7756)](https://github.com/prowler-cloud/prowler/pull/7756) + – Use `getLatestFindings` on findings page when no scan or date filters are applied. [(#7756)](https://github.com/prowler-cloud/prowler/pull/7756) ### 🐞 Fixes @@ -98,7 +144,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Revalidate the page when a role is deleted. [(#6976)](https://github.com/prowler-cloud/prowler/pull/6976) - Allows removing group visibility when creating a role. [(#7088)](https://github.com/prowler-cloud/prowler/pull/7088) - Displays correct error messages when deleting a user. [(#7089)](https://github.com/prowler-cloud/prowler/pull/7089) -- Updated label: *"Select a scan job"* → *"Select a cloud provider"*. [(#7107)](https://github.com/prowler-cloud/prowler/pull/7107) +- Updated label: _"Select a scan job"_ → _"Select a cloud provider"_. [(#7107)](https://github.com/prowler-cloud/prowler/pull/7107) - Display uid if alias is missing when creating a group. [(#7137)](https://github.com/prowler-cloud/prowler/pull/7137) --- diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index 21c65765e0..cdc947fd8c 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -30,7 +30,6 @@ export const getCompliancesOverview = async ({ }); const data = await compliances.json(); const parsedData = parseStringify(data); - revalidatePath("/compliance"); return parsedData; } catch (error) { @@ -79,3 +78,77 @@ export const getComplianceOverviewMetadataInfo = async ({ return undefined; } }; + +export const getComplianceAttributes = async (complianceId: string) => { + const headers = await getAuthHeaders({ contentType: false }); + + try { + const url = new URL(`${apiBaseUrl}/compliance-overviews/attributes`); + url.searchParams.append("filter[compliance_id]", complianceId); + + const response = await fetch(url.toString(), { + headers, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch compliance attributes: ${response.statusText}`, + ); + } + + const data = await response.json(); + + const parsedData = parseStringify(data); + return parsedData; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error fetching compliance attributes:", error); + return undefined; + } + // */ +}; + +export const getComplianceRequirements = async ({ + complianceId, + scanId, + region, +}: { + complianceId: string; + scanId: string; + region?: string | string[]; +}) => { + const headers = await getAuthHeaders({ contentType: false }); + + try { + const url = new URL(`${apiBaseUrl}/compliance-overviews/requirements`); + url.searchParams.append("filter[compliance_id]", complianceId); + url.searchParams.append("filter[scan_id]", scanId); + + if (region) { + const regionValue = Array.isArray(region) ? region.join(",") : region; + url.searchParams.append("filter[region__in]", regionValue); + //remove page param + } + url.searchParams.delete("page"); + + const response = await fetch(url.toString(), { + headers, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch compliance requirements: ${response.statusText}`, + ); + } + + const data = await response.json(); + const parsedData = parseStringify(data); + + return parsedData; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error fetching compliance requirements:", error); + return undefined; + } + // */ +}; diff --git a/ui/actions/providers/providers.ts b/ui/actions/providers/providers.ts index 2a36f7ca05..922041d487 100644 --- a/ui/actions/providers/providers.ts +++ b/ui/actions/providers/providers.ts @@ -7,9 +7,18 @@ import { apiBaseUrl, getAuthHeaders, getErrorMessage, + getFormValue, parseStringify, wait, } from "@/lib"; +import { + buildSecretConfig, + buildUpdateSecretConfig, + handleApiError, + handleApiResponse, +} from "@/lib/provider-credentials/build-crendentials"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; +import { ProvidersApiResponse, ProviderType } from "@/types/providers"; export const getProviders = async ({ page = 1, @@ -17,7 +26,7 @@ export const getProviders = async ({ sort = "", filters = {}, pageSize = 10, -}) => { +}): Promise => { const headers = await getAuthHeaders({ contentType: false }); if (isNaN(Number(page)) || page < 1) redirect("/providers"); @@ -73,10 +82,8 @@ export const getProvider = async (formData: FormData) => { export const updateProvider = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); - - const providerId = formData.get("providerId"); - const providerAlias = formData.get("alias"); - + const providerId = formData.get(ProviderCredentialFields.PROVIDER_ID); + const providerAlias = formData.get(ProviderCredentialFields.PROVIDER_ALIAS); const url = new URL(`${apiBaseUrl}/providers/${providerId}`); try { @@ -87,29 +94,21 @@ export const updateProvider = async (formData: FormData) => { data: { type: "providers", id: providerId, - attributes: { - alias: providerAlias, - }, + attributes: { alias: providerAlias }, }, }), }); - const data = await response.json(); - revalidatePath("/providers"); - return parseStringify(data); + return handleApiResponse(response, "/providers"); } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - return { - error: getErrorMessage(error), - }; + return handleApiError(error); } }; export const addProvider = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); - const providerType = formData.get("providerType") as string; + const providerType = formData.get("providerType") as ProviderType; const providerUid = formData.get("providerUid") as string; const providerAlias = formData.get("providerAlias") as string; @@ -149,102 +148,37 @@ export const addCredentialsProvider = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: true }); const url = new URL(`${apiBaseUrl}/providers/secrets`); - const secretName = formData.get("secretName"); - const providerId = formData.get("providerId"); - const providerType = formData.get("providerType"); - - const isRole = formData.get("role_arn") !== null; - - let secret = {}; - let secretType = "static"; // Default to static credentials - - if (providerType === "aws") { - if (isRole) { - // Role-based configuration for AWS - secretType = "role"; - secret = { - role_arn: formData.get("role_arn"), - external_id: formData.get("external_id"), - aws_access_key_id: formData.get("aws_access_key_id") || undefined, - aws_secret_access_key: - formData.get("aws_secret_access_key") || undefined, - aws_session_token: formData.get("aws_session_token") || undefined, - session_duration: - parseInt(formData.get("session_duration") as string, 10) || 3600, - role_session_name: formData.get("role_session_name") || undefined, - }; - } else { - // Static credentials configuration for AWS - secret = { - aws_access_key_id: formData.get("aws_access_key_id"), - aws_secret_access_key: formData.get("aws_secret_access_key"), - aws_session_token: formData.get("aws_session_token") || undefined, - }; - } - } else if (providerType === "azure") { - // Static credentials configuration for Azure - secret = { - client_id: formData.get("client_id"), - client_secret: formData.get("client_secret"), - tenant_id: formData.get("tenant_id"), - }; - } else if (providerType === "m365") { - // Static credentials configuration for M365 - secret = { - client_id: formData.get("client_id"), - client_secret: formData.get("client_secret"), - tenant_id: formData.get("tenant_id"), - user: formData.get("user"), - encrypted_password: formData.get("encrypted_password"), - }; - } else if (providerType === "gcp") { - // Static credentials configuration for GCP - secret = { - client_id: formData.get("client_id"), - client_secret: formData.get("client_secret"), - refresh_token: formData.get("refresh_token"), - }; - } else if (providerType === "kubernetes") { - // Static credentials configuration for Kubernetes - secret = { - kubeconfig_content: formData.get("kubeconfig_content"), - }; - } - - const bodyData = { - data: { - type: "provider-secrets", - attributes: { - secret_type: secretType, - secret, - name: secretName, - }, - relationships: { - provider: { - data: { - id: providerId, - type: "providers", - }, - }, - }, - }, - }; + const providerId = getFormValue( + formData, + ProviderCredentialFields.PROVIDER_ID, + ); + const providerType = getFormValue( + formData, + ProviderCredentialFields.PROVIDER_TYPE, + ) as ProviderType; try { + const { secretType, secret } = buildSecretConfig(formData, providerType); + const response = await fetch(url.toString(), { method: "POST", headers, - body: JSON.stringify(bodyData), + body: JSON.stringify({ + data: { + type: "provider-secrets", + attributes: { secret_type: secretType, secret }, + relationships: { + provider: { + data: { id: providerId, type: "providers" }, + }, + }, + }, + }), }); - const data = await response.json(); - revalidatePath("/providers"); - return parseStringify(data); + + return handleApiResponse(response, "/providers"); } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - return { - error: getErrorMessage(error), - }; + return handleApiError(error); } }; @@ -254,120 +188,48 @@ export const updateCredentialsProvider = async ( ) => { const headers = await getAuthHeaders({ contentType: true }); const url = new URL(`${apiBaseUrl}/providers/secrets/${credentialsId}`); - - const secretName = formData.get("secretName"); - const providerType = formData.get("providerType"); - - const isRole = formData.get("role_arn") !== null; - - let secret = {}; - - if (providerType === "aws") { - if (isRole) { - // Role-based configuration for AWS - secret = { - role_arn: formData.get("role_arn"), - aws_access_key_id: formData.get("aws_access_key_id") || undefined, - aws_secret_access_key: - formData.get("aws_secret_access_key") || undefined, - aws_session_token: formData.get("aws_session_token") || undefined, - session_duration: - parseInt(formData.get("session_duration") as string, 10) || 3600, - external_id: formData.get("external_id") || undefined, - role_session_name: formData.get("role_session_name") || undefined, - }; - } else { - // Static credentials configuration for AWS - secret = { - aws_access_key_id: formData.get("aws_access_key_id"), - aws_secret_access_key: formData.get("aws_secret_access_key"), - aws_session_token: formData.get("aws_session_token") || undefined, - }; - } - } else if (providerType === "azure") { - // Static credentials configuration for Azure - secret = { - client_id: formData.get("client_id"), - client_secret: formData.get("client_secret"), - tenant_id: formData.get("tenant_id"), - }; - } else if (providerType === "m365") { - // Static credentials configuration for M365 - secret = { - client_id: formData.get("client_id"), - client_secret: formData.get("client_secret"), - tenant_id: formData.get("tenant_id"), - user: formData.get("user"), - encrypted_password: formData.get("encrypted_password"), - }; - } else if (providerType === "gcp") { - // Static credentials configuration for GCP - secret = { - client_id: formData.get("client_id"), - client_secret: formData.get("client_secret"), - refresh_token: formData.get("refresh_token"), - }; - } else if (providerType === "kubernetes") { - // Static credentials configuration for Kubernetes - secret = { - kubeconfig_content: formData.get("kubeconfig_content"), - }; - } - - const bodyData = { - data: { - type: "provider-secrets", - id: credentialsId, - attributes: { - name: secretName, - secret, - }, - }, - }; + const providerType = getFormValue( + formData, + ProviderCredentialFields.PROVIDER_TYPE, + ) as ProviderType; try { + const secret = buildUpdateSecretConfig(formData, providerType); + const response = await fetch(url.toString(), { method: "PATCH", headers, - body: JSON.stringify(bodyData), + body: JSON.stringify({ + data: { + type: "provider-secrets", + id: credentialsId, + attributes: { secret }, + }, + }), }); if (!response.ok) { - throw new Error(`Failed to update credentials: ${response.statusText}`); + const data = await response.json(); + return parseStringify(data); // Return API errors for UI handling } - const data = await response.json(); - revalidatePath("/providers"); - return parseStringify(data); + return handleApiResponse(response, "/providers"); } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - return { - error: getErrorMessage(error), - }; + return handleApiError(error); } }; export const checkConnectionProvider = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: false }); - - const providerId = formData.get("providerId"); - + const providerId = formData.get(ProviderCredentialFields.PROVIDER_ID); const url = new URL(`${apiBaseUrl}/providers/${providerId}/connection`); try { - const response = await fetch(url.toString(), { - method: "POST", - headers, - }); - const data = await response.json(); + const response = await fetch(url.toString(), { method: "POST", headers }); await wait(2000); - revalidatePath("/providers"); - return parseStringify(data); + return handleApiResponse(response, "/providers"); } catch (error) { - return { - error: getErrorMessage(error), - }; + return handleApiError(error); } }; @@ -413,7 +275,7 @@ export const deleteCredentials = async (secretId: string) => { export const deleteProvider = async (formData: FormData) => { const headers = await getAuthHeaders({ contentType: false }); - const providerId = formData.get("id"); + const providerId = formData.get(ProviderCredentialFields.PROVIDER_ID); if (!providerId) { return { error: "Provider ID is required" }; diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 39e17a4b42..b3115ea0e9 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -236,10 +236,23 @@ export const getExportsZip = async (scanId: string) => { headers, }); + if (response.status === 202) { + const json = await response.json(); + const taskId = json?.data?.id; + const state = json?.data?.attributes?.state; + return { + pending: true, + state, + taskId, + }; + } + if (!response.ok) { const errorData = await response.json(); + throw new Error( - errorData?.errors?.[0]?.detail || "Failed to fetch report", + errorData?.errors?.detail || + "Unable to fetch scan report. Contact support if the issue continues.", ); } @@ -271,20 +284,28 @@ export const getComplianceCsv = async ( ); try { - const response = await fetch(url.toString(), { - headers, - }); + const response = await fetch(url.toString(), { headers }); + + if (response.status === 202) { + const json = await response.json(); + const taskId = json?.data?.id; + const state = json?.data?.attributes?.state; + return { + pending: true, + state, + taskId, + }; + } if (!response.ok) { const errorData = await response.json(); throw new Error( - errorData?.errors?.[0]?.detail || "Failed to fetch compliance report", + errorData?.errors?.detail || + "Unable to retrieve compliance report. Contact support if the issue continues.", ); } - // Get the blob data as an array buffer const arrayBuffer = await response.arrayBuffer(); - // Convert to base64 const base64 = Buffer.from(arrayBuffer).toString("base64"); return { diff --git a/ui/actions/users/tenants.ts b/ui/actions/users/tenants.ts index 0901345852..9063fc9e1b 100644 --- a/ui/actions/users/tenants.ts +++ b/ui/actions/users/tenants.ts @@ -1,4 +1,7 @@ +"use server"; + import { revalidatePath } from "next/cache"; +import { z } from "zod"; import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib/helper"; @@ -26,3 +29,67 @@ export const getAllTenants = async () => { return undefined; } }; + +const editTenantFormSchema = z + .object({ + tenantId: z.string(), + name: z.string().trim().min(1, { message: "Name is required" }), + currentName: z.string(), + }) + .refine((data) => data.name !== data.currentName, { + message: "Name must be different from the current name", + path: ["name"], + }); + +export async function updateTenantName(prevState: any, formData: FormData) { + const headers = await getAuthHeaders({ contentType: true }); + const formDataObject = Object.fromEntries(formData); + const validatedData = editTenantFormSchema.safeParse(formDataObject); + + if (!validatedData.success) { + const formFieldErrors = validatedData.error.flatten().fieldErrors; + + return { + errors: { + name: formFieldErrors?.name?.[0], + }, + }; + } + + const { tenantId, name } = validatedData.data; + + const payload = { + data: { + type: "tenants", + id: tenantId, + attributes: { + name: name.trim(), + }, + }, + }; + + try { + const url = new URL(`${apiBaseUrl}/tenants/${tenantId}`); + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Failed to update tenant name: ${response.statusText}`); + } + + await response.json(); + revalidatePath("/profile"); + return { success: "Tenant name updated successfully!" }; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Error updating tenant name:", error); + return { + errors: { + general: "Error updating tenant name. Please try again.", + }, + }; + } +} diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx new file mode 100644 index 0000000000..71e7706285 --- /dev/null +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -0,0 +1,277 @@ +import { Spacer } from "@nextui-org/react"; +import Image from "next/image"; +import React, { Suspense } from "react"; + +import { + getComplianceAttributes, + getComplianceOverviewMetadataInfo, + getComplianceRequirements, +} from "@/actions/compliances"; +import { getProvider } from "@/actions/providers"; +import { getScans } from "@/actions/scans"; +import { + BarChart, + BarChartSkeleton, + ClientAccordionWrapper, + ComplianceHeader, + HeatmapChart, + HeatmapChartSkeleton, + PieChart, + PieChartSkeleton, + SkeletonAccordion, +} from "@/components/compliance"; +import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance"; +import { ContentLayout } from "@/components/ui"; +import { getComplianceMapper } from "@/lib/compliance/commons"; +import { ScanProps } from "@/types"; +import { Framework, RequirementsTotals } from "@/types/compliance"; + +interface ComplianceDetailSearchParams { + complianceId: string; + version?: string; + scanId?: string; + "filter[region__in]"?: string; + "filter[cis_profile_level]"?: string; +} + +const ComplianceIconSmall = ({ + logoPath, + title, +}: { + logoPath: string; + title: string; +}) => { + return ( +
+ {`${title} +
+ ); +}; + +const ChartsWrapper = ({ + children, +}: { + children: React.ReactNode; + logoPath?: string; +}) => { + return ( +
+ {children && + React.Children.toArray(children).map( + (child: React.ReactNode, index: number) => ( +
+ {child} +
+ ), + )} +
+ ); +}; + +export default async function ComplianceDetail({ + params, + searchParams, +}: { + params: { compliancetitle: string }; + searchParams: ComplianceDetailSearchParams; +}) { + const { compliancetitle } = params; + const { complianceId, version, scanId } = searchParams; + const regionFilter = searchParams["filter[region__in]"]; + const cisProfileFilter = searchParams["filter[cis_profile_level]"]; + const logoPath = getComplianceIcon(compliancetitle); + + // Create a key that includes region filter for Suspense + const searchParamsKey = JSON.stringify(searchParams || {}); + + const formattedTitle = compliancetitle.split("-").join(" "); + const pageTitle = version + ? `Compliance Details: ${formattedTitle} - ${version}` + : `Compliance Details: ${formattedTitle}`; + + // Fetch scans data + const scansData = await getScans({ + filters: { + "filter[state]": "completed", + }, + }); + + // Expand scans with provider information + const expandedScansData = scansData?.data?.length + ? await Promise.all( + scansData.data.map(async (scan: ScanProps) => { + const providerId = scan.relationships?.provider?.data?.id; + + if (!providerId) { + return { ...scan, providerInfo: null }; + } + + const formData = new FormData(); + formData.append("id", providerId); + + const providerData = await getProvider(formData); + + return { + ...scan, + providerInfo: providerData?.data + ? { + provider: providerData.data.attributes.provider, + uid: providerData.data.attributes.uid, + alias: providerData.data.attributes.alias, + } + : null, + }; + }), + ) + : []; + + const selectedScanId = scanId || expandedScansData[0]?.id || null; + + // Fetch metadata info for regions + const metadataInfoData = await getComplianceOverviewMetadataInfo({ + filters: { + "filter[scan_id]": selectedScanId, + }, + }); + + const uniqueRegions = metadataInfoData?.data?.attributes?.regions || []; + + return ( + + ) : ( + "fluent-mdl2:compliance-audit" + ) + } + > + + + + + + + + + + + } + > + + + + ); +} + +const SSRComplianceContent = async ({ + complianceId, + scanId, + region, + filter, + logoPath, +}: { + complianceId: string; + scanId: string; + region?: string; + filter?: string; + logoPath?: string; +}) => { + if (!scanId) { + return ( +
+ + + + + + +
+ ); + } + + // Get compliance data and attributes once + const [attributesData, requirementsData] = await Promise.all([ + getComplianceAttributes(complianceId), + getComplianceRequirements({ + complianceId, + scanId, + region, + }), + ]); + + // Determine framework from the first attribute item + const framework = attributesData?.data?.[0]?.attributes?.framework; + const mapper = getComplianceMapper(framework); + + // Use the same data for both compliance view and heatmap + const data = mapper.mapComplianceData( + attributesData, + requirementsData, + filter, + ); + + // Calculate category heatmap data + const categoryHeatmapData = mapper.calculateCategoryHeatmapData(data); + + const totalRequirements: RequirementsTotals = data.reduce( + (acc: RequirementsTotals, framework: Framework) => ({ + pass: acc.pass + framework.pass, + fail: acc.fail + framework.fail, + manual: acc.manual + framework.manual, + }), + { pass: 0, fail: 0, manual: 0 }, + ); + + const accordionItems = mapper.toAccordionItems(data, scanId); + const topFailedSections = mapper.getTopFailedSections(data); + + // Todo: rethink as every compliance has a different number of items + // const defaultKeys = accordionItems.slice(0, 2).map((item) => item.key); + const defaultKeys = [""]; + + return ( +
+ + + + + + + + +
+ ); +}; diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index e3dab9e762..061af051d7 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -1,6 +1,4 @@ export const dynamic = "force-dynamic"; - -import { Spacer } from "@nextui-org/react"; import { Suspense } from "react"; import { getCompliancesOverview } from "@/actions/compliances"; @@ -12,11 +10,10 @@ import { ComplianceSkeletonGrid, NoScansAvailable, } from "@/components/compliance"; -import { DataCompliance } from "@/components/compliance/data-compliance"; -import { FilterControls } from "@/components/filters"; +import { ComplianceHeader } from "@/components/compliance/compliance-header/compliance-header"; import { ContentLayout } from "@/components/ui"; -import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom"; -import { ComplianceOverviewData, ScanProps, SearchParamsProps } from "@/types"; +import { ScanProps, SearchParamsProps } from "@/types"; +import { ComplianceOverviewData } from "@/types/compliance"; export default async function Compliance({ searchParams, @@ -33,6 +30,7 @@ export default async function Compliance({ filters: { "filter[state]": "completed", }, + pageSize: 50, }); if (!scansData?.data) { @@ -83,21 +81,10 @@ export default async function Compliance({ {selectedScanId ? ( <> - - - - - - }> @@ -132,7 +119,11 @@ const SSRComplianceGrid = async ({ }); // Check if the response contains no data - if (!compliancesData || compliancesData?.data?.length === 0) { + if ( + !compliancesData || + !compliancesData.data || + compliancesData.data.length === 0 + ) { return (
@@ -154,25 +145,22 @@ const SSRComplianceGrid = async ({ return (
{compliancesData.data.map((compliance: ComplianceOverviewData) => { - const { attributes } = compliance; - const { - framework, - version, - requirements_status: { passed, total }, - compliance_id, - } = attributes; + const { attributes, id } = compliance; + const { framework, version, requirements_passed, total_requirements } = + attributes; return ( ); })} diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index f137b451cc..e8290a4167 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -19,12 +19,17 @@ import { ContentLayout } from "@/components/ui"; import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; import { createDict, + createScanDetailsMapping, extractFiltersAndQuery, extractSortAndKey, hasDateOrScanFilter, } from "@/lib"; -import { ProviderAccountProps, ProviderProps } from "@/types"; -import { FindingProps, ScanProps, SearchParamsProps } from "@/types/components"; +import { + createProviderDetailsMapping, + extractProviderUIDs, +} from "@/lib/provider-helpers"; +import { ScanProps } from "@/types"; +import { FindingProps, SearchParamsProps } from "@/types/components"; export default async function Findings({ searchParams, @@ -44,7 +49,7 @@ export default async function Findings({ filters, }), getProviders({ pageSize: 50 }), - getScans({}), + getScans({ pageSize: 50 }), ]); // Extract unique regions and services from the new endpoint @@ -52,84 +57,74 @@ export default async function Findings({ const uniqueServices = metadataInfoData?.data?.attributes?.services || []; const uniqueResourceTypes = metadataInfoData?.data?.attributes?.resource_types || []; - // Get findings data - // Extract provider UIDs - const providerUIDs: string[] = Array.from( - new Set( - providersData?.data - ?.map((provider: ProviderProps) => provider.attributes?.uid) - .filter(Boolean), - ), - ); - - const providerDetails: Array<{ [uid: string]: ProviderAccountProps }> = - providerUIDs.map((uid) => { - const provider = providersData.data.find( - (p: { attributes: { uid: string } }) => p.attributes?.uid === uid, - ); + // Extract provider UIDs and details using helper functions + const providerUIDs = providersData ? extractProviderUIDs(providersData) : []; + const providerDetails = providersData + ? createProviderDetailsMapping(providerUIDs, providersData) + : []; + // Update the Provider UID filter + const updatedFilters = filterFindings.map((filter) => { + if (filter.key === "provider_uid__in") { return { - [uid]: { - provider: provider?.attributes?.provider || "", - uid: uid, - alias: provider?.attributes?.alias ?? null, - }, + ...filter, + values: providerUIDs, + valueLabelMapping: providerDetails, }; - }); + } + return filter; + }); // Extract scan UUIDs with "completed" state and more than one resource - const completedScans = scansData?.data - ?.filter( - (scan: ScanProps) => - scan.attributes.state === "completed" && - scan.attributes.unique_resource_count > 1, - ) - .map((scan: ScanProps) => ({ - id: scan.id, - name: scan.attributes.name, - })); + const completedScans = scansData?.data?.filter( + (scan: ScanProps) => + scan.attributes.state === "completed" && + scan.attributes.unique_resource_count > 1, + ); const completedScanIds = completedScans?.map((scan: ScanProps) => scan.id) || []; + const scanDetails = createScanDetailsMapping(completedScans, providersData); + return ( + }> diff --git a/ui/app/(prowler)/manage-groups/page.tsx b/ui/app/(prowler)/manage-groups/page.tsx index c5e5be9932..8af34c74b8 100644 --- a/ui/app/(prowler)/manage-groups/page.tsx +++ b/ui/app/(prowler)/manage-groups/page.tsx @@ -115,7 +115,7 @@ const SSRDataEditGroup = async ({ const associatedProviders = relationships.providers?.data.map( (provider: ProviderProps) => { const matchingProvider = providersList.find( - (p: ProviderProps) => p.id === provider.id, + (p: { id: string; name: string }) => p.id === provider.id, ); return { id: provider.id, diff --git a/ui/app/(prowler)/profile/page.tsx b/ui/app/(prowler)/profile/page.tsx index b78d9e8439..ff26a9db15 100644 --- a/ui/app/(prowler)/profile/page.tsx +++ b/ui/app/(prowler)/profile/page.tsx @@ -8,16 +8,15 @@ import { UserBasicInfoCard } from "@/components/users/profile"; import { MembershipsCard } from "@/components/users/profile/memberships-card"; import { RolesCard } from "@/components/users/profile/roles-card"; import { SkeletonUserInfo } from "@/components/users/profile/skeleton-user-info"; -import { RoleDetail, TenantDetailData } from "@/types/users/users"; +import { isUserOwnerAndHasManageAccount } from "@/lib/permissions"; +import { RoleDetail, TenantDetailData } from "@/types/users"; export default async function Profile() { return ( -
- }> - - -
+ }> + +
); } @@ -61,14 +60,27 @@ const SSRDataUser = async () => { ), ); + const isOwner = isUserOwnerAndHasManageAccount( + roleDetails, + memberships?.data || [], + userProfile.data.id, + ); + return ( -
+
- - +
+
+ +
+
+ +
+
); }; diff --git a/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx b/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx index fce06d1a88..b867e919d0 100644 --- a/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx +++ b/ui/app/(prowler)/providers/(set-up-provider)/add-credentials/page.tsx @@ -1,13 +1,18 @@ import React from "react"; import { - ViaCredentialsForm, - ViaRoleForm, + AddViaCredentialsForm, + AddViaRoleForm, } from "@/components/providers/workflow/forms"; -import { SelectViaAWS } from "@/components/providers/workflow/forms/select-via-aws/select-via-aws"; +import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws"; +import { + AddViaServiceAccountForm, + SelectViaGCP, +} from "@/components/providers/workflow/forms/select-credentials-type/gcp"; +import { ProviderType } from "@/types/providers"; interface Props { - searchParams: { type: string; id: string; via?: string }; + searchParams: { type: ProviderType; id: string; via?: string }; } export default function AddCredentialsPage({ searchParams }: Props) { @@ -17,14 +22,24 @@ export default function AddCredentialsPage({ searchParams }: Props) { )} + {searchParams.type === "gcp" && !searchParams.via && ( + + )} + {((searchParams.type === "aws" && searchParams.via === "credentials") || - searchParams.type !== "aws") && ( - + (searchParams.type === "gcp" && searchParams.via === "credentials") || + (searchParams.type !== "aws" && searchParams.type !== "gcp")) && ( + )} {searchParams.type === "aws" && searchParams.via === "role" && ( - + )} + + {searchParams.type === "gcp" && + searchParams.via === "service-account" && ( + + )} ); } diff --git a/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx b/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx index 3a680c3be8..185a107244 100644 --- a/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx +++ b/ui/app/(prowler)/providers/(set-up-provider)/update-credentials/page.tsx @@ -1,53 +1,47 @@ import React from "react"; -import { InfoIcon } from "@/components/icons"; +import { CredentialsUpdateInfo } from "@/components/providers"; import { UpdateViaCredentialsForm, UpdateViaRoleForm, } from "@/components/providers/workflow/forms"; -import { SelectViaAWS } from "@/components/providers/workflow/forms/select-via-aws/select-via-aws"; +import { UpdateViaServiceAccountForm } from "@/components/providers/workflow/forms/update-via-service-account-key-form"; +import { ProviderType } from "@/types/providers"; interface Props { - searchParams: { type: string; id: string; via?: string }; + searchParams: { + type: ProviderType; + id: string; + via?: string; + secretId?: string; + }; } export default function UpdateCredentialsPage({ searchParams }: Props) { return ( <> - {searchParams.type === "aws" && !searchParams.via && ( - <> -
-

- 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, updates must use a role. -

-
-

- To switch from static credentials to a role (or vice versa), the - provider must be deleted and set up again. -

- -
- - )} + {(searchParams.type === "aws" || searchParams.type === "gcp") && + !searchParams.via && ( + + )} {((searchParams.type === "aws" && searchParams.via === "credentials") || - searchParams.type !== "aws") && ( + (searchParams.type === "gcp" && searchParams.via === "credentials") || + (searchParams.type !== "aws" && searchParams.type !== "gcp")) && ( )} {searchParams.type === "aws" && searchParams.via === "role" && ( )} + + {searchParams.type === "gcp" && + searchParams.via === "service-account" && ( + + )} ); } diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index 0679139770..bcf97dd47a 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -14,6 +14,10 @@ import { SkeletonTableScans } from "@/components/scans/table"; import { ColumnGetScans } from "@/components/scans/table/scans"; import { ContentLayout } from "@/components/ui"; import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; +import { + createProviderDetailsMapping, + extractProviderUIDs, +} from "@/lib/provider-helpers"; import { ProviderProps, ScanProps, SearchParamsProps } from "@/types"; export default async function Scans({ @@ -61,6 +65,24 @@ export default async function Scans({ scan.attributes.state === "available", ); + // Extract provider UIDs and create provider details mapping for filtering + const providerUIDs = providersData ? extractProviderUIDs(providersData) : []; + const providerDetails = providersData + ? createProviderDetailsMapping(providerUIDs, providersData) + : []; + + // Update the Provider UID filter + const updatedFilters = filterScans.map((filter) => { + if (filter.key === "provider_uid__in") { + return { + ...filter, + values: providerUIDs, + valueLabelMapping: providerDetails, + }; + } + return filter; + }); + return ( <> {thereIsNoProviders && ( @@ -89,7 +111,7 @@ export default async function Scans({
- +
diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx new file mode 100644 index 0000000000..23bedb0459 --- /dev/null +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; + +import { getFindings } from "@/actions/findings/findings"; +import { + ColumnFindings, + SkeletonTableFindings, +} from "@/components/findings/table"; +import { Accordion } from "@/components/ui/accordion/Accordion"; +import { DataTable } from "@/components/ui/table"; +import { createDict } from "@/lib"; +import { getComplianceMapper } from "@/lib/compliance/commons"; +import { Requirement } from "@/types/compliance"; +import { FindingProps, FindingsResponse } from "@/types/components"; + +interface ClientAccordionContentProps { + requirement: Requirement; + scanId: string; + framework: string; + disableFindings?: boolean; +} + +export const ClientAccordionContent = ({ + requirement, + framework, + scanId, + disableFindings = false, +}: ClientAccordionContentProps) => { + const [findings, setFindings] = useState(null); + const [expandedFindings, setExpandedFindings] = useState([]); + const searchParams = useSearchParams(); + const pageNumber = searchParams.get("page") || "1"; + const complianceId = searchParams.get("complianceId"); + const defaultSort = "severity,status,-inserted_at"; + const sort = searchParams.get("sort") || defaultSort; + const loadedPageRef = useRef(null); + const loadedSortRef = useRef(null); + const isExpandedRef = useRef(false); + const region = searchParams.get("filter[region__in]") || ""; + + useEffect(() => { + async function loadFindings() { + if ( + !disableFindings && + requirement.check_ids?.length > 0 && + requirement.status !== "No findings" && + (loadedPageRef.current !== pageNumber || + loadedSortRef.current !== sort || + !isExpandedRef.current) + ) { + loadedPageRef.current = pageNumber; + loadedSortRef.current = sort; + isExpandedRef.current = true; + + try { + const checkIds = requirement.check_ids; + const encodedSort = sort.replace(/^\+/, ""); + const findingsData = await getFindings({ + filters: { + "filter[check_id__in]": checkIds.join(","), + "filter[scan]": scanId, + ...(region && { "filter[region__in]": region }), + }, + page: parseInt(pageNumber, 10), + sort: encodedSort, + }); + + setFindings(findingsData); + + if (findingsData?.data) { + // Create dictionaries for resources, scans, and providers + const resourceDict = createDict("resources", findingsData); + const scanDict = createDict("scans", findingsData); + const providerDict = createDict("providers", findingsData); + + // Expand each finding with its corresponding resource, scan, and provider + const expandedData = findingsData.data.map( + (finding: FindingProps) => { + const scan = scanDict[finding.relationships?.scan?.data?.id]; + const resource = + resourceDict[finding.relationships?.resources?.data?.[0]?.id]; + const provider = + providerDict[scan?.relationships?.provider?.data?.id]; + + return { + ...finding, + relationships: { scan, resource, provider }, + }; + }, + ); + setExpandedFindings(expandedData); + } + } catch (error) { + console.error("Error loading findings:", error); + } + } + } + + loadFindings(); + }, [requirement, scanId, pageNumber, sort, region, disableFindings]); + + const renderDetails = () => { + if (!complianceId) { + return null; + } + + const mapper = getComplianceMapper(framework); + const detailsComponent = mapper.getDetailsComponent(requirement); + + return
{detailsComponent}
; + }; + + if (disableFindings) { + return ( +
+ {renderDetails()} +

+ ⚠️ This requirement has no checks; therefore, there are no findings. +

+
+ ); + } + + const checks = requirement.check_ids || []; + const checksList = ( +
+
+
+ + {checks.join(", ")} + +
+
+ ); + + const accordionChecksItems = [ + { + key: "checks", + title: ( +
+ {checks.length} + {checks.length > 1 ? Checks : Check} +
+ ), + content: checksList, + }, + ]; + + const renderFindingsTable = () => { + if (findings === null && requirement.status !== "MANUAL") { + return ; + } + + if (findings?.data?.length && findings.data.length > 0) { + return ( + <> +

Findings

+ + index !== 4 && index !== 7, + )} + data={expandedFindings || []} + metadata={findings?.meta} + disableScroll={true} + /> + + ); + } + + return ( +
+ ⚠️ There are no findings for these regions +
+ ); + }; + + return ( +
+ {renderDetails()} + + {checks.length > 0 && ( +
+ +
+ )} + + {renderFindingsTable()} +
+ ); +}; diff --git a/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx b/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx new file mode 100644 index 0000000000..6288cf9171 --- /dev/null +++ b/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useState } from "react"; + +import { Accordion, AccordionItemProps } from "@/components/ui"; +import { CustomButton } from "@/components/ui/custom"; + +export const ClientAccordionWrapper = ({ + items, + defaultExpandedKeys, + hideExpandButton = false, +}: { + items: AccordionItemProps[]; + defaultExpandedKeys: string[]; + hideExpandButton?: boolean; +}) => { + const [selectedKeys, setSelectedKeys] = + useState(defaultExpandedKeys); + const [isExpanded, setIsExpanded] = useState(false); + + // Function to get all keys except the last level (requirements) + const getAllKeysExceptLastLevel = (items: AccordionItemProps[]): string[] => { + const keys: string[] = []; + + const traverse = (items: AccordionItemProps[], level: number = 0) => { + items.forEach((item) => { + // Add current item key if it's not the last level + if (item.items && item.items.length > 0) { + keys.push(item.key); + // Check if the children have their own children (not the last level) + const hasGrandChildren = item.items.some( + (child) => child.items && child.items.length > 0, + ); + if (hasGrandChildren) { + traverse(item.items, level + 1); + } + } + }); + }; + + traverse(items); + return keys; + }; + + const handleToggleExpand = () => { + if (isExpanded) { + setSelectedKeys(defaultExpandedKeys); + } else { + const allKeys = getAllKeysExceptLastLevel(items); + setSelectedKeys(allKeys); + } + setIsExpanded(!isExpanded); + }; + + const handleSelectionChange = (keys: string[]) => { + setSelectedKeys(keys); + }; + + return ( +
+ {!hideExpandButton && ( +
+ + {isExpanded ? "Collapse all" : "Expand all"} + +
+ )} + +
+ ); +}; diff --git a/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx b/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx new file mode 100644 index 0000000000..96db332359 --- /dev/null +++ b/ui/components/compliance/compliance-accordion/compliance-accordion-requeriment-title.tsx @@ -0,0 +1,21 @@ +import { FindingStatus, StatusFindingBadge } from "@/components/ui/table"; + +interface ComplianceAccordionRequirementTitleProps { + type: string; + name: string; + status: FindingStatus; +} + +export const ComplianceAccordionRequirementTitle = ({ + name, + status, +}: ComplianceAccordionRequirementTitleProps) => { + return ( +
+
+ {name} +
+ +
+ ); +}; diff --git a/ui/components/compliance/compliance-accordion/compliance-accordion-title.tsx b/ui/components/compliance/compliance-accordion/compliance-accordion-title.tsx new file mode 100644 index 0000000000..c9fa69aa8d --- /dev/null +++ b/ui/components/compliance/compliance-accordion/compliance-accordion-title.tsx @@ -0,0 +1,137 @@ +import { Tooltip } from "@nextui-org/react"; + +interface ComplianceAccordionTitleProps { + label: string; + pass: number; + fail: number; + manual?: number; + isParentLevel?: boolean; +} + +export const ComplianceAccordionTitle = ({ + label, + pass, + fail, + manual = 0, + isParentLevel = false, +}: ComplianceAccordionTitleProps) => { + const total = pass + fail + manual; + const passPercentage = (pass / total) * 100; + const failPercentage = (fail / total) * 100; + const manualPercentage = (manual / total) * 100; + + return ( +
+
+ + {label.charAt(0).toUpperCase() + label.slice(1)} + +
+
+
+ {total > 0 && isParentLevel && ( + + Requirements: + + )} +
+ +
+ {total > 0 ? ( +
+ {pass > 0 && ( + +
Pass
+
+ {pass} ({passPercentage.toFixed(1)}%) +
+
+ } + size="sm" + placement="top" + delay={0} + closeDelay={0} + > +
0 ? "2px" : "0", + }} + /> + + )} + {fail > 0 && ( + +
Fail
+
+ {fail} ({failPercentage.toFixed(1)}%) +
+
+ } + size="sm" + placement="top" + delay={0} + closeDelay={0} + > +
0 ? "2px" : "0", + }} + /> + + )} + {manual > 0 && ( + +
Manual
+
+ {manual} ({manualPercentage.toFixed(1)}%) +
+
+ } + size="sm" + placement="top" + delay={0} + closeDelay={0} + > +
+ + )} +
+ ) : ( +
+ )} +
+ + +
Total requirements
+
{total}
+
+ } + size="sm" + placement="top" + > +
+ {total > 0 ? total : "—"} +
+ +
+
+ ); +}; diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx index 56b6cec402..14e4f9782f 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -2,8 +2,8 @@ import { Card, CardBody, Progress } from "@nextui-org/react"; import Image from "next/image"; -import { useSearchParams } from "next/navigation"; -import React from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import React, { useState } from "react"; import { DownloadIconButton, toast } from "@/components/ui"; import { downloadComplianceCsv } from "@/lib/helper"; @@ -19,6 +19,7 @@ interface ComplianceCardProps { prevTotalRequirements: number; scanId: string; complianceId: string; + id: string; } export const ComplianceCard: React.FC = ({ @@ -28,9 +29,12 @@ export const ComplianceCard: React.FC = ({ totalRequirements, scanId, complianceId, + id, }) => { const searchParams = useSearchParams(); + const router = useRouter(); const hasRegionFilter = searchParams.has("filter[region__in]"); + const [isDownloading, setIsDownloading] = useState(false); const formatTitle = (title: string) => { return title.split("-").join(" "); @@ -67,8 +71,34 @@ export const ComplianceCard: React.FC = ({ return "success"; }; + const navigateToDetail = () => { + const formattedTitleForUrl = encodeURIComponent(title); + const path = `/compliance/${formattedTitleForUrl}`; + const params = new URLSearchParams(); + + params.set("complianceId", id); + params.set("version", version); + params.set("scanId", scanId); + + router.push(`${path}?${params.toString()}`); + }; + const handleDownload = async () => { + setIsDownloading(true); + try { + await downloadComplianceCsv(scanId, complianceId, toast); + } finally { + setIsDownloading(false); + } + }; + return ( - +
= ({ - downloadComplianceCsv(scanId, complianceId, toast) - } + onDownload={handleDownload} textTooltip="Download compliance CSV report" isDisabled={hasRegionFilter} + isDownloading={isDownloading} /> {/* {getScanChange()} */}
diff --git a/ui/components/compliance/compliance-charts/bar-chart.tsx b/ui/components/compliance/compliance-charts/bar-chart.tsx new file mode 100644 index 0000000000..3afd02ee5d --- /dev/null +++ b/ui/components/compliance/compliance-charts/bar-chart.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { + Bar, + BarChart as RechartsBarChart, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { translateType } from "@/lib/compliance/ens"; +import { FailedSection } from "@/types/compliance"; + +const CustomYAxisTick = (props: any) => { + const { x, y, payload, theme } = props; + const text = payload.value; + const maxLength = 50; + + const truncatedText = + text.length > maxLength ? `${text.slice(0, maxLength)}...` : text; + + return ( + + + {truncatedText} + + + ); +}; + +interface FailedSectionsListProps { + sections: FailedSection[]; +} + +const title = ( +

+ Failed Sections (Top 5) +

+); + +export const BarChart = ({ sections }: FailedSectionsListProps) => { + const { theme } = useTheme(); + + const getTypeColor = (type: string) => { + switch (type.toLowerCase()) { + case "requisito": + return "#ff5356"; + case "recomendacion": + return "#FDC53A"; // Increased contrast from #FDDD8A + case "refuerzo": + return "#7FB5FF"; // Increased contrast from #B5D7FF + default: + return "#ff5356"; + } + }; + + const chartData = [...sections] + .sort((a, b) => b.total - a.total) + .slice(0, 5) + .map((section) => ({ + name: section.name.charAt(0).toUpperCase() + section.name.slice(1), + ...section.types, + })); + + const allTypes = Array.from( + new Set(sections.flatMap((section) => Object.keys(section.types || {}))), + ); + + // Add empty bars to complete up to 5 bars for better distribution + while (chartData.length < 5) { + const emptyBar: any = { name: "" }; + allTypes.forEach((type) => { + emptyBar[type] = 0; + }); + chartData.push(emptyBar); + } + + // Calculate the maximum value to ensure proper scaling + const maxValue = Math.max( + ...chartData.map((item) => + allTypes.reduce((sum, type) => sum + ((item as any)[type] || 0), 0), + ), + ); + + // Set minimum domain to ensure bars are always visible + const domainMax = Math.max(maxValue, 1); + + // Check if there are no failed sections + if (!sections || sections.length === 0) { + return ( +
+ {title} +
+

There are no failed sections

+
+
+ ); + } + + return ( +
+ {title} + +
+ + + + } + axisLine={false} + tickLine={false} + /> + { + if (!props.active || !props.payload || !props.payload.length) { + return null; + } + + const data = props.payload[0].payload; + if (!data.name || data.name === "") { + return null; + } + + const hasValues = allTypes.some((type) => data[type] > 0); + if (!hasValues) { + return null; + } + + return ( +
+ {props.payload.map((entry: any, index: number) => ( +
+

{data.name}

+

+ + {translateType(entry.dataKey)}: {entry.value} + +

+
+ ))} +
+ ); + }} + cursor={false} + /> + {allTypes.map((type, i) => ( + + ))} + translateType(value)} + wrapperStyle={{ + fontSize: "10px", + display: "flex", + justifyContent: "center", + width: "100%", + paddingTop: "16px", + marginBottom: "16px", + marginLeft: "56px", + }} + iconType="circle" + layout="horizontal" + verticalAlign="bottom" + /> +
+
+
+
+ ); +}; diff --git a/ui/components/compliance/compliance-charts/heatmap-chart.tsx b/ui/components/compliance/compliance-charts/heatmap-chart.tsx new file mode 100644 index 0000000000..3a78faf346 --- /dev/null +++ b/ui/components/compliance/compliance-charts/heatmap-chart.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { cn } from "@nextui-org/react"; +import { useTheme } from "next-themes"; +import { useState } from "react"; + +import { CategoryData } from "@/types/compliance"; + +interface HeatmapChartProps { + categories?: CategoryData[]; +} + +const getHeatmapColor = (percentage: number): string => { + if (percentage === 0) return "#10b981"; // Green for 0% failures + if (percentage <= 25) return "#eab308"; // Yellow + if (percentage <= 50) return "#f97316"; // Orange + if (percentage <= 100) return "#ef4444"; // Red + return "#ef4444"; +}; + +const capitalizeFirstLetter = (text: string): string => { + const lowerText = text.toLowerCase(); + const firstLetterIndex = lowerText.search(/[a-zA-Z]/); + if (firstLetterIndex === -1) return text; // No letters found + + return ( + lowerText.slice(0, firstLetterIndex) + + lowerText.charAt(firstLetterIndex).toUpperCase() + + lowerText.slice(firstLetterIndex + 1) + ); +}; + +const title = ( +

+ Sections Failure Rate +

+); + +export const HeatmapChart = ({ categories = [] }: HeatmapChartProps) => { + const { theme } = useTheme(); + const [hoveredItem, setHoveredItem] = useState(null); + const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); + + // Use categories data and prepare it + const heatmapData = categories + .filter((item) => item.totalRequirements > 0) + .sort((a, b) => b.failurePercentage - a.failurePercentage) + .slice(0, 9); // Exactly 9 items for 3x3 grid + + // Check if there are no items with data + if (!categories.length || heatmapData.length === 0) { + return ( +
+ {title} +
+

No category data available

+
+
+ ); + } + + const handleMouseEnter = (item: CategoryData, event: React.MouseEvent) => { + setHoveredItem(item); + setMousePosition({ x: event.clientX, y: event.clientY }); + }; + + const handleMouseMove = (event: React.MouseEvent) => { + setMousePosition({ x: event.clientX, y: event.clientY }); + }; + + const handleMouseLeave = () => { + setHoveredItem(null); + }; + + return ( +
+ {title} + +
+
+ {heatmapData.map((item) => ( +
handleMouseEnter(item, e)} + onMouseMove={handleMouseMove} + onMouseLeave={handleMouseLeave} + > +
+
+ {capitalizeFirstLetter(item.name)} +
+
+ {item.failurePercentage}% +
+
+
+ ))} +
+ + {/* Custom Tooltip */} + {hoveredItem && ( +
+
+ {capitalizeFirstLetter(hoveredItem.name)} +
+
Failure Rate: {hoveredItem.failurePercentage}%
+
+ Failed: {hoveredItem.failedRequirements}/ + {hoveredItem.totalRequirements} +
+
+ )} +
+
+ ); +}; diff --git a/ui/components/compliance/compliance-charts/pie-chart.tsx b/ui/components/compliance/compliance-charts/pie-chart.tsx new file mode 100644 index 0000000000..3e5cc01fb8 --- /dev/null +++ b/ui/components/compliance/compliance-charts/pie-chart.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useTheme } from "next-themes"; +import { + Cell, + Label, + Pie, + PieChart as RechartsPieChart, + Tooltip, +} from "recharts"; + +import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart"; + +interface PieChartProps { + pass: number; + fail: number; + manual: number; +} + +const chartConfig = { + number: { + label: "Requirements", + }, + pass: { + label: "Pass", + color: "hsl(var(--chart-success))", + }, + fail: { + label: "Fail", + color: "hsl(var(--chart-fail))", + }, + manual: { + label: "Manual", + color: "hsl(var(--chart-warning))", + }, +} satisfies ChartConfig; + +export const PieChart = ({ pass, fail, manual }: PieChartProps) => { + const { theme } = useTheme(); + + const chartData = [ + { + name: "Pass", + value: pass, + fill: "#3CEC6D", + }, + { + name: "Fail", + value: fail, + fill: "#FB718F", + }, + { + name: "Manual", + value: manual, + fill: "#868994", + }, + ]; + + const totalRequirements = pass + fail + manual; + + const emptyChartData = [ + { + name: "Empty", + value: 1, + fill: "#64748b", + }, + ]; + + interface CustomTooltipProps { + active: boolean; + payload: { + payload: { + name: string; + value: number; + fill: string; + }; + }[]; + } + + const CustomTooltip = ({ active, payload }: CustomTooltipProps) => { + if (active && payload && payload.length) { + const data = payload[0]; + return ( +
+
+
+ + {data.payload.name}: {data.payload.value} + +
+
+ ); + } + return null; + }; + + return ( +
+

+ Requirements Status +

+ + + + } + /> + 0 ? chartData : emptyChartData} + dataKey="value" + nameKey="name" + innerRadius={70} + outerRadius={100} + paddingAngle={2} + cornerRadius={4} + > + {(totalRequirements > 0 ? chartData : emptyChartData).map( + (entry, index) => ( + + ), + )} + + + + +
+
+
Pass
+
{pass}
+
+
+
Fail
+
{fail}
+
+
+
Manual
+
{manual}
+
+
+
+ ); +}; diff --git a/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx b/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx new file mode 100644 index 0000000000..9317c61485 --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/aws-well-architected-details.tsx @@ -0,0 +1,87 @@ +import { SeverityBadge } from "@/components/ui/table"; +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBadge, + ComplianceBadgeContainer, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, + ComplianceLink, +} from "./shared-components"; + +export const AWSWellArchitectedCustomDetails = ({ + requirement, +}: { + requirement: Requirement; +}) => { + return ( + + {requirement.description && ( + + {requirement.description} + + )} + + {requirement.well_architected_name && ( + + + {requirement.well_architected_name as string} + + + )} + + + {requirement.level_of_risk && ( +
+ + Risk Level: + + +
+ )} + + {requirement.well_architected_question_id && ( + + )} + + {requirement.well_architected_practice_id && ( + + )} + + {requirement.assessment_method && ( + + )} +
+ + {requirement.implementation_guidance_url && ( + + + {requirement.implementation_guidance_url as string} + + + )} +
+ ); +}; diff --git a/ui/components/compliance/compliance-custom-details/cis-details.tsx b/ui/components/compliance/compliance-custom-details/cis-details.tsx new file mode 100644 index 0000000000..f804f8e7d0 --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/cis-details.tsx @@ -0,0 +1,133 @@ +import ReactMarkdown from "react-markdown"; + +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBadge, + ComplianceBadgeContainer, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, + ComplianceLink, +} from "./shared-components"; + +interface CISDetailsProps { + requirement: Requirement; +} + +export const CISCustomDetails = ({ requirement }: CISDetailsProps) => { + const processReferences = ( + references: string | number | string[] | object[] | undefined, + ): string[] => { + if (typeof references !== "string") return []; + + // Use regex to extract all URLs that start with https:// + const urlRegex = /https:\/\/[^:]+/g; + const urls = references.match(urlRegex); + + return urls || []; + }; + + return ( + + {requirement.description && ( + + {requirement.description} + + )} + + + {requirement.profile && ( + + )} + + {requirement.assessment_status && ( + + )} + + + {requirement.subsection && ( + + + {requirement.subsection as string} + + + )} + + {requirement.rationale_statement && ( + + + {requirement.rationale_statement as string} + + + )} + + {requirement.impact_statement && ( + + + {requirement.impact_statement as string} + + + )} + + {requirement.remediation_procedure && + typeof requirement.remediation_procedure === "string" && ( + + {/* Prettier -> "plugins": ["prettier-plugin-tailwindcss"] is not ready yet to "prose": */} + {/* eslint-disable-next-line */} +
+ {requirement.remediation_procedure} +
+
+ )} + + {requirement.audit_procedure && + typeof requirement.audit_procedure === "string" && ( + + {/* eslint-disable-next-line */} +
+ {requirement.audit_procedure} +
+
+ )} + + {requirement.additional_information && ( + + + {requirement.additional_information as string} + + + )} + + {requirement.default_value && ( + + + {requirement.default_value as string} + + + )} + + {requirement.references && ( + +
+ {processReferences(requirement.references).map( + (url: string, index: number) => ( +
+ {url} +
+ ), + )} +
+
+ )} +
+ ); +}; diff --git a/ui/components/compliance/compliance-custom-details/ens-details.tsx b/ui/components/compliance/compliance-custom-details/ens-details.tsx new file mode 100644 index 0000000000..9e947cea14 --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/ens-details.tsx @@ -0,0 +1,50 @@ +import { translateType } from "@/lib/compliance/ens"; +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBadge, + ComplianceBadgeContainer, + ComplianceChipContainer, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, +} from "./shared-components"; + +export const ENSCustomDetails = ({ + requirement, +}: { + requirement: Requirement; +}) => { + return ( + + {requirement.description && ( + + {requirement.description} + + )} + + + {requirement.type && ( + + )} + + {requirement.nivel && ( + + )} + + + + + ); +}; diff --git a/ui/components/compliance/compliance-custom-details/generic-details.tsx b/ui/components/compliance/compliance-custom-details/generic-details.tsx new file mode 100644 index 0000000000..caa3fec05d --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/generic-details.tsx @@ -0,0 +1,67 @@ +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBadge, + ComplianceBadgeContainer, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, +} from "./shared-components"; + +export const GenericCustomDetails = ({ + requirement, +}: { + requirement: Requirement; +}) => { + return ( + + {requirement.description && ( + + {requirement.description} + + )} + + + {requirement.item_id && ( + + )} + + {requirement.service && ( + + )} + + {requirement.type && ( + + )} + + + {requirement.subsection && ( + + + {requirement.subsection as string} + + + )} + + {requirement.subgroup && ( + + + {requirement.subgroup as string} + + + )} + + ); +}; diff --git a/ui/components/compliance/compliance-custom-details/iso-details.tsx b/ui/components/compliance/compliance-custom-details/iso-details.tsx new file mode 100644 index 0000000000..425a6aa49f --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/iso-details.tsx @@ -0,0 +1,31 @@ +import { Requirement } from "@/types/compliance"; + +import { + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, +} from "./shared-components"; + +export const ISOCustomDetails = ({ + requirement, +}: { + requirement: Requirement; +}) => { + return ( + + {requirement.description && ( + + {requirement.description} + + )} + + {requirement.objetive_name && ( + + + {requirement.objetive_name as string} + + + )} + + ); +}; diff --git a/ui/components/compliance/compliance-custom-details/kisa-details.tsx b/ui/components/compliance/compliance-custom-details/kisa-details.tsx new file mode 100644 index 0000000000..c29a39b22d --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/kisa-details.tsx @@ -0,0 +1,53 @@ +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBulletList, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, +} from "./shared-components"; + +export const KISACustomDetails = ({ + requirement, +}: { + requirement: Requirement; +}) => { + const auditChecklist = requirement.audit_checklist as string[] | undefined; + const relatedRegulations = requirement.related_regulations as + | string[] + | undefined; + const auditEvidence = requirement.audit_evidence as string[] | undefined; + const nonComplianceCases = requirement.non_compliance_cases as + | string[] + | undefined; + + return ( + + {requirement.description && ( + + {requirement.description} + + )} + + + + + + + + + + ); +}; diff --git a/ui/components/compliance/compliance-custom-details/mitre-details.tsx b/ui/components/compliance/compliance-custom-details/mitre-details.tsx new file mode 100644 index 0000000000..71eca0d370 --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/mitre-details.tsx @@ -0,0 +1,111 @@ +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBadge, + ComplianceBadgeContainer, + ComplianceChipContainer, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, + ComplianceLink, +} from "./shared-components"; + +export const MITRECustomDetails = ({ + requirement, +}: { + requirement: Requirement; +}) => { + const cloudServices = requirement.cloud_services as + | Array<{ + service: string; + category: string; + value: string; + comment: string; + }> + | undefined; + + return ( + + {requirement.description && ( + + {requirement.description} + + )} + + + {requirement.technique_id && ( + + )} + + + + + + + {requirement.subtechniques && + Array.isArray(requirement.subtechniques) && + requirement.subtechniques.length > 0 && ( + + )} + + {requirement.technique_url && ( + + + {requirement.technique_url as string} + + + )} + + {cloudServices && cloudServices.length > 0 && ( + +
+ {cloudServices.map((service, index) => ( +
+
+ + + +
+ {service.comment && ( +
+
+ Details +
+ + {service.comment} + +
+ )} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/ui/components/compliance/compliance-custom-details/shared-components.tsx b/ui/components/compliance/compliance-custom-details/shared-components.tsx new file mode 100644 index 0000000000..2d1344b19b --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/shared-components.tsx @@ -0,0 +1,166 @@ +import Link from "next/link"; + +import { cn } from "@/lib/utils"; + +export const ComplianceLink = ({ + href, + children, +}: { + href: string; + children: React.ReactNode; +}) => { + return ( + + {children} + + ); +}; + +export const ComplianceDetailContainer = ({ + children, +}: { + children: React.ReactNode; +}) => { + return
{children}
; +}; + +export const ComplianceDetailSection = ({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) => { + return ( +
+

+ {title} +

+ {children} +
+ ); +}; + +export const ComplianceDetailText = ({ + children, + className = "", +}: { + children: React.ReactNode; + className?: string; +}) => { + return

{children}

; +}; + +export const ComplianceBadgeContainer = ({ + children, +}: { + children: React.ReactNode; +}) => { + return
{children}
; +}; + +type BadgeColor = + | "red" // Risk/Level/Severity + | "blue" // Assessment/Method + | "orange" // Type/Category + | "green" // Weight/Score (positive) + | "purple" // Profile + | "indigo" // IDs/References + | "gray"; // Additional Info/Neutral + +export const ComplianceBadge = ({ + label, + value, + color, + conditional = false, +}: { + label: string; + value: string | number; + color: BadgeColor; + conditional?: boolean; +}) => { + const actualColor = conditional && Number(value) === 0 ? "gray" : color; + + const colorClasses = { + red: "bg-red-50 text-red-700 ring-red-600/10 dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/20", + blue: "bg-blue-50 text-blue-700 ring-blue-600/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/20", + orange: + "bg-orange-50 text-orange-700 ring-orange-600/10 dark:bg-orange-400/10 dark:text-orange-400 dark:ring-orange-400/20", + green: + "bg-green-50 text-green-700 ring-green-600/10 dark:bg-green-400/10 dark:text-green-400 dark:ring-green-400/20", + purple: + "bg-purple-50 text-purple-700 ring-purple-600/10 dark:bg-purple-400/10 dark:text-purple-400 dark:ring-purple-400/20", + indigo: + "bg-indigo-50 text-indigo-700 ring-indigo-600/10 dark:bg-indigo-400/10 dark:text-indigo-400 dark:ring-indigo-400/20", + gray: "bg-gray-50 text-gray-600 ring-gray-500/10 dark:bg-gray-400/10 dark:text-gray-400 dark:ring-gray-400/20", + }; + + return ( +
+ + {label}: + + + {value} + +
+ ); +}; + +export const ComplianceBulletList = ({ + title, + items, +}: { + title: string; + items: string[]; +}) => { + if (!items || items.length === 0) return null; + + return ( + +
+ {items.map((item: string, index: number) => ( +
+ + {item} +
+ ))} +
+
+ ); +}; + +export const ComplianceChipContainer = ({ + title, + items, +}: { + title: string; + items: string[]; +}) => { + if (!items || items.length === 0) return null; + + return ( + +
+ {items.map((item: string, index: number) => ( + + {item} + + ))} +
+
+ ); +}; diff --git a/ui/components/compliance/compliance-custom-details/threat-details.tsx b/ui/components/compliance/compliance-custom-details/threat-details.tsx new file mode 100644 index 0000000000..1cf127bf84 --- /dev/null +++ b/ui/components/compliance/compliance-custom-details/threat-details.tsx @@ -0,0 +1,68 @@ +import { Requirement } from "@/types/compliance"; + +import { + ComplianceBadge, + ComplianceBadgeContainer, + ComplianceDetailContainer, + ComplianceDetailSection, + ComplianceDetailText, +} from "./shared-components"; + +export const ThreatCustomDetails = ({ + requirement, +}: { + requirement: Requirement; +}) => { + return ( + + {requirement.description && ( + + {requirement.description} + + )} + + {requirement.attributeDescription && ( + + + {requirement.attributeDescription as string} + + + )} + + + {typeof requirement.levelOfRisk === "number" && ( + + )} + + {typeof requirement.weight === "number" && ( + + )} + + {typeof requirement.score === "number" && ( + + )} + + + {requirement.additionalInformation && ( + + + {requirement.additionalInformation as string} + + + )} + + ); +}; diff --git a/ui/components/compliance/compliance-header/compliance-header.tsx b/ui/components/compliance/compliance-header/compliance-header.tsx new file mode 100644 index 0000000000..6427a28646 --- /dev/null +++ b/ui/components/compliance/compliance-header/compliance-header.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Spacer } from "@nextui-org/react"; + +import { FilterControls } from "@/components/filters"; +import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom"; + +import { DataCompliance } from "./data-compliance"; +import { SelectScanComplianceDataProps } from "./scan-selector"; + +interface ComplianceHeaderProps { + scans: SelectScanComplianceDataProps["scans"]; + uniqueRegions: string[]; + showSearch?: boolean; + showRegionFilter?: boolean; + framework?: string; // Framework name to show specific filters + showProviders?: boolean; +} + +export const ComplianceHeader = ({ + scans, + uniqueRegions, + showSearch = true, + showRegionFilter = true, + framework, + showProviders = true, +}: ComplianceHeaderProps) => { + const frameworkFilters = []; + + // Add CIS Profile Level filter if framework is CIS + if (framework === "CIS") { + frameworkFilters.push({ + key: "cis_profile_level", + labelCheckboxGroup: "Level", + values: ["Level 1", "Level 2"], + index: 0, // Show first + showSelectAll: false, // No "Select All" option since Level 2 includes Level 1 + defaultValues: ["Level 2"], // Default to Level 2 selected (which includes Level 1) + }); + } + + // Prepare region filters + const regionFilters = showRegionFilter + ? [ + { + key: "region__in", + labelCheckboxGroup: "Regions", + values: uniqueRegions, + index: 1, // Show after framework filters + defaultToSelectAll: true, // Default to all regions selected + }, + ] + : []; + + const allFilters = [...frameworkFilters, ...regionFilters]; + + return ( + <> + {showSearch && } + + {showProviders && } + {allFilters.length > 0 && ( + <> + {showProviders && } + + + )} + + + ); +}; diff --git a/ui/components/compliance/compliance-scan-info.tsx b/ui/components/compliance/compliance-header/compliance-scan-info.tsx similarity index 59% rename from ui/components/compliance/compliance-scan-info.tsx rename to ui/components/compliance/compliance-header/compliance-scan-info.tsx index c4bff1d7ad..acaef4caa2 100644 --- a/ui/components/compliance/compliance-scan-info.tsx +++ b/ui/components/compliance/compliance-header/compliance-scan-info.tsx @@ -1,8 +1,9 @@ -import { Divider } from "@nextui-org/react"; +import { Divider, Tooltip } from "@nextui-org/react"; import React from "react"; import { DateWithTime, EntityInfoShort } from "@/components/ui/entities"; import { ProviderType } from "@/types"; + interface ComplianceScanInfoProps { scan: { providerInfo: { @@ -21,18 +22,25 @@ export const ComplianceScanInfo: React.FC = ({ scan, }) => { return ( -
+
- -
-

- {scan.attributes.name || "- -"} -

+ +
+ +

+ {scan.attributes.name || "- -"} +

+
diff --git a/ui/components/compliance/data-compliance/data-compliance.tsx b/ui/components/compliance/compliance-header/data-compliance.tsx similarity index 80% rename from ui/components/compliance/data-compliance/data-compliance.tsx rename to ui/components/compliance/compliance-header/data-compliance.tsx index 9b57033bc1..c04cd0fed0 100644 --- a/ui/components/compliance/data-compliance/data-compliance.tsx +++ b/ui/components/compliance/compliance-header/data-compliance.tsx @@ -3,8 +3,10 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useEffect } from "react"; -import { SelectScanComplianceData } from "@/components/compliance/data-compliance"; -import { SelectScanComplianceDataProps } from "@/types"; +import { + ScanSelector, + SelectScanComplianceDataProps, +} from "@/components/compliance/compliance-header/index"; interface DataComplianceProps { scans: SelectScanComplianceDataProps["scans"]; } @@ -33,8 +35,8 @@ export const DataCompliance = ({ scans }: DataComplianceProps) => { return (
-
- + void; +} + +export const ScanSelector = ({ scans, selectedScanId, onSelectionChange, @@ -14,14 +26,17 @@ export const SelectScanComplianceData = ({ aria-label="Select a Scan" placeholder="Select a scan" classNames={{ - selectorIcon: "right-2", + trigger: "w-full min-w-[365px]", }} size="lg" labelPlacement="outside" selectedKeys={new Set([selectedScanId])} - onSelectionChange={(keys) => - onSelectionChange(Array.from(keys)[0] as string) - } + onSelectionChange={(keys) => { + const newSelectedId = Array.from(keys)[0] as string; + if (newSelectedId && newSelectedId !== selectedScanId) { + onSelectionChange(newSelectedId); + } + }} renderValue={() => { const selectedItem = scans.find((item) => item.id === selectedScanId); return selectedItem ? ( diff --git a/ui/components/compliance/data-compliance/index.ts b/ui/components/compliance/data-compliance/index.ts deleted file mode 100644 index 14f430df04..0000000000 --- a/ui/components/compliance/data-compliance/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./data-compliance"; -export * from "./select-scan-compliance-data"; diff --git a/ui/components/compliance/index.ts b/ui/components/compliance/index.ts index d0ba1eb027..096951a261 100644 --- a/ui/components/compliance/index.ts +++ b/ui/components/compliance/index.ts @@ -1,4 +1,21 @@ +export * from "./compliance-accordion/client-accordion-content"; +export * from "./compliance-accordion/client-accordion-wrapper"; +export * from "./compliance-accordion/compliance-accordion-requeriment-title"; +export * from "./compliance-accordion/compliance-accordion-title"; export * from "./compliance-card"; -export * from "./compliance-scan-info"; -export * from "./compliance-skeleton-grid"; +export * from "./compliance-charts/bar-chart"; +export * from "./compliance-charts/heatmap-chart"; +export * from "./compliance-charts/pie-chart"; +export * from "./compliance-custom-details/cis-details"; +export * from "./compliance-custom-details/ens-details"; +export * from "./compliance-custom-details/iso-details"; +export * from "./compliance-header/compliance-header"; +export * from "./compliance-header/compliance-scan-info"; +export * from "./compliance-header/data-compliance"; +export * from "./compliance-header/scan-selector"; export * from "./no-scans-available"; +export * from "./skeletons/bar-chart-skeleton"; +export * from "./skeletons/compliance-accordion-skeleton"; +export * from "./skeletons/compliance-grid-skeleton"; +export * from "./skeletons/heatmap-chart-skeleton"; +export * from "./skeletons/pie-chart-skeleton"; diff --git a/ui/components/compliance/skeletons/bar-chart-skeleton.tsx b/ui/components/compliance/skeletons/bar-chart-skeleton.tsx new file mode 100644 index 0000000000..2338818068 --- /dev/null +++ b/ui/components/compliance/skeletons/bar-chart-skeleton.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Skeleton } from "@nextui-org/react"; + +export const BarChartSkeleton = () => { + return ( +
+ {/* Title skeleton */} + +
+ + + {/* Chart area skeleton */} +
+ {/* Bar chart skeleton - 5 horizontal bars */} + {Array.from({ length: 5 }).map((_, index) => ( +
+ {/* Bar skeleton with varying widths */} + +
+ +
+ ))} + + {/* Legend skeleton */} +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+ +
+ + +
+ +
+ ))} +
+
+
+ ); +}; diff --git a/ui/components/compliance/skeletons/compliance-accordion-skeleton.tsx b/ui/components/compliance/skeletons/compliance-accordion-skeleton.tsx new file mode 100644 index 0000000000..f1077b53ee --- /dev/null +++ b/ui/components/compliance/skeletons/compliance-accordion-skeleton.tsx @@ -0,0 +1,30 @@ +import { Skeleton } from "@nextui-org/react"; +import React from "react"; + +interface SkeletonAccordionProps { + itemCount?: number; + className?: string; + isCompact?: boolean; +} + +export const SkeletonAccordion = ({ + itemCount = 3, + className = "", + isCompact = false, +}: SkeletonAccordionProps) => { + const itemHeight = isCompact ? "h-10" : "h-14"; + + return ( +
+ {[...Array(itemCount)].map((_, index) => ( + +
+
+ ))} +
+ ); +}; + +SkeletonAccordion.displayName = "SkeletonAccordion"; diff --git a/ui/components/compliance/compliance-skeleton-grid.tsx b/ui/components/compliance/skeletons/compliance-grid-skeleton.tsx similarity index 100% rename from ui/components/compliance/compliance-skeleton-grid.tsx rename to ui/components/compliance/skeletons/compliance-grid-skeleton.tsx diff --git a/ui/components/compliance/skeletons/heatmap-chart-skeleton.tsx b/ui/components/compliance/skeletons/heatmap-chart-skeleton.tsx new file mode 100644 index 0000000000..ae247900f2 --- /dev/null +++ b/ui/components/compliance/skeletons/heatmap-chart-skeleton.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { Skeleton } from "@nextui-org/react"; + +export const HeatmapChartSkeleton = () => { + return ( +
+ {/* Title skeleton */} + +
+ + + {/* Heatmap area skeleton - 3x3 grid like the real component */} +
+
+ {Array.from({ length: 9 }).map((_, index) => ( + +
+ + ))} +
+
+
+ ); +}; diff --git a/ui/components/compliance/skeletons/pie-chart-skeleton.tsx b/ui/components/compliance/skeletons/pie-chart-skeleton.tsx new file mode 100644 index 0000000000..f21c653b24 --- /dev/null +++ b/ui/components/compliance/skeletons/pie-chart-skeleton.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Skeleton } from "@nextui-org/react"; + +export const PieChartSkeleton = () => { + return ( +
+ {/* Title skeleton */} + +
+ + + {/* Pie chart skeleton */} +
+ {/* Outer circle */} + +
+ + + {/* Inner circle (donut hole) */} +
+ + {/* Center text skeleton */} +
+ +
+ + +
+ +
+
+ + {/* Bottom stats skeleton */} +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+
+ ); +}; diff --git a/ui/components/filters/data-filters.ts b/ui/components/filters/data-filters.ts index 8d712bfb70..ec964bbc26 100644 --- a/ui/components/filters/data-filters.ts +++ b/ui/components/filters/data-filters.ts @@ -30,6 +30,11 @@ export const filterScans = [ labelCheckboxGroup: "Trigger", values: ["scheduled", "manual"], }, + { + key: "provider_uid__in", + labelCheckboxGroup: "Provider UID", + values: [], + }, // Add more filter categories as needed ]; @@ -38,21 +43,31 @@ export const filterFindings = [ key: "severity__in", labelCheckboxGroup: "Severity", values: ["critical", "high", "medium", "low", "informational"], + index: 1, }, { key: "status__in", labelCheckboxGroup: "Status", values: ["PASS", "FAIL", "MANUAL"], - }, - { - key: "delta__in", - labelCheckboxGroup: "Delta", - values: ["new", "changed"], + index: 2, }, { key: "provider_type__in", labelCheckboxGroup: "Cloud Provider", values: ["aws", "azure", "m365", "gcp", "kubernetes"], + index: 4, + }, + { + key: "provider_uid__in", + labelCheckboxGroup: "Provider UID", + values: [], + index: 8, + }, + { + key: "delta__in", + labelCheckboxGroup: "Delta", + values: ["new", "changed"], + index: 3, }, // Add more filter categories as needed ]; diff --git a/ui/components/findings/table/column-findings.tsx b/ui/components/findings/table/column-findings.tsx index 8bfeac0a7f..a8636ab750 100644 --- a/ui/components/findings/table/column-findings.tsx +++ b/ui/components/findings/table/column-findings.tsx @@ -1,11 +1,16 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; +import { Database } from "lucide-react"; import { useSearchParams } from "next/navigation"; import { DataTableRowDetails } from "@/components/findings/table"; import { InfoIcon } from "@/components/icons"; -import { DateWithTime, EntityInfoShort } from "@/components/ui/entities"; +import { + DateWithTime, + EntityInfoShort, + SnippetChip, +} from "@/components/ui/entities"; import { TriggerSheet } from "@/components/ui/sheet"; import { DataTableColumnHeader, @@ -63,7 +68,7 @@ const FindingDetailsCell = ({ row }: { row: any }) => { }; return ( -
+
} title="Finding Details" @@ -105,8 +110,10 @@ export const ColumnFindings: ColumnDef[] = [ return (
- {(delta === "new" || delta === "changed") && ( + {delta === "new" || delta === "changed" ? ( + ) : ( +
)}

{checktitle} @@ -119,6 +126,21 @@ export const ColumnFindings: ColumnDef[] = [ ); }, }, + { + accessorKey: "resourceName", + header: "Resource name", + cell: ({ row }) => { + const resourceName = getResourceData(row, "name"); + + return ( + `...${value.slice(-10)}`} + icon={} + /> + ); + }, + }, { accessorKey: "severity", header: ({ column }) => ( diff --git a/ui/components/findings/table/skeleton-table-findings.tsx b/ui/components/findings/table/skeleton-table-findings.tsx index 3af6403e7c..865fd14911 100644 --- a/ui/components/findings/table/skeleton-table-findings.tsx +++ b/ui/components/findings/table/skeleton-table-findings.tsx @@ -1,65 +1,11 @@ -import { Card, Skeleton } from "@nextui-org/react"; import React from "react"; +import { SkeletonTable } from "../../ui/skeleton/skeleton"; + export const SkeletonTableFindings = () => { return ( - - {/* Table headers */} -

- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- - {/* Table body */} -
- {[...Array(3)].map((_, index) => ( -
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- ))} -
- +
+ +
); }; diff --git a/ui/components/icons/compliance/IconCompliance.tsx b/ui/components/icons/compliance/IconCompliance.tsx index 543f6c68c8..c73d0d14ee 100644 --- a/ui/components/icons/compliance/IconCompliance.tsx +++ b/ui/components/icons/compliance/IconCompliance.tsx @@ -8,9 +8,12 @@ import GDPRLogo from "./gdpr.svg"; import GxPLogo from "./gxp-aws.svg"; import HIPAALogo from "./hipaa.svg"; import ISOLogo from "./iso-27001.svg"; +import KISALogo from "./kisa.svg"; import MITRELogo from "./mitre-attack.svg"; +import NIS2Logo from "./nis2.svg"; import NISTLogo from "./nist.svg"; import PCILogo from "./pci-dss.svg"; +import PROWLERTHREATLogo from "./prowlerThreat.svg"; import RBILogo from "./rbi.svg"; import SOC2Logo from "./soc2.svg"; @@ -60,4 +63,13 @@ export const getComplianceIcon = (complianceTitle: string) => { if (complianceTitle.toLowerCase().includes("soc2")) { return SOC2Logo; } + if (complianceTitle.toLowerCase().includes("kisa")) { + return KISALogo; + } + if (complianceTitle.toLowerCase().includes("prowlerthreatscore")) { + return PROWLERTHREATLogo; + } + if (complianceTitle.toLowerCase().includes("nis2")) { + return NIS2Logo; + } }; diff --git a/ui/components/icons/compliance/kisa.svg b/ui/components/icons/compliance/kisa.svg new file mode 100644 index 0000000000..8450a6566d --- /dev/null +++ b/ui/components/icons/compliance/kisa.svg @@ -0,0 +1,439 @@ + +Created with Fabric.js 5.2.4 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/components/icons/compliance/nis2.svg b/ui/components/icons/compliance/nis2.svg new file mode 100644 index 0000000000..a8773aed8f --- /dev/null +++ b/ui/components/icons/compliance/nis2.svg @@ -0,0 +1,28966 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/components/icons/compliance/prowlerThreat.svg b/ui/components/icons/compliance/prowlerThreat.svg new file mode 100644 index 0000000000..f14d50c323 --- /dev/null +++ b/ui/components/icons/compliance/prowlerThreat.svg @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/ui/components/overview/findings-by-severity-chart/findings-by-severity-chart.tsx b/ui/components/overview/findings-by-severity-chart/findings-by-severity-chart.tsx index 3f30d2ee70..ae4d704731 100644 --- a/ui/components/overview/findings-by-severity-chart/findings-by-severity-chart.tsx +++ b/ui/components/overview/findings-by-severity-chart/findings-by-severity-chart.tsx @@ -116,9 +116,9 @@ export const FindingsBySeverityChart = ({ > diff --git a/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx b/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx index 331748adc0..7e6040ed60 100644 --- a/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx +++ b/ui/components/overview/findings-by-status-chart/findings-by-status-chart.tsx @@ -146,9 +146,9 @@ export const FindingsByStatusChart: React.FC = ({ -
+
-
+
{ return row.original; }; @@ -81,8 +85,10 @@ export const ColumnNewFindingsToDate: ColumnDef[] = [ return (
- {(delta === "new" || delta === "changed") && ( + {delta === "new" || delta === "changed" ? ( + ) : ( +
)}

{checktitle} @@ -95,6 +101,21 @@ export const ColumnNewFindingsToDate: ColumnDef[] = [ ); }, }, + { + accessorKey: "resourceName", + header: "Resource name", + cell: ({ row }) => { + const resourceName = getResourceData(row, "name"); + + return ( + `...${value.slice(-10)}`} + icon={} + /> + ); + }, + }, { accessorKey: "severity", header: "Severity", diff --git a/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx b/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx index 6a24119681..c6ac03661b 100644 --- a/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx +++ b/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx @@ -1,65 +1,11 @@ -import { Card, Skeleton } from "@nextui-org/react"; import React from "react"; +import { SkeletonTable } from "@/components/ui/skeleton/skeleton"; + export const SkeletonTableNewFindings = () => { return ( - - {/* Table headers */} -

- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- - {/* Table body */} -
- {[...Array(3)].map((_, index) => ( -
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
- -
-
-
- ))} -
- +
+ +
); }; diff --git a/ui/components/providers/credentials-update-info.tsx b/ui/components/providers/credentials-update-info.tsx new file mode 100644 index 0000000000..1b32e2391f --- /dev/null +++ b/ui/components/providers/credentials-update-info.tsx @@ -0,0 +1,51 @@ +"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"; + +interface UpdateCredentialsInfoProps { + providerType: ProviderType; + initialVia?: string; +} + +export const CredentialsUpdateInfo = ({ + providerType, + initialVia, +}: UpdateCredentialsInfoProps) => { + const renderSelectComponent = () => { + if (providerType === "aws") { + return ; + } + if (providerType === "gcp") { + return ; + } + 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()} +
+ ); +}; diff --git a/ui/components/providers/forms/delete-form.tsx b/ui/components/providers/forms/delete-form.tsx index 2f80b3294b..056a6be2c4 100644 --- a/ui/components/providers/forms/delete-form.tsx +++ b/ui/components/providers/forms/delete-form.tsx @@ -10,9 +10,10 @@ import { DeleteIcon } from "@/components/icons"; import { useToast } from "@/components/ui"; import { CustomButton } from "@/components/ui/custom"; import { Form } from "@/components/ui/form"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; const formSchema = z.object({ - providerId: z.string(), + [ProviderCredentialFields.PROVIDER_ID]: z.string(), }); export const DeleteForm = ({ @@ -53,7 +54,11 @@ export const DeleteForm = ({ return (
- +
>({ resolver: zodResolver(formSchema), defaultValues: { - providerId: providerId, - alias: providerAlias, + [ProviderCredentialFields.PROVIDER_ID]: providerId, + [ProviderCredentialFields.PROVIDER_ALIAS]: providerAlias, }, }); @@ -74,14 +75,16 @@ export const EditForm = ({
diff --git a/ui/components/providers/index.ts b/ui/components/providers/index.ts index 99dc5901b7..967b661073 100644 --- a/ui/components/providers/index.ts +++ b/ui/components/providers/index.ts @@ -1,4 +1,5 @@ export * from "./add-provider-button"; +export * from "./credentials-update-info"; export * from "./forms/delete-form"; export * from "./link-to-scans"; export * from "./provider-info"; diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index f16585e34b..8527bfd754 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -3,7 +3,7 @@ import { Chip } from "@nextui-org/react"; import { ColumnDef } from "@tanstack/react-table"; -import { DateWithTime, SnippetId } from "@/components/ui/entities"; +import { DateWithTime, SnippetChip } from "@/components/ui/entities"; import { DataTableColumnHeader } from "@/components/ui/table"; import { ProviderProps } from "@/types"; @@ -74,7 +74,7 @@ export const ColumnProviders: ColumnDef[] = [ const { attributes: { uid }, } = getProviderData(row); - return ; + return ; }, }, { diff --git a/ui/components/providers/workflow/forms/add-via-credentials-form.tsx b/ui/components/providers/workflow/forms/add-via-credentials-form.tsx new file mode 100644 index 0000000000..511b0aaf2d --- /dev/null +++ b/ui/components/providers/workflow/forms/add-via-credentials-form.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { addCredentialsProvider } from "@/actions/providers/providers"; +import { ProviderType } from "@/types"; + +import { BaseCredentialsForm } from "./base-credentials-form"; + +export const AddViaCredentialsForm = ({ + searchParams, +}: { + searchParams: { type: string; id: string }; +}) => { + const providerType = searchParams.type as ProviderType; + const providerId = searchParams.id; + + const handleAddCredentials = async (formData: FormData) => { + return await addCredentialsProvider(formData); + }; + + const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}`; + + return ( + + ); +}; diff --git a/ui/components/providers/workflow/forms/add-via-role-form.tsx b/ui/components/providers/workflow/forms/add-via-role-form.tsx new file mode 100644 index 0000000000..e7945450de --- /dev/null +++ b/ui/components/providers/workflow/forms/add-via-role-form.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { addCredentialsProvider } from "@/actions/providers/providers"; +import { ProviderType } from "@/types"; + +import { BaseCredentialsForm } from "./base-credentials-form"; + +export const AddViaRoleForm = ({ + searchParams, +}: { + searchParams: { type: string; id: string }; +}) => { + const providerType = searchParams.type as ProviderType; + const providerId = searchParams.id; + + const handleAddCredentials = async (formData: FormData) => { + return await addCredentialsProvider(formData); + }; + + const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}`; + + return ( + + ); +}; diff --git a/ui/components/providers/workflow/forms/base-credentials-form.tsx b/ui/components/providers/workflow/forms/base-credentials-form.tsx new file mode 100644 index 0000000000..39ab8ff6b3 --- /dev/null +++ b/ui/components/providers/workflow/forms/base-credentials-form.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { Divider } from "@nextui-org/react"; +import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { Control } from "react-hook-form"; + +import { CustomButton } from "@/components/ui/custom"; +import { Form } from "@/components/ui/form"; +import { useCredentialsForm } from "@/hooks/use-credentials-form"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; +import { + AWSCredentials, + AWSCredentialsRole, + AzureCredentials, + GCPDefaultCredentials, + GCPServiceAccountKey, + KubernetesCredentials, + M365Credentials, + ProviderType, +} from "@/types"; + +import { ProviderTitleDocs } from "../provider-title-docs"; +import { AWSStaticCredentialsForm } from "./select-credentials-type/aws/credentials-type"; +import { AWSRoleCredentialsForm } from "./select-credentials-type/aws/credentials-type/aws-role-credentials-form"; +import { GCPDefaultCredentialsForm } from "./select-credentials-type/gcp/credentials-type"; +import { GCPServiceAccountKeyForm } from "./select-credentials-type/gcp/credentials-type/gcp-service-account-key-form"; +import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form"; +import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form"; +import { M365CredentialsForm } from "./via-credentials/m365-credentials-form"; + +type BaseCredentialsFormProps = { + providerType: ProviderType; + providerId: string; + onSubmit: (formData: FormData) => Promise; + successNavigationUrl: string; + submitButtonText?: string; + showBackButton?: boolean; +}; + +export const BaseCredentialsForm = ({ + providerType, + providerId, + onSubmit, + successNavigationUrl, + submitButtonText = "Next", + showBackButton = true, +}: BaseCredentialsFormProps) => { + const { + form, + isLoading, + handleSubmit, + handleBackStep, + searchParamsObj, + externalId, + } = useCredentialsForm({ + providerType, + providerId, + onSubmit, + successNavigationUrl, + }); + + return ( + + + + + + + + + + {providerType === "aws" && searchParamsObj.get("via") === "role" && ( + } + setValue={form.setValue as any} + externalId={externalId} + /> + )} + {providerType === "aws" && searchParamsObj.get("via") !== "role" && ( + } + /> + )} + {providerType === "azure" && ( + } + /> + )} + {providerType === "m365" && ( + } + /> + )} + {providerType === "gcp" && + searchParamsObj.get("via") === "service-account" && ( + } + /> + )} + {providerType === "gcp" && + searchParamsObj.get("via") !== "service-account" && ( + + } + /> + )} + {providerType === "kubernetes" && ( + } + /> + )} + +
+ {showBackButton && + (searchParamsObj.get("via") === "credentials" || + searchParamsObj.get("via") === "role" || + searchParamsObj.get("via") === "service-account") && ( + } + isDisabled={isLoading} + > + Back + + )} + } + > + {isLoading ? <>Loading : {submitButtonText}} + +
+ + + ); +}; diff --git a/ui/components/providers/workflow/forms/connect-account-form.tsx b/ui/components/providers/workflow/forms/connect-account-form.tsx index a33ebd786b..fa1d48abd4 100644 --- a/ui/components/providers/workflow/forms/connect-account-form.tsx +++ b/ui/components/providers/workflow/forms/connect-account-form.tsx @@ -10,7 +10,6 @@ import * as z from "zod"; import { useToast } from "@/components/ui"; import { CustomButton, CustomInput } from "@/components/ui/custom"; import { Form } from "@/components/ui/form"; -import { ProviderType } from "@/types"; import { addProvider } from "../../../../actions/providers/providers"; import { addProviderFormSchema, ApiError } from "../../../../types"; @@ -171,7 +170,7 @@ export const ConnectAccountForm = () => { {/* Step 2: UID, alias, and credentials (if AWS) */} {prevStep === 2 && ( <> - + { const credentialsType = useWatch({ control, - name: "credentials_type" as const, + name: ProviderCredentialFields.CREDENTIALS_TYPE, defaultValue: "aws-sdk-default", }); @@ -35,7 +35,7 @@ export const AWSCredentialsRoleForm = ({ Authentication - - - - - - - {providerType === "aws" && ( - } - /> - )} - {providerType === "azure" && ( - } - /> - )} - {providerType === "m365" && ( - } - /> - )} - {providerType === "gcp" && ( - } - /> - )} - {providerType === "kubernetes" && ( - } - /> - )} - -
- {searchParamsObj.get("via") === "credentials" && ( - } - isDisabled={isLoading} - > - Back - - )} - } - > - {isLoading ? <>Loading : Next} - -
- - + ); }; diff --git a/ui/components/providers/workflow/forms/update-via-role-form.tsx b/ui/components/providers/workflow/forms/update-via-role-form.tsx index c0d9c24917..6385cac7f8 100644 --- a/ui/components/providers/workflow/forms/update-via-role-form.tsx +++ b/ui/components/providers/workflow/forms/update-via-role-form.tsx @@ -1,195 +1,32 @@ "use client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useSession } from "next-auth/react"; -import { Control, useForm, UseFormSetValue } from "react-hook-form"; -import * as z from "zod"; - import { updateCredentialsProvider } from "@/actions/providers/providers"; -import { useToast } from "@/components/ui"; -import { CustomButton } from "@/components/ui/custom"; -import { Form } from "@/components/ui/form"; -import { - addCredentialsRoleFormSchema, - ApiError, - AWSCredentialsRole, -} from "@/types"; +import { ProviderType } from "@/types"; -import { AWSCredentialsRoleForm } from "./via-role/aws-role-form"; +import { BaseCredentialsForm } from "./base-credentials-form"; export const UpdateViaRoleForm = ({ searchParams, }: { searchParams: { type: string; id: string; secretId?: string }; }) => { - const router = useRouter(); - const { toast } = useToast(); - const { data: session } = useSession(); - - const searchParamsObj = useSearchParams(); - - // Extract values from searchParams - const providerType = searchParams.type; + const providerType = searchParams.type as ProviderType; const providerId = searchParams.id; const providerSecretId = searchParams.secretId || ""; - const externalId = session?.tenantId; - const formSchema = addCredentialsRoleFormSchema(providerType); - type FormSchemaType = z.infer & { - credentials_type: "aws-sdk-default" | "access-secret-key"; + const handleUpdateCredentials = async (formData: FormData) => { + return await updateCredentialsProvider(providerSecretId, formData); }; - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - providerId, - providerType, - credentials_type: "aws-sdk-default", - ...(providerType === "aws" && { - role_arn: "", - external_id: externalId, - aws_access_key_id: "", - aws_secret_access_key: "", - aws_session_token: "", - role_session_name: "", - session_duration: "3600", - }), - }, - }); - - const isLoading = form.formState.isSubmitting; - - // Handle form submission - const onSubmitClient = async (values: FormSchemaType) => { - try { - const formData = new FormData(); - - Object.entries(values).forEach(([key, value]) => { - if (key === "credentials_type") return; - - if ( - values.credentials_type === "access-secret-key" && - [ - "aws_access_key_id", - "aws_secret_access_key", - "aws_session_token", - ].includes(key) - ) { - if (value !== undefined && value !== "") { - formData.append(key, String(value)); - } - return; - } - - if (value !== undefined && value !== "") { - formData.append(key, String(value)); - } - }); - - const data = await updateCredentialsProvider(providerSecretId, formData); - - // Handle errors - if (data?.errors?.length) { - data.errors.forEach((error: ApiError) => { - const errorMessage = error.detail; - switch (error.source.pointer) { - case "/data/attributes/secret/role_arn": - form.setError("role_arn" as keyof FormSchemaType, { - type: "server", - message: errorMessage, - }); - break; - case "/data/attributes/secret/external_id": - form.setError("external_id" as keyof FormSchemaType, { - type: "server", - message: errorMessage, - }); - break; - default: - toast({ - variant: "destructive", - title: "Oops! Something went wrong", - description: errorMessage, - }); - } - }); - } else { - // Redirect on success - router.push( - `/providers/test-connection?type=${providerType}&id=${providerId}&updated=true`, - ); - } - } catch (error) { - // eslint-disable-next-line no-console - console.error("Error during submission:", error); - toast({ - variant: "destructive", - title: "Submission failed", - description: "An error occurred while processing your request.", - }); - } - }; - - // Handle back navigation - const handleBackStep = () => { - const currentParams = new URLSearchParams(window.location.search); - currentParams.delete("via"); - router.push(`?${currentParams.toString()}`); - }; + const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}&updated=true`; return ( -
- - - - - {/* Conditional AWS Form */} - {providerType === "aws" && ( - } - setValue={ - form.setValue as unknown as UseFormSetValue - } - externalId={externalId || ""} - /> - )} - - {/* Action Buttons */} -
- {searchParamsObj.get("via") === "role" && ( - } - isDisabled={isLoading} - > - Back - - )} - } - > - {isLoading ? <>Loading : Next} - -
- - + ); }; diff --git a/ui/components/providers/workflow/forms/update-via-service-account-key-form.tsx b/ui/components/providers/workflow/forms/update-via-service-account-key-form.tsx new file mode 100644 index 0000000000..a1db92bbdd --- /dev/null +++ b/ui/components/providers/workflow/forms/update-via-service-account-key-form.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { updateCredentialsProvider } from "@/actions/providers/providers"; +import { ProviderType } from "@/types"; + +import { BaseCredentialsForm } from "./base-credentials-form"; + +export const UpdateViaServiceAccountForm = ({ + searchParams, +}: { + searchParams: { type: string; id: string; secretId?: string }; +}) => { + const providerType = searchParams.type as ProviderType; + const providerId = searchParams.id; + const providerSecretId = searchParams.secretId || ""; + + const handleUpdateCredentials = async (formData: FormData) => { + return await updateCredentialsProvider(providerSecretId, formData); + }; + + const successNavigationUrl = `/providers/test-connection?type=${providerType}&id=${providerId}&updated=true`; + + return ( + + ); +}; diff --git a/ui/components/providers/workflow/forms/via-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials-form.tsx deleted file mode 100644 index 38b3a98a86..0000000000 --- a/ui/components/providers/workflow/forms/via-credentials-form.tsx +++ /dev/null @@ -1,265 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { Divider } from "@nextui-org/divider"; -import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { Control, useForm } from "react-hook-form"; -import * as z from "zod"; - -import { addCredentialsProvider } from "@/actions/providers/providers"; -import { useToast } from "@/components/ui"; -import { CustomButton } from "@/components/ui/custom"; -import { Form } from "@/components/ui/form"; -import { ProviderType } from "@/types"; -import { - addCredentialsFormSchema, - ApiError, - AWSCredentials, - AzureCredentials, - GCPCredentials, - KubernetesCredentials, - M365Credentials, -} from "@/types"; - -import { ProviderTitleDocs } from "../provider-title-docs"; -import { AWScredentialsForm } from "./via-credentials/aws-credentials-form"; -import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form"; -import { GCPcredentialsForm } from "./via-credentials/gcp-credentials-form"; -import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form"; -import { M365CredentialsForm } from "./via-credentials/m365-credentials-form"; - -type CredentialsFormSchema = z.infer< - ReturnType ->; - -// Add this type intersection to include all fields -type FormType = CredentialsFormSchema & - AWSCredentials & - AzureCredentials & - GCPCredentials & - KubernetesCredentials & - M365Credentials; - -export const ViaCredentialsForm = ({ - searchParams, -}: { - searchParams: { type: string; id: string }; -}) => { - const router = useRouter(); - const { toast } = useToast(); - - const searchParamsObj = useSearchParams(); - - // Handler for back button - const handleBackStep = () => { - const currentParams = new URLSearchParams(window.location.search); - currentParams.delete("via"); - router.push(`?${currentParams.toString()}`); - }; - - const providerType = searchParams.type; - const providerId = searchParams.id; - const formSchema = addCredentialsFormSchema(providerType); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - providerId, - providerType, - ...(providerType === "aws" - ? { - aws_access_key_id: "", - aws_secret_access_key: "", - aws_session_token: "", - } - : providerType === "azure" - ? { - client_id: "", - client_secret: "", - tenant_id: "", - } - : providerType === "m365" - ? { - client_id: "", - client_secret: "", - tenant_id: "", - user: "", - encrypted_password: "", - } - : providerType === "gcp" - ? { - client_id: "", - client_secret: "", - refresh_token: "", - } - : providerType === "kubernetes" - ? { - kubeconfig_content: "", - } - : {}), - }, - }); - - const isLoading = form.formState.isSubmitting; - - const onSubmitClient = async (values: FormType) => { - const formData = new FormData(); - - Object.entries(values).forEach( - ([key, value]) => value !== undefined && formData.append(key, value), - ); - - const data = await addCredentialsProvider(formData); - - if (data?.errors && data.errors.length > 0) { - data.errors.forEach((error: ApiError) => { - const errorMessage = error.detail; - switch (error.source.pointer) { - case "/data/attributes/secret/aws_access_key_id": - form.setError("aws_access_key_id", { - type: "server", - message: errorMessage, - }); - break; - case "/data/attributes/secret/aws_secret_access_key": - form.setError("aws_secret_access_key", { - type: "server", - message: errorMessage, - }); - break; - case "/data/attributes/secret/aws_session_token": - form.setError("aws_session_token", { - type: "server", - message: errorMessage, - }); - break; - case "/data/attributes/secret/client_id": - form.setError("client_id", { - type: "server", - message: errorMessage, - }); - break; - case "/data/attributes/secret/client_secret": - form.setError("client_secret", { - type: "server", - message: errorMessage, - }); - break; - case "/data/attributes/secret/user": - form.setError("user", { - type: "server", - message: errorMessage, - }); - break; - case "/data/attributes/secret/encrypted_password": - form.setError("encrypted_password", { - type: "server", - message: errorMessage, - }); - break; - case "/data/attributes/secret/tenant_id": - form.setError("tenant_id", { - type: "server", - message: errorMessage, - }); - break; - case "/data/attributes/secret/kubeconfig_content": - form.setError("kubeconfig_content", { - type: "server", - message: errorMessage, - }); - break; - case "/data/attributes/name": - form.setError("secretName", { - type: "server", - message: errorMessage, - }); - break; - default: - toast({ - variant: "destructive", - title: "Oops! Something went wrong", - description: errorMessage, - }); - } - }); - } else { - router.push( - `/providers/test-connection?type=${providerType}&id=${providerId}`, - ); - } - }; - - return ( -
- - - - - - - - - {providerType === "aws" && ( - } - /> - )} - {providerType === "azure" && ( - } - /> - )} - {providerType === "m365" && ( - } - /> - )} - {providerType === "gcp" && ( - } - /> - )} - {providerType === "kubernetes" && ( - } - /> - )} - -
- {searchParamsObj.get("via") === "credentials" && ( - } - isDisabled={isLoading} - > - Back - - )} - } - > - {isLoading ? <>Loading : Next} - -
- - - ); -}; diff --git a/ui/components/providers/workflow/forms/via-credentials/index.ts b/ui/components/providers/workflow/forms/via-credentials/index.ts index 88bf5587f0..9058fc7362 100644 --- a/ui/components/providers/workflow/forms/via-credentials/index.ts +++ b/ui/components/providers/workflow/forms/via-credentials/index.ts @@ -1,5 +1,3 @@ -export * from "./aws-credentials-form"; export * from "./azure-credentials-form"; -export * from "./gcp-credentials-form"; export * from "./k8s-credentials-form"; export * from "./m365-credentials-form"; diff --git a/ui/components/providers/workflow/forms/via-credentials/m365-credentials-form.tsx b/ui/components/providers/workflow/forms/via-credentials/m365-credentials-form.tsx index 1ba6024ffc..d7c2d0bef1 100644 --- a/ui/components/providers/workflow/forms/via-credentials/m365-credentials-form.tsx +++ b/ui/components/providers/workflow/forms/via-credentials/m365-credentials-form.tsx @@ -64,14 +64,14 @@ export const M365CredentialsForm = ({ /> ); diff --git a/ui/components/providers/workflow/forms/via-role-form.tsx b/ui/components/providers/workflow/forms/via-role-form.tsx deleted file mode 100644 index 3bed8d3af7..0000000000 --- a/ui/components/providers/workflow/forms/via-role-form.tsx +++ /dev/null @@ -1,193 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useSession } from "next-auth/react"; -import { Control, useForm, UseFormSetValue } from "react-hook-form"; -import * as z from "zod"; - -import { addCredentialsProvider } from "@/actions/providers/providers"; -import { useToast } from "@/components/ui"; -import { CustomButton } from "@/components/ui/custom"; -import { Form } from "@/components/ui/form"; -import { - addCredentialsRoleFormSchema, - ApiError, - AWSCredentialsRole, -} from "@/types"; - -import { AWSCredentialsRoleForm } from "./via-role/aws-role-form"; - -export const ViaRoleForm = ({ - searchParams, -}: { - searchParams: { type: string; id: string }; -}) => { - const router = useRouter(); - const { toast } = useToast(); - const { data: session } = useSession(); - const searchParamsObj = useSearchParams(); - const externalId = session?.tenantId; - - // Handler for back button - const handleBackStep = () => { - const currentParams = new URLSearchParams(window.location.search); - currentParams.delete("via"); - router.push(`?${currentParams.toString()}`); - }; - - const providerType = searchParams.type; - const providerId = searchParams.id; - - const formSchema = addCredentialsRoleFormSchema(providerType); - type FormSchemaType = z.infer & { - credentials_type: "aws-sdk-default" | "access-secret-key"; - }; - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - providerId, - providerType, - credentials_type: "aws-sdk-default", - ...(providerType === "aws" - ? { - role_arn: "", - external_id: externalId, - aws_access_key_id: "", - aws_secret_access_key: "", - aws_session_token: "", - role_session_name: "", - session_duration: "3600", - } - : {}), - }, - }); - - const isLoading = form.formState.isSubmitting; - - const onSubmitClient = async (values: FormSchemaType) => { - const formData = new FormData(); - - Object.entries(values).forEach(([key, value]) => { - // Do not include credentials_type - if (key === "credentials_type") return; - - // If credentials_type is "access-secret-key", include the relevant fields - if ( - values.credentials_type === "access-secret-key" && - [ - "aws_access_key_id", - "aws_secret_access_key", - "aws_session_token", - ].includes(key) - ) { - if (value !== undefined && value !== "") { - formData.append(key, String(value)); - } - return; - } - - // Add any other valid field - if (value !== undefined && value !== "") { - formData.append(key, String(value)); - } - }); - - try { - const data = await addCredentialsProvider(formData); - - if (data?.errors && data.errors.length > 0) { - data.errors.forEach((error: ApiError) => { - const errorMessage = error.detail; - - switch (error.source.pointer) { - case "/data/attributes/secret/role_arn": - form.setError("role_arn" as keyof FormSchemaType, { - type: "server", - message: errorMessage, - }); - break; - case "/data/attributes/secret/external_id": - form.setError("external_id" as keyof FormSchemaType, { - type: "server", - message: errorMessage, - }); - break; - default: - toast({ - variant: "destructive", - title: "Oops! Something went wrong", - description: errorMessage, - }); - } - }); - } else { - router.push( - `/providers/test-connection?type=${providerType}&id=${providerId}`, - ); - } - } catch (error) { - // eslint-disable-next-line no-console - console.error("Error during submission:", error); - toast({ - variant: "destructive", - title: "Submission failed", - description: "An error occurred while processing your request.", - }); - } - }; - - return ( -
- - - - - {providerType === "aws" && ( - } - setValue={ - form.setValue as unknown as UseFormSetValue - } - externalId={externalId || ""} - /> - )} - -
- {searchParamsObj.get("via") === "role" && ( - } - isDisabled={isLoading} - > - Back - - )} - } - > - {isLoading ? <>Loading : Next} - -
- - - ); -}; diff --git a/ui/components/providers/workflow/forms/via-role/index.ts b/ui/components/providers/workflow/forms/via-role/index.ts deleted file mode 100644 index 763c90c0c0..0000000000 --- a/ui/components/providers/workflow/forms/via-role/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./aws-role-form"; diff --git a/ui/components/scans/launch-workflow/select-scan-provider.tsx b/ui/components/scans/launch-workflow/select-scan-provider.tsx index 490d3a05f2..10a21d2b10 100644 --- a/ui/components/scans/launch-workflow/select-scan-provider.tsx +++ b/ui/components/scans/launch-workflow/select-scan-provider.tsx @@ -80,8 +80,8 @@ export const SelectScanProvider = < {providers.map((item) => (
[] = [
), cell: ({ row }) => { - const scanId = row.original.id; - const scanState = row.original.attributes?.state; - return ( - downloadScanZip(scanId, toast)} - isDisabled={scanState !== "completed"} - /> +
+ +
); }, }, diff --git a/ui/components/scans/table/scans/data-table-download-details.tsx b/ui/components/scans/table/scans/data-table-download-details.tsx new file mode 100644 index 0000000000..4579ef3c56 --- /dev/null +++ b/ui/components/scans/table/scans/data-table-download-details.tsx @@ -0,0 +1,34 @@ +import { Row } from "@tanstack/react-table"; +import { useState } from "react"; + +import { DownloadIconButton, useToast } from "@/components/ui"; +import { downloadScanZip } from "@/lib"; + +interface DataTableDownloadDetailsProps { + row: Row; +} + +export function DataTableDownloadDetails({ + row, +}: DataTableDownloadDetailsProps) { + const { toast } = useToast(); + const [isDownloading, setIsDownloading] = useState(false); + + const scanId = (row.original as { id: string }).id; + const scanState = (row.original as any).attributes?.state; + + const handleDownload = async () => { + setIsDownloading(true); + await downloadScanZip(scanId, toast); + setIsDownloading(false); + }; + + return ( + + ); +} diff --git a/ui/components/ui/accordion/Accordion.tsx b/ui/components/ui/accordion/Accordion.tsx index aa029a121c..8117d5eea6 100644 --- a/ui/components/ui/accordion/Accordion.tsx +++ b/ui/components/ui/accordion/Accordion.tsx @@ -6,7 +6,7 @@ import { Selection, } from "@nextui-org/react"; import { ChevronDown } from "lucide-react"; -import React, { ReactNode, useCallback, useState } from "react"; +import React, { ReactNode, useCallback, useMemo, useState } from "react"; import { cn } from "@/lib/utils"; @@ -24,17 +24,24 @@ export interface AccordionProps { variant?: "light" | "shadow" | "bordered" | "splitted"; className?: string; defaultExpandedKeys?: string[]; + selectedKeys?: string[]; selectionMode?: "single" | "multiple"; isCompact?: boolean; showDivider?: boolean; + onItemExpand?: (key: string) => void; + onSelectionChange?: (keys: string[]) => void; } const AccordionContent = ({ content, items, + selectedKeys, + onSelectionChange, }: { content: ReactNode; items?: AccordionItemProps[]; + selectedKeys?: string[]; + onSelectionChange?: (keys: string[]) => void; }) => { return (
@@ -46,6 +53,8 @@ const AccordionContent = ({ variant="light" isCompact selectionMode="multiple" + selectedKeys={selectedKeys} + onSelectionChange={onSelectionChange} />
)} @@ -58,21 +67,58 @@ export const Accordion = ({ variant = "light", className, defaultExpandedKeys = [], + selectedKeys, selectionMode = "single", isCompact = false, showDivider = true, + onItemExpand, + onSelectionChange, }: AccordionProps) => { - const [expandedKeys, setExpandedKeys] = useState( + // Determine if component is in controlled or uncontrolled mode + const isControlled = selectedKeys !== undefined; + + const [internalExpandedKeys, setInternalExpandedKeys] = useState( new Set(defaultExpandedKeys), ); - const handleSelectionChange = useCallback((keys: Selection) => { - setExpandedKeys(keys); - }, []); + // Use selectedKeys if controlled, otherwise use internal state + const expandedKeys = useMemo( + () => (isControlled ? new Set(selectedKeys) : internalExpandedKeys), + [isControlled, selectedKeys, internalExpandedKeys], + ); + + const handleSelectionChange = useCallback( + (keys: Selection) => { + const keysArray = Array.from(keys as Set); + + // If controlled mode, call parent callback + if (isControlled && onSelectionChange) { + onSelectionChange(keysArray); + } else { + // If uncontrolled, update internal state + setInternalExpandedKeys(keys); + } + + // Handle onItemExpand for backward compatibility + if (onItemExpand && keys !== expandedKeys) { + const currentKeys = Array.from(expandedKeys as Set); + const newKeys = keysArray; + + const newlyExpandedKeys = newKeys.filter( + (key) => !currentKeys.includes(key), + ); + + newlyExpandedKeys.forEach((key) => { + onItemExpand(key); + }); + } + }, + [expandedKeys, onItemExpand, isControlled, onSelectionChange], + ); return ( } classNames={{ - base: index === 0 || index === 1 ? "my-2" : "my-1", - title: "text-sm font-medium", + base: index === 0 || index === 1 ? "my-1" : "my-1", + title: "text-sm", subtitle: "text-xs text-gray-500", trigger: - "p-2 rounded-lg data-[hover=true]:bg-gray-50 dark:data-[hover=true]:bg-gray-800/50", - content: "p-2", + "py-2 px-2 rounded-lg data-[hover=true]:bg-gray-50 dark:data-[hover=true]:bg-gray-800/50 w-full flex items-center", + content: "px-0 py-1", }} > - + ))} diff --git a/ui/components/ui/chart/horizontal-split-chart.tsx b/ui/components/ui/chart/horizontal-split-chart.tsx index b3b4c783a3..88c79e2996 100644 --- a/ui/components/ui/chart/horizontal-split-chart.tsx +++ b/ui/components/ui/chart/horizontal-split-chart.tsx @@ -70,6 +70,16 @@ interface HorizontalSplitBarProps { * @default "text-gray-700" */ labelColor?: string; + /** + * Growth ratio multiplier (pixels per value unit) + * @default 1 + */ + ratio?: number; + /** + * Show zero values in labels + * @default true + */ + showZero?: boolean; } /** @@ -99,6 +109,8 @@ export const HorizontalSplitBar = ({ tooltipContentA, tooltipContentB, labelColor = "text-gray-700", + ratio = 1, + showZero = true, }: HorizontalSplitBarProps) => { // Reference to the container to measure its width const containerRef = React.useRef(null); @@ -150,8 +162,9 @@ export const HorizontalSplitBar = ({ const halfWidth = availableWidth / 2; const separatorWidth = 1; - let rawWidthA = valA; - let rawWidthB = valB; + // Apply ratio multiplier to raw widths + let rawWidthA = valA * ratio; + let rawWidthB = valB * ratio; // Determine if we need to scale to fit in available space const maxSideWidth = halfWidth - separatorWidth / 2; @@ -183,7 +196,7 @@ export const HorizontalSplitBar = ({ className={cn("text-xs font-medium", labelColor)} aria-label={`${formattedValueA} ${tooltipContentA ? tooltipContentA : ""}`} > - {valA > 0 ? formattedValueA : "0"} + {valA > 0 ? formattedValueA : showZero ? "0" : ""}
{/* Left bar */} {valA > 0 && ( @@ -230,7 +243,7 @@ export const HorizontalSplitBar = ({ className={cn("text-xs font-medium", labelColor)} aria-label={`${formattedValueB} ${tooltipContentB ? tooltipContentB : ""}`} > - {valB > 0 ? formattedValueB : "0"} + {valB > 0 ? formattedValueB : showZero ? "0" : ""}
diff --git a/ui/components/ui/content-layout/content-layout.tsx b/ui/components/ui/content-layout/content-layout.tsx index f1fdb13a57..518fb58efa 100644 --- a/ui/components/ui/content-layout/content-layout.tsx +++ b/ui/components/ui/content-layout/content-layout.tsx @@ -1,12 +1,13 @@ -import { Suspense, use } from "react"; +import { ReactNode, Suspense, use } from "react"; import { getUserInfo } from "@/actions/users/users"; import { Navbar } from "../nav-bar/navbar"; import { SkeletonContentLayout } from "./skeleton-content-layout"; + interface ContentLayoutProps { title: string; - icon: string; + icon: string | ReactNode; children: React.ReactNode; } diff --git a/ui/components/ui/custom/custom-alert-modal.tsx b/ui/components/ui/custom/custom-alert-modal.tsx index 2df428104e..3878bb686f 100644 --- a/ui/components/ui/custom/custom-alert-modal.tsx +++ b/ui/components/ui/custom/custom-alert-modal.tsx @@ -23,9 +23,10 @@ export const CustomAlertModal: React.FC = ({ size="xl" classNames={{ base: "dark:bg-prowler-blue-800", - closeButton: "right-0", + closeButton: "rounded-md", }} backdrop="blur" + placement="center" > {(_onClose) => ( diff --git a/ui/components/ui/custom/custom-dropdown-filter.tsx b/ui/components/ui/custom/custom-dropdown-filter.tsx index ee18bab9bc..9330b5306b 100644 --- a/ui/components/ui/custom/custom-dropdown-filter.tsx +++ b/ui/components/ui/custom/custom-dropdown-filter.tsx @@ -10,167 +10,271 @@ import { PopoverTrigger, ScrollShadow, } from "@nextui-org/react"; -import { XCircle } from "lucide-react"; +import { ChevronDown, X } from "lucide-react"; import { useSearchParams } from "next/navigation"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; -import { PlusCircleIcon } from "@/components/icons"; -import { useUrlFilters } from "@/hooks/use-url-filters"; -import { CustomDropdownFilterProps } from "@/types"; +import { ComplianceScanInfo } from "@/components/compliance/compliance-header/compliance-scan-info"; +import { EntityInfoShort } from "@/components/ui/entities"; +import { isScanEntity } from "@/lib/helper-filters"; +import { + CustomDropdownFilterProps, + FilterEntity, + ProviderEntity, + ScanEntity, +} from "@/types"; -import { EntityInfoShort } from "../entities"; - -const filterSelectedClass = - "inline-flex items-center border py-1 text-xs transition-colors border-transparent bg-default-500 text-secondary-foreground hover:bg-default-500/80 rounded-md px-2 font-normal"; - -export const CustomDropdownFilter: React.FC = ({ +export const CustomDropdownFilter = ({ filter, onFilterChange, -}) => { +}: CustomDropdownFilterProps) => { const searchParams = useSearchParams(); - const { clearFilter } = useUrlFilters(); const [groupSelected, setGroupSelected] = useState(new Set()); - const [pendingClearFilter, setPendingClearFilter] = useState( - null, + const [isOpen, setIsOpen] = useState(false); + const hasUserInteracted = useRef(false); + + const filterValues = useMemo(() => filter?.values || [], [filter?.values]); + const selectedValues = Array.from(groupSelected).filter( + (value) => value !== "all", ); + const isAllSelected = + selectedValues.length === filterValues.length && filterValues.length > 0; - const allFilterKeys = useMemo(() => filter?.values || [], [filter?.values]); + const activeFilterValue = useMemo(() => { + const filterParam = searchParams.get(`filter[${filter?.key}]`); + return filterParam ? filterParam.split(",") : []; + }, [searchParams, filter?.key]); - const getActiveFilter = useMemo(() => { - const currentFilters: Record = {}; - Array.from(searchParams.entries()).forEach(([key, value]) => { - if (key.startsWith("filter[") && key.endsWith("]")) { - const filterKey = key.slice(7, -1); - if (filter && filter.key === filterKey) { - // eslint-disable-next-line security/detect-object-injection - currentFilters[filterKey] = value; - } + // Helper function to handle URL filter values sync + const syncWithActiveFilters = useCallback(() => { + const newSelection = new Set(activeFilterValue); + if ( + newSelection.size === filterValues.length && + filter?.showSelectAll !== false + ) { + newSelection.add("all"); + } + setGroupSelected(newSelection); + }, [activeFilterValue, filterValues, filter?.showSelectAll]); + + const resetComponentState = useCallback(() => { + setGroupSelected(new Set()); + hasUserInteracted.current = false; + }, []); + + const applyDefaultValues = useCallback(() => { + if (filter?.defaultToSelectAll && filterValues.length > 0) { + const newSelection = new Set(filterValues); + if (filter?.showSelectAll !== false) { + newSelection.add("all"); } - }); - return currentFilters; - }, [searchParams, filter]); + setGroupSelected(newSelection); + } else if (filter?.defaultValues && filter.defaultValues.length > 0) { + const validDefaultValues = filter.defaultValues.filter((value) => + filterValues.includes(value), + ); + const newSelection = new Set(validDefaultValues); - const memoizedFilterValues = useMemo( - () => filter?.values || [], - [filter?.values], - ); - - useEffect(() => { - if (filter && getActiveFilter[filter.key]) { - const activeValues = getActiveFilter[filter.key].split(","); - const newSelection = new Set(activeValues); - if (newSelection.size === memoizedFilterValues.length) { + // Add "all" if all items are selected and showSelectAll is not false + if ( + validDefaultValues.length === filterValues.length && + filter?.showSelectAll !== false + ) { newSelection.add("all"); } setGroupSelected(newSelection); } else { setGroupSelected(new Set()); } - }, [getActiveFilter, filter?.key, memoizedFilterValues, filter]); + }, [ + filterValues, + filter?.defaultToSelectAll, + filter?.defaultValues, + filter?.showSelectAll, + ]); + + useEffect(() => { + const hasActiveFilters = activeFilterValue.length > 0; + const userHasInteracted = hasUserInteracted.current; + + if (hasActiveFilters) { + // URL has filter values - sync component state with URL + syncWithActiveFilters(); + } else if (userHasInteracted) { + // URL has no filters but user had interacted - reset component state + resetComponentState(); + } else { + // URL has no filters and user hasn't interacted - apply defaults + applyDefaultValues(); + } + }, [ + activeFilterValue, + syncWithActiveFilters, + resetComponentState, + applyDefaultValues, + ]); + + const updateSelection = useCallback( + (newValues: string[]) => { + // Mark that user has interacted with the filter + hasUserInteracted.current = true; + + const actualValues = newValues.filter((key) => key !== "all"); + const newSelection = new Set(actualValues); + + // Auto-add "all" if all items are selected and showSelectAll is not false + if ( + actualValues.length === filterValues.length && + filterValues.length > 0 && + filter?.showSelectAll !== false + ) { + newSelection.add("all"); + } + + setGroupSelected(newSelection); + + // Notify parent with actual values (excluding "all") + onFilterChange?.(filter.key, actualValues); + }, + [filterValues.length, onFilterChange, filter.key, filter?.showSelectAll], + ); const onSelectionChange = useCallback( (keys: string[]) => { - setGroupSelected((prevGroupSelected) => { - const newSelection = new Set(keys); + const currentSelection = Array.from(groupSelected); + const newKeys = new Set(keys); + const oldKeys = new Set(currentSelection); - if ( - newSelection.size === allFilterKeys.length && - !newSelection.has("all") - ) { - return new Set(["all", ...allFilterKeys]); - } else if (prevGroupSelected.has("all")) { - newSelection.delete("all"); - return new Set(allFilterKeys.filter((key) => newSelection.has(key))); - } - return newSelection; - }); + // Check if "all" was just toggled + const allWasSelected = oldKeys.has("all"); + const allIsSelected = newKeys.has("all"); - if (onFilterChange && filter) { - const selectedValues = keys.filter((key) => key !== "all"); - onFilterChange(filter.key, selectedValues); + if (allIsSelected && !allWasSelected) { + // "all" was just selected - select all items + updateSelection(filterValues); + } else if (!allIsSelected && allWasSelected) { + // "all" was just deselected - deselect all items + updateSelection([]); + } else if (allIsSelected && allWasSelected) { + // "all" was already selected, but individual items changed + // Remove "all" and keep only the individual selections + const individualSelections = keys.filter((key) => key !== "all"); + updateSelection(individualSelections); + } else { + // Normal individual selection without "all" + updateSelection(keys); } }, - [allFilterKeys, onFilterChange, filter], + [groupSelected, updateSelection, filterValues], ); - const handleSelectAllClick = useCallback(() => { - setGroupSelected((prevGroupSelected: Set) => { - const newSelection: Set = prevGroupSelected.has("all") - ? new Set() - : new Set(["all", ...allFilterKeys]); + const handleClearAll = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + updateSelection([]); + }, + [updateSelection], + ); - if (onFilterChange && filter) { - const selectedValues = Array.from(newSelection).filter( - (key) => key !== "all", + const getDisplayLabel = useCallback( + (value: string) => { + const entity: FilterEntity | undefined = filter.valueLabelMapping?.find( + (entry) => entry[value], + )?.[value]; + if (!entity) return value; + + if (isScanEntity(entity as ScanEntity)) { + return ( + (entity as ScanEntity).attributes?.name || + (entity as ScanEntity).providerInfo?.alias || + (entity as ScanEntity).providerInfo?.uid || + value + ); + } else { + return ( + (entity as ProviderEntity).alias || + (entity as ProviderEntity).uid || + value ); - onFilterChange(filter.key, selectedValues); } - - return newSelection; - }); - }, [allFilterKeys, onFilterChange, filter]); - - // Update the pending clear filter - const onClearFilter = useCallback((filterKey: string) => { - setPendingClearFilter(filterKey); - }, []); - - // Execute the update in the router after the render - useEffect(() => { - if (pendingClearFilter && filter) { - clearFilter(pendingClearFilter); - setPendingClearFilter(null); // Reset the state - } - }, [pendingClearFilter, clearFilter, filter]); + }, + [filter.valueLabelMapping], + ); return ( -
- - - -
+ +
= ({ onValueChange={onSelectionChange} className="font-bold" > - - Select All - - + {filter?.showSelectAll !== false && ( + <> + + Select All + + + + )} - {memoizedFilterValues.map((value) => { - // Find the corresponding entity from valueLabelMapping - const matchingEntry = filter.valueLabelMapping?.find( - (entry) => entry[value], - ); - const entity = matchingEntry?.[value]; + {filterValues.map((value) => { + const entity: FilterEntity | undefined = + filter.valueLabelMapping?.find((entry) => entry[value])?.[ + value + ]; return ( = ({ value={value} > {entity ? ( - + isScanEntity(entity as ScanEntity) ? ( + + ) : ( + + ) ) : ( value )} diff --git a/ui/components/ui/custom/custom-server-input.tsx b/ui/components/ui/custom/custom-server-input.tsx new file mode 100644 index 0000000000..58318d96c9 --- /dev/null +++ b/ui/components/ui/custom/custom-server-input.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Input } from "@nextui-org/react"; +import React from "react"; + +interface CustomServerInputProps { + name: string; + label?: string; + labelPlacement?: "inside" | "outside"; + variant?: "flat" | "bordered" | "underlined" | "faded"; + type?: string; + placeholder?: string; + isRequired?: boolean; + isInvalid?: boolean; + errorMessage?: string; +} + +/** + * Custom input component that is used to display a server input without useForm hook. + */ +export const CustomServerInput = ({ + name, + type = "text", + label, + labelPlacement = "outside", + placeholder, + variant = "bordered", + isRequired = false, + isInvalid = false, + errorMessage, +}: CustomServerInputProps) => { + return ( +
+ +
+ ); +}; diff --git a/ui/components/ui/custom/index.ts b/ui/components/ui/custom/index.ts index d236bdc1d1..4e7a55b03f 100644 --- a/ui/components/ui/custom/index.ts +++ b/ui/components/ui/custom/index.ts @@ -6,4 +6,5 @@ export * from "./custom-dropdown-selection"; export * from "./custom-input"; export * from "./custom-loader"; export * from "./custom-radio"; +export * from "./custom-server-input"; export * from "./custom-textarea"; diff --git a/ui/components/ui/download-icon-button/download-icon-button.tsx b/ui/components/ui/download-icon-button/download-icon-button.tsx index 364ae0fe39..e84ecfa568 100644 --- a/ui/components/ui/download-icon-button/download-icon-button.tsx +++ b/ui/components/ui/download-icon-button/download-icon-button.tsx @@ -11,6 +11,7 @@ interface DownloadIconButtonProps { ariaLabel?: string; isDisabled?: boolean; textTooltip?: string; + isDownloading?: boolean; } export const DownloadIconButton = ({ @@ -19,20 +20,24 @@ export const DownloadIconButton = ({ ariaLabel = "Download report", isDisabled, textTooltip = "Download report", + isDownloading = false, }: DownloadIconButtonProps) => { return (
onDownload(paramId)} className="p-0 text-default-500 hover:text-primary disabled:opacity-30" isIconOnly ariaLabel={ariaLabel} size="sm" > - +
diff --git a/ui/components/ui/entities/date-with-time.tsx b/ui/components/ui/entities/date-with-time.tsx index e04c3e1cb6..080f2a0a74 100644 --- a/ui/components/ui/entities/date-with-time.tsx +++ b/ui/components/ui/entities/date-with-time.tsx @@ -30,9 +30,13 @@ export const DateWithTime: React.FC = ({
- {formattedDate} + + {formattedDate} + {showTime && ( - {formattedTime} + + {formattedTime} + )}
diff --git a/ui/components/ui/entities/entity-info-short.tsx b/ui/components/ui/entities/entity-info-short.tsx index c1c957642b..2b2ca7d6f0 100644 --- a/ui/components/ui/entities/entity-info-short.tsx +++ b/ui/components/ui/entities/entity-info-short.tsx @@ -1,15 +1,18 @@ +import { Tooltip } from "@nextui-org/react"; import React from "react"; +import { IdIcon } from "@/components/icons"; import { ProviderType } from "@/types"; import { getProviderLogo } from "./get-provider-logo"; -import { SnippetId } from "./snippet-id"; +import { SnippetChip } from "./snippet-chip"; interface EntityInfoProps { cloudProvider: ProviderType; entityAlias?: string; entityId?: string; hideCopyButton?: boolean; + snippetWidth?: string; } export const EntityInfoShort: React.FC = ({ @@ -19,16 +22,21 @@ export const EntityInfoShort: React.FC = ({ hideCopyButton = false, }) => { return ( -
-
+
+
{getProviderLogo(cloudProvider)}
-
+
{entityAlias && ( - {entityAlias} + + + {entityAlias} + + )} - } />
diff --git a/ui/components/ui/entities/index.ts b/ui/components/ui/entities/index.ts index cb329a03e3..fb13c99282 100644 --- a/ui/components/ui/entities/index.ts +++ b/ui/components/ui/entities/index.ts @@ -3,5 +3,4 @@ export * from "./entity-info-short"; export * from "./get-provider-logo"; export * from "./info-field"; export * from "./scan-status"; -export * from "./snippet-id"; -export * from "./snippet-label"; +export * from "./snippet-chip"; diff --git a/ui/components/ui/entities/info-field.tsx b/ui/components/ui/entities/info-field.tsx index 0607efdf7b..3d75bb98ae 100644 --- a/ui/components/ui/entities/info-field.tsx +++ b/ui/components/ui/entities/info-field.tsx @@ -5,9 +5,10 @@ import { InfoIcon } from "lucide-react"; interface InfoFieldProps { label: string; children: React.ReactNode; - variant?: "default" | "simple"; + variant?: "default" | "simple" | "transparent"; className?: string; tooltipContent?: string; + inline?: boolean; } { + if (inline) { + return ( +
+ + + {label}: + {tooltipContent && ( + +
+ +
+
+ )} +
+
+
{children}
+
+ ); + } + return (
@@ -45,8 +67,10 @@ export const InfoField = ({
{children}
+ ) : variant === "transparent" ? ( +
{children}
) : ( -
+
{children}
)} diff --git a/ui/components/ui/entities/snippet-chip.tsx b/ui/components/ui/entities/snippet-chip.tsx new file mode 100644 index 0000000000..5431ef94b5 --- /dev/null +++ b/ui/components/ui/entities/snippet-chip.tsx @@ -0,0 +1,54 @@ +import { cn, Snippet, Tooltip } from "@nextui-org/react"; +import React from "react"; + +import { CopyIcon, DoneIcon } from "@/components/icons"; + +interface SnippetChipProps { + value: string; + ariaLabel?: string; + icon?: React.ReactNode; + hideCopyButton?: boolean; + formatter?: (value: string) => string; + className?: string; +} +export const SnippetChip = ({ + value, + hideCopyButton = false, + ariaLabel = `Copy ${value} to clipboard`, + icon, + formatter, + className, + ...props +}: SnippetChipProps) => { + return ( + } + checkIcon={} + hideCopyButton={hideCopyButton} + codeString={value} + {...props} + > +
+ {icon} + + + {formatter ? formatter(value) : value} + + +
+
+ ); +}; diff --git a/ui/components/ui/entities/snippet-id.tsx b/ui/components/ui/entities/snippet-id.tsx deleted file mode 100644 index cb4b1a7e87..0000000000 --- a/ui/components/ui/entities/snippet-id.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Snippet, Tooltip } from "@nextui-org/react"; -import React from "react"; - -import { CopyIcon, DoneIcon, IdIcon } from "@/components/icons"; - -interface SnippetIdProps { - entityId: string; - hideCopyButton?: boolean; - [key: string]: any; -} -export const SnippetId: React.FC = ({ - entityId, - hideCopyButton = false, - ...props -}) => { - return ( - } - checkIcon={} - hideCopyButton={hideCopyButton} - {...props} - > -

- - - - {entityId} - - -

-
- ); -}; diff --git a/ui/components/ui/entities/snippet-label.tsx b/ui/components/ui/entities/snippet-label.tsx deleted file mode 100644 index 18525d6fe5..0000000000 --- a/ui/components/ui/entities/snippet-label.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Snippet } from "@nextui-org/react"; -import React from "react"; - -import { CopyIcon, DoneIcon } from "@/components/icons"; - -interface SnippetLabelProps { - label: string; - [key: string]: any; -} -export const SnippetLabel: React.FC = ({ - label, - ...props -}) => { - return ( - label !== "" && ( - } - checkIcon={} - {...props} - > -

- {label} -

-
- ) - ); -}; diff --git a/ui/components/ui/nav-bar/navbar.tsx b/ui/components/ui/nav-bar/navbar.tsx index 5cebc9586f..38906f72bf 100644 --- a/ui/components/ui/nav-bar/navbar.tsx +++ b/ui/components/ui/nav-bar/navbar.tsx @@ -1,13 +1,15 @@ import { Icon } from "@iconify/react"; +import { ReactNode } from "react"; import { ThemeSwitch } from "@/components/ThemeSwitch"; import { UserProfileProps } from "@/types"; import { SheetMenu } from "../sidebar/sheet-menu"; import { UserNav } from "../user-nav/user-nav"; + interface NavbarProps { title: string; - icon: string; + icon: string | ReactNode; user: UserProfileProps; } @@ -17,12 +19,18 @@ export function Navbar({ title, icon, user }: NavbarProps) {
- + {typeof icon === "string" ? ( + + ) : ( +
+ {icon} +
+ )}

{title}

diff --git a/ui/components/ui/skeleton/skeleton.tsx b/ui/components/ui/skeleton/skeleton.tsx new file mode 100644 index 0000000000..4591264f7d --- /dev/null +++ b/ui/components/ui/skeleton/skeleton.tsx @@ -0,0 +1,123 @@ +import { cn } from "@/lib/utils"; + +interface SkeletonProps { + className?: string; + variant?: "default" | "card" | "table" | "text" | "circle" | "rectangular"; + width?: string | number; + height?: string | number; + animate?: boolean; +} + +export function Skeleton({ + className, + variant = "default", + width, + height, + animate = true, +}: SkeletonProps) { + const variantClasses = { + default: "w-full h-4 rounded-lg", + card: "w-full h-40 rounded-xl", + table: "w-full h-60 rounded-lg", + text: "w-24 h-4 rounded-full", + circle: "rounded-full w-8 h-8", + rectangular: "rounded-md", + }; + + return ( +
+ ); +} + +export function SkeletonTable({ + rows = 5, + columns = 4, + className, + roundedCells = true, +}: { + rows?: number; + columns?: number; + className?: string; + roundedCells?: boolean; +}) { + return ( +
+ {/* Header */} +
+ {Array.from({ length: columns }).map((_, index) => ( + + ))} +
+ + {/* Rows */} + {Array.from({ length: rows }).map((_, rowIndex) => ( +
+ {Array.from({ length: columns }).map((_, colIndex) => ( + + ))} +
+ ))} +
+ ); +} + +export function SkeletonCard({ className }: { className?: string }) { + return ( +
+ + + +
+ ); +} + +export function SkeletonText({ + lines = 3, + className, + lastLineWidth = "w-1/2", +}: { + lines?: number; + className?: string; + lastLineWidth?: string; +}) { + return ( +
+ {Array.from({ length: lines - 1 }).map((_, index) => ( + + ))} + +
+ ); +} diff --git a/ui/components/ui/table/data-table-filter-custom.tsx b/ui/components/ui/table/data-table-filter-custom.tsx index 655297659c..de2015619f 100644 --- a/ui/components/ui/table/data-table-filter-custom.tsx +++ b/ui/components/ui/table/data-table-filter-custom.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState } from "react"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { CustomFilterIcon } from "@/components/icons"; import { CustomButton, CustomDropdownFilter } from "@/components/ui/custom"; @@ -20,6 +20,21 @@ export const DataTableFilterCustom = ({ const { updateFilter } = useUrlFilters(); const [showFilters, setShowFilters] = useState(defaultOpen); + // Sort filters by index property, with fallback to original order for filters without index + const sortedFilters = useMemo(() => { + return [...filters].sort((a, b) => { + // If both have index, sort by index + if (a.index !== undefined && b.index !== undefined) { + return a.index - b.index; + } + // If only one has index, prioritize the one with index + if (a.index !== undefined) return -1; + if (b.index !== undefined) return 1; + // If neither has index, maintain original order + return 0; + }); + }, [filters]); + const pushDropdownFilter = useCallback( (key: string, values: string[]) => { updateFilter(key, values.length > 0 ? values : null); @@ -30,7 +45,7 @@ export const DataTableFilterCustom = ({ return (
4 ? "flex-col" : "flex-col md:flex-row" + sortedFilters.length > 4 ? "flex-col" : "flex-col md:flex-row" } gap-4`} > } onPress={() => setShowFilters(!showFilters)} - className="w-fit" + className="w-full max-w-fit" >

{showFilters ? "Hide Filters" : "Show Filters"} @@ -56,12 +71,12 @@ export const DataTableFilterCustom = ({ >
4 + sortedFilters.length >= 4 ? "grid-cols-1 md:grid-cols-4" : "grid-cols-1 md:grid-cols-3" }`} > - {filters.map((filter) => ( + {sortedFilters.map((filter) => ( { const params = new URLSearchParams(searchParams); - if (pageNumber === "...") return `${pathname}?${params.toString()}`; + // Preserve all important parameters + const scanId = searchParams.get("scanId"); + const id = searchParams.get("id"); + const version = searchParams.get("version"); if (+pageNumber > totalPages) { return `${pathname}?${params.toString()}`; } params.set("page", pageNumber.toString()); + + // Ensure that scanId, id and version are preserved + if (scanId) params.set("scanId", scanId); + if (id) params.set("id", id); + if (version) params.set("version", version); + return `${pathname}?${params.toString()}`; }; + const isFirstPage = currentPage === 1; + const isLastPage = currentPage === totalPages; + return (
-
- {totalEntries} entries in Total. +
+ {totalEntries} entries in total
-
- {/* Rows per page selector */} -
-

Rows per page

- { + setSelectedPageSize(value); - const params = new URLSearchParams(searchParams); - params.set("pageSize", value); - params.set("page", "1"); + const params = new URLSearchParams(searchParams); - // This pushes the URL without reloading the page - router.push(`${pathname}?${params.toString()}`); - }} - > - - - - - {itemsPerPageOptions.map((pageSize) => ( - - {pageSize} - - ))} - - + // Preserve all important parameters + const scanId = searchParams.get("scanId"); + const id = searchParams.get("id"); + const version = searchParams.get("version"); + + params.set("pageSize", value); + params.set("page", "1"); + + // Ensure that scanId, id and version are preserved + if (scanId) params.set("scanId", scanId); + if (id) params.set("id", id); + if (version) params.set("version", version); + + // This pushes the URL without reloading the page + if (disableScroll) { + const url = `${pathname}?${params.toString()}`; + router.push(url, { scroll: false }); + } else { + router.push(`${pathname}?${params.toString()}`); + } + }} + > + + + + + {itemsPerPageOptions.map((pageSize) => ( + + {pageSize} + + ))} + + +
+
+ Page {currentPage} of {totalPages} +
+
+ isFirstPage && e.preventDefault()} + > +
-
- Page {currentPage} of {totalPages} -
-
- -
-
+ )}
); } diff --git a/ui/components/ui/table/data-table.tsx b/ui/components/ui/table/data-table.tsx index b759e4316d..49ad556897 100644 --- a/ui/components/ui/table/data-table.tsx +++ b/ui/components/ui/table/data-table.tsx @@ -29,12 +29,14 @@ interface DataTableProviderProps { data: TData[]; metadata?: MetaDataProps; customFilters?: FilterOption[]; + disableScroll?: boolean; } export function DataTable({ columns, data, metadata, + disableScroll = false, }: DataTableProviderProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); @@ -109,7 +111,10 @@ export function DataTable({
{metadata && (
- +
)} diff --git a/ui/components/ui/table/status-finding-badge.tsx b/ui/components/ui/table/status-finding-badge.tsx index b9532d3b5d..8177d116d7 100644 --- a/ui/components/ui/table/status-finding-badge.tsx +++ b/ui/components/ui/table/status-finding-badge.tsx @@ -16,10 +16,12 @@ const statusColorMap: Record< export const StatusFindingBadge = ({ status, size = "sm", + value, ...props }: { status: FindingStatus; size?: "sm" | "md" | "lg"; + value?: string | number; }) => { const color = statusColorMap[status]; @@ -33,6 +35,7 @@ export const StatusFindingBadge = ({ > {status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()} + {value !== undefined && `: ${value}`} ); diff --git a/ui/components/users/forms/edit-tenant-form.tsx b/ui/components/users/forms/edit-tenant-form.tsx new file mode 100644 index 0000000000..1e4d83d523 --- /dev/null +++ b/ui/components/users/forms/edit-tenant-form.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { Dispatch, SetStateAction, useEffect } from "react"; +import { useFormState, useFormStatus } from "react-dom"; + +import { updateTenantName } from "@/actions/users/tenants"; +import { SaveIcon } from "@/components/icons"; +import { useToast } from "@/components/ui"; +import { CustomButton, CustomServerInput } from "@/components/ui/custom"; + +const SubmitButton = () => { + const { pending } = useFormStatus(); + + return ( + } + > + {pending ? <>Loading : Save} + + ); +}; + +export const EditTenantForm = ({ + tenantId, + tenantName, + setIsOpen, +}: { + tenantId: string; + tenantName?: string; + setIsOpen: Dispatch>; +}) => { + const [state, formAction] = useFormState(updateTenantName, null); + const { toast } = useToast(); + + useEffect(() => { + if (state?.success) { + toast({ + title: "Changed successfully", + description: state.success, + }); + setIsOpen(false); + } else if (state?.errors?.general) { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: state.errors.general, + }); + } + }, [state, toast, setIsOpen]); + + return ( +
+
+ Current name: {tenantName} +
+ + + + {/* Hidden inputs for Server Action */} + + + +
+ setIsOpen(false)} + > + Cancel + + + +
+ + ); +}; diff --git a/ui/components/users/forms/index.ts b/ui/components/users/forms/index.ts index a081952ade..7d863e16c5 100644 --- a/ui/components/users/forms/index.ts +++ b/ui/components/users/forms/index.ts @@ -1,2 +1,3 @@ export * from "./delete-form"; export * from "./edit-form"; +export * from "./edit-tenant-form"; diff --git a/ui/components/users/profile/membership-item.tsx b/ui/components/users/profile/membership-item.tsx index f9d28a9f7d..a22f33dda0 100644 --- a/ui/components/users/profile/membership-item.tsx +++ b/ui/components/users/profile/membership-item.tsx @@ -1,27 +1,77 @@ -import { Chip } from "@nextui-org/react"; +"use client"; -import { DateWithTime } from "@/components/ui/entities"; -import { MembershipDetailData } from "@/types/users/users"; +import { Chip } from "@nextui-org/react"; +import { useState } from "react"; + +import { CustomAlertModal, CustomButton } from "@/components/ui/custom"; +import { DateWithTime, InfoField } from "@/components/ui/entities"; +import { MembershipDetailData } from "@/types/users"; + +import { EditTenantForm } from "../forms"; export const MembershipItem = ({ membership, tenantName, + tenantId, + isOwner, }: { membership: MembershipDetailData; tenantName: string; -}) => ( -
-
-
- - {membership.attributes.role} - -

{tenantName}

+ tenantId: string; + isOwner: boolean; +}) => { + const [isEditOpen, setIsEditOpen] = useState(false); + + return ( + <> + + + + +
+
+ + {membership.attributes.role} + + +
+ + + {tenantName} + + + + + +
+ + {isOwner && ( + setIsEditOpen(true)} + > + Change name + + )} +
-
-
- Joined on: - -
-
-); + + ); +}; diff --git a/ui/components/users/profile/memberships-card.tsx b/ui/components/users/profile/memberships-card.tsx index c752882156..0aba1b4834 100644 --- a/ui/components/users/profile/memberships-card.tsx +++ b/ui/components/users/profile/memberships-card.tsx @@ -1,15 +1,17 @@ import { Card, CardBody, CardHeader } from "@nextui-org/react"; -import { MembershipDetailData, TenantDetailData } from "@/types/users/users"; +import { MembershipDetailData, TenantDetailData } from "@/types/users"; import { MembershipItem } from "./membership-item"; export const MembershipsCard = ({ memberships, tenantsMap, + isOwner, }: { memberships: MembershipDetailData[]; tenantsMap: Record; + isOwner: boolean; }) => { return ( @@ -26,16 +28,18 @@ export const MembershipsCard = ({
No memberships found.
) : (
- {memberships.map((membership) => ( - - ))} + {memberships.map((membership) => { + const tenantId = membership.relationships.tenant.data.id; + return ( + + ); + })}
)} diff --git a/ui/components/users/profile/role-item.tsx b/ui/components/users/profile/role-item.tsx index 27c056843c..4d1ad7861e 100644 --- a/ui/components/users/profile/role-item.tsx +++ b/ui/components/users/profile/role-item.tsx @@ -6,7 +6,7 @@ import { useState } from "react"; import { CustomButton } from "@/components/ui/custom/custom-button"; import { getRolePermissions } from "@/lib/permissions"; -import { RoleData, RoleDetail } from "@/types/users/users"; +import { RoleData, RoleDetail } from "@/types/users"; interface PermissionItemProps { enabled: boolean; @@ -35,7 +35,7 @@ export const RoleItem = ({ role: RoleData; roleDetail?: RoleDetail; }) => { - const [isExpanded, setIsExpanded] = useState(false); + const [isExpanded, setIsExpanded] = useState(true); if (!roleDetail) { return ( diff --git a/ui/components/users/profile/roles-card.tsx b/ui/components/users/profile/roles-card.tsx index 5135c41528..126984be08 100644 --- a/ui/components/users/profile/roles-card.tsx +++ b/ui/components/users/profile/roles-card.tsx @@ -1,6 +1,6 @@ import { Card, CardBody, CardHeader } from "@nextui-org/react"; -import { RoleData, RoleDetail } from "@/types/users/users"; +import { RoleData, RoleDetail } from "@/types/users"; import { RoleItem } from "./role-item"; diff --git a/ui/components/users/profile/user-basic-info-card.tsx b/ui/components/users/profile/user-basic-info-card.tsx index 5c4ea0bbbd..7085953062 100644 --- a/ui/components/users/profile/user-basic-info-card.tsx +++ b/ui/components/users/profile/user-basic-info-card.tsx @@ -1,48 +1,16 @@ "use client"; -import { Card, CardBody, Divider, Tooltip } from "@nextui-org/react"; -import { CircleUserRound } from "lucide-react"; -import { useState } from "react"; +import { Card, CardBody, Divider } from "@nextui-org/react"; -import { CopyIcon, DoneIcon } from "@/components/icons"; -import { CustomButton } from "@/components/ui/custom/custom-button"; -import { DateWithTime } from "@/components/ui/entities"; -import { UserDataWithRoles } from "@/types/users/users"; +import { DateWithTime, InfoField, SnippetChip } from "@/components/ui/entities"; +import { UserDataWithRoles } from "@/types/users"; + +import { ProwlerShort } from "../../icons"; const TenantIdCopy = ({ id }: { id: string }) => { - const [copied, setCopied] = useState(false); - - const handleCopyTenantId = () => { - navigator.clipboard.writeText(id); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - return ( -
-

- Active organization ID: -

-
- - - - {id} - - {copied ? ( - - ) : ( - - )} - - -
+
+
); }; @@ -59,30 +27,32 @@ export const UserBasicInfoCard = ({ return ( -
- -
-

Name:

- {name} +
+
+
-
-

Email:

- {email} -
-
-

Company:

- {company_name} -
-
-

- Date Joined: -

- - +
+ {name} + + {email} + {company_name && ` | ${company_name}`}
- - +
+ +
+
+
+ + + +
+
+
+ + + +
diff --git a/ui/hooks/index.ts b/ui/hooks/index.ts index 2d03f3ba70..17921cb82a 100644 --- a/ui/hooks/index.ts +++ b/ui/hooks/index.ts @@ -1,3 +1,5 @@ +export * from "./use-credentials-form"; +export * from "./use-form-server-errors"; +export * from "./use-local-storage"; export * from "./use-sidebar"; export * from "./use-store"; -export * from "./useLocalStorage"; diff --git a/ui/hooks/use-credentials-form.ts b/ui/hooks/use-credentials-form.ts new file mode 100644 index 0000000000..df6ffa9517 --- /dev/null +++ b/ui/hooks/use-credentials-form.ts @@ -0,0 +1,168 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { useForm } from "react-hook-form"; + +import { useFormServerErrors } from "@/hooks/use-form-server-errors"; +import { filterEmptyValues } from "@/lib"; +import { PROVIDER_CREDENTIALS_ERROR_MAPPING } from "@/lib/error-mappings"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; +import { + addCredentialsFormSchema, + addCredentialsRoleFormSchema, + addCredentialsServiceAccountFormSchema, + ProviderType, +} from "@/types"; + +type CredentialsFormData = { + providerId: string; + providerType: ProviderType; + [key: string]: any; +}; + +type UseCredentialsFormProps = { + providerType: ProviderType; + providerId: string; + onSubmit: (formData: FormData) => Promise; + successNavigationUrl: string; +}; + +export const useCredentialsForm = ({ + providerType, + providerId, + onSubmit, + successNavigationUrl, +}: UseCredentialsFormProps) => { + const router = useRouter(); + const searchParamsObj = useSearchParams(); + const { data: session } = useSession(); + const via = searchParamsObj.get("via"); + + // Select the appropriate schema based on provider type and via parameter + const getFormSchema = () => { + if (providerType === "aws" && via === "role") { + return addCredentialsRoleFormSchema(providerType); + } + if (providerType === "gcp" && via === "service-account") { + return addCredentialsServiceAccountFormSchema(providerType); + } + return addCredentialsFormSchema(providerType); + }; + + const formSchema = getFormSchema(); + + // Get default values based on provider type and via parameter + const getDefaultValues = (): CredentialsFormData => { + const baseDefaults = { + [ProviderCredentialFields.PROVIDER_ID]: providerId, + [ProviderCredentialFields.PROVIDER_TYPE]: providerType, + }; + + // AWS Role credentials + if (providerType === "aws" && via === "role") { + return { + ...baseDefaults, + [ProviderCredentialFields.CREDENTIALS_TYPE]: "aws-sdk-default", + [ProviderCredentialFields.ROLE_ARN]: "", + [ProviderCredentialFields.EXTERNAL_ID]: session?.tenantId || "", + [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: "", + [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: "", + [ProviderCredentialFields.AWS_SESSION_TOKEN]: "", + [ProviderCredentialFields.ROLE_SESSION_NAME]: "", + [ProviderCredentialFields.SESSION_DURATION]: "3600", + }; + } + + // GCP Service Account + if (providerType === "gcp" && via === "service-account") { + return { + ...baseDefaults, + [ProviderCredentialFields.SERVICE_ACCOUNT_KEY]: "", + }; + } + + switch (providerType) { + case "aws": + return { + ...baseDefaults, + [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: "", + [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: "", + [ProviderCredentialFields.AWS_SESSION_TOKEN]: "", + }; + case "azure": + return { + ...baseDefaults, + [ProviderCredentialFields.CLIENT_ID]: "", + [ProviderCredentialFields.CLIENT_SECRET]: "", + [ProviderCredentialFields.TENANT_ID]: "", + }; + case "m365": + return { + ...baseDefaults, + [ProviderCredentialFields.CLIENT_ID]: "", + [ProviderCredentialFields.CLIENT_SECRET]: "", + [ProviderCredentialFields.TENANT_ID]: "", + [ProviderCredentialFields.USER]: "", + [ProviderCredentialFields.PASSWORD]: "", + }; + case "gcp": + return { + ...baseDefaults, + [ProviderCredentialFields.CLIENT_ID]: "", + [ProviderCredentialFields.CLIENT_SECRET]: "", + [ProviderCredentialFields.REFRESH_TOKEN]: "", + }; + case "kubernetes": + return { + ...baseDefaults, + [ProviderCredentialFields.KUBECONFIG_CONTENT]: "", + }; + default: + return baseDefaults; + } + }; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: getDefaultValues(), + }); + + const { handleServerResponse } = useFormServerErrors( + form, + PROVIDER_CREDENTIALS_ERROR_MAPPING, + ); + + // Handler for back button + const handleBackStep = () => { + const currentParams = new URLSearchParams(window.location.search); + currentParams.delete("via"); + router.push(`?${currentParams.toString()}`); + }; + + // Form submit handler + const handleSubmit = async (values: CredentialsFormData) => { + const formData = new FormData(); + + // Filter out empty values first, then append all remaining values + const filteredValues = filterEmptyValues(values); + Object.entries(filteredValues).forEach(([key, value]) => { + formData.append(key, value); + }); + + const data = await onSubmit(formData); + + const isSuccess = handleServerResponse(data); + if (isSuccess) { + router.push(successNavigationUrl); + } + }; + + return { + form, + isLoading: form.formState.isSubmitting, + handleSubmit, + handleBackStep, + searchParamsObj, + externalId: session?.tenantId || "", + }; +}; diff --git a/ui/hooks/use-form-server-errors.ts b/ui/hooks/use-form-server-errors.ts new file mode 100644 index 0000000000..d8d662bac7 --- /dev/null +++ b/ui/hooks/use-form-server-errors.ts @@ -0,0 +1,61 @@ +import { UseFormReturn } from "react-hook-form"; + +import { useToast } from "@/components/ui"; +import { ApiError } from "@/types"; + +/** + * Generic hook for handling server errors in forms + * Can be used across different types of forms, not just credential forms + */ +export const useFormServerErrors = >( + form: UseFormReturn, + customErrorMapping?: Record, +) => { + const { toast } = useToast(); + + const handleServerErrors = ( + errors: ApiError[], + errorMapping?: Record, + ) => { + errors.forEach((error: ApiError) => { + const errorMessage = error.detail; + const fieldName = errorMapping?.[error.source.pointer]; + + if (fieldName && fieldName in form.formState.defaultValues!) { + form.setError(fieldName as any, { + type: "server", + message: errorMessage, + }); + } else { + // Handle unknown error pointers with toast + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: errorMessage, + }); + } + }); + }; + + const handleServerResponse = ( + data: any, + errorMapping?: Record, + ) => { + // Check for both error (singular) and errors (plural) from server responses + if (data?.error) { + // Handle single error from server + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: data.error, + }); + return false; // Indicates error occurred + } else if (data?.errors && data.errors.length > 0) { + handleServerErrors(data.errors, errorMapping || customErrorMapping); + return false; // Indicates error occurred + } + return true; // Indicates success + }; + + return { handleServerResponse, handleServerErrors }; +}; diff --git a/ui/hooks/useLocalStorage.ts b/ui/hooks/use-local-storage.ts similarity index 100% rename from ui/hooks/useLocalStorage.ts rename to ui/hooks/use-local-storage.ts diff --git a/ui/hooks/use-url-filters.ts b/ui/hooks/use-url-filters.ts index c29de37327..83bd225791 100644 --- a/ui/hooks/use-url-filters.ts +++ b/ui/hooks/use-url-filters.ts @@ -16,8 +16,10 @@ export const useUrlFilters = () => { (key: string, value: string | string[] | null) => { const params = new URLSearchParams(searchParams.toString()); - // Always reset page to 1 when a filter is applied - params.set("page", "1"); + // Only reset page to 1 if page parameter already exists + if (params.has("page")) { + params.set("page", "1"); + } const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`; @@ -40,7 +42,11 @@ export const useUrlFilters = () => { const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`; params.delete(filterKey); - params.set("page", "1"); + + // Only reset page to 1 if page parameter already exists + if (params.has("page")) { + params.set("page", "1"); + } router.push(`${pathname}?${params.toString()}`, { scroll: false }); }, diff --git a/ui/lib/compliance/aws-well-architected.tsx b/ui/lib/compliance/aws-well-architected.tsx new file mode 100644 index 0000000000..d6fd809987 --- /dev/null +++ b/ui/lib/compliance/aws-well-architected.tsx @@ -0,0 +1,221 @@ +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; +import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; +import { FindingStatus } from "@/components/ui/table/status-finding-badge"; +import { + AttributesData, + AWSWellArchitectedAttributesMetadata, + Framework, + Requirement, + RequirementItemData, + RequirementsData, + RequirementStatus, +} from "@/types/compliance"; + +export const mapComplianceData = ( + attributesData: AttributesData, + requirementsData: RequirementsData, +): Framework[] => { + const attributes = attributesData?.data || []; + const requirements = requirementsData?.data || []; + + // Create a map for quick lookup of requirements by id + const requirementsMap = new Map(); + requirements.forEach((req: RequirementItemData) => { + requirementsMap.set(req.id, req); + }); + + const frameworks: Framework[] = []; + + // Process attributes and merge with requirements data + for (const attributeItem of attributes) { + const id = attributeItem.id; + const metadataArray = attributeItem.attributes?.attributes + ?.metadata as unknown as AWSWellArchitectedAttributesMetadata[]; + const attrs = metadataArray?.[0]; + if (!attrs) continue; + + // Get corresponding requirement data + const requirementData = requirementsMap.get(id); + if (!requirementData) continue; + + const frameworkName = attributeItem.attributes.framework; + const sectionName = attrs.Section || ""; + const subSectionName = attrs.SubSection || ""; + const description = attributeItem.attributes.description; + const status = requirementData.attributes.status || ""; + const checks = attributeItem.attributes.attributes.check_ids || []; + const requirementName = id; + + if (!sectionName || !subSectionName) { + continue; + } + + // Find or create framework + let framework = frameworks.find((f) => f.name === frameworkName); + if (!framework) { + framework = { + name: frameworkName, + pass: 0, + fail: 0, + manual: 0, + categories: [], + }; + frameworks.push(framework); + } + + // Find or create category (Section) + let category = framework.categories.find((c) => c.name === sectionName); + if (!category) { + category = { + name: sectionName, + pass: 0, + fail: 0, + manual: 0, + controls: [], + }; + framework.categories.push(category); + } + + // Find or create control (SubSection) + let control = category.controls.find((c) => c.label === subSectionName); + if (!control) { + control = { + label: subSectionName, + pass: 0, + fail: 0, + manual: 0, + requirements: [], + }; + category.controls.push(control); + } + + // Create requirement + const finalStatus: RequirementStatus = status as RequirementStatus; + const requirement: Requirement = { + name: requirementName, + description: description, + status: finalStatus, + check_ids: checks, + pass: finalStatus === "PASS" ? 1 : 0, + fail: finalStatus === "FAIL" ? 1 : 0, + manual: finalStatus === "MANUAL" ? 1 : 0, + well_architected_name: attrs.Name, + well_architected_question_id: attrs.WellArchitectedQuestionId, + well_architected_practice_id: attrs.WellArchitectedPracticeId, + level_of_risk: attrs.LevelOfRisk, + assessment_method: attrs.AssessmentMethod, + implementation_guidance_url: attrs.ImplementationGuidanceUrl, + }; + + control.requirements.push(requirement); + } + + // Calculate counters + frameworks.forEach((framework) => { + framework.pass = 0; + framework.fail = 0; + framework.manual = 0; + + framework.categories.forEach((category) => { + category.pass = 0; + category.fail = 0; + category.manual = 0; + + category.controls.forEach((control) => { + control.pass = 0; + control.fail = 0; + control.manual = 0; + + control.requirements.forEach((requirement) => { + if (requirement.status === "MANUAL") { + control.manual++; + } else if (requirement.status === "PASS") { + control.pass++; + } else if (requirement.status === "FAIL") { + control.fail++; + } + }); + + category.pass += control.pass; + category.fail += control.fail; + category.manual += control.manual; + }); + + framework.pass += category.pass; + framework.fail += category.fail; + framework.manual += category.manual; + }); + }); + + return frameworks; +}; + +export const toAccordionItems = ( + data: Framework[], + scanId: string | undefined, +): AccordionItemProps[] => { + return data.flatMap((framework) => + framework.categories.map((category) => { + return { + key: `${framework.name}-${category.name}`, + title: ( + + ), + content: "", + items: category.controls.map((control, i: number) => { + return { + key: `${framework.name}-${category.name}-control-${i}`, + title: ( + + ), + content: "", + items: control.requirements.map((requirement, j: number) => { + const itemKey = `${framework.name}-${category.name}-control-${i}-req-${j}`; + + return { + key: itemKey, + title: ( + + ), + content: ( + + ), + items: [], + }; + }), + isDisabled: + control.pass === 0 && control.fail === 0 && control.manual === 0, + }; + }), + }; + }), + ); +}; diff --git a/ui/lib/compliance/cis.tsx b/ui/lib/compliance/cis.tsx new file mode 100644 index 0000000000..df59c6dd95 --- /dev/null +++ b/ui/lib/compliance/cis.tsx @@ -0,0 +1,204 @@ +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; +import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; +import { FindingStatus } from "@/components/ui/table/status-finding-badge"; +import { + AttributesData, + CISAttributesMetadata, + Framework, + Requirement, + RequirementItemData, + RequirementsData, + RequirementStatus, +} from "@/types/compliance"; + +export const mapComplianceData = ( + attributesData: AttributesData, + requirementsData: RequirementsData, + filter?: string, // "Level 1" or "Level 2" or undefined (show all) +): Framework[] => { + const attributes = attributesData?.data || []; + const requirements = requirementsData?.data || []; + + // Create a map for quick lookup of requirements by id + const requirementsMap = new Map(); + requirements.forEach((req: RequirementItemData) => { + requirementsMap.set(req.id, req); + }); + + const frameworks: Framework[] = []; + + // Process attributes and merge with requirements data + for (const attributeItem of attributes) { + const id = attributeItem.id; + const metadataArray = attributeItem.attributes?.attributes + ?.metadata as unknown as CISAttributesMetadata[]; + const attrs = metadataArray?.[0]; + if (!attrs) continue; + + // Apply profile filter + if (filter === "Level 1" && attrs.Profile !== "Level 1") { + continue; // Skip Level 2 requirements when Level 1 is selected + } + + // Get corresponding requirement data + const requirementData = requirementsMap.get(id); + if (!requirementData) continue; + + const frameworkName = attributeItem.attributes.framework; + const sectionName = attrs.Section; + const description = attributeItem.attributes.description; + const status = requirementData.attributes.status || ""; + const checks = attributeItem.attributes.attributes.check_ids || []; + const requirementName = id; + + // Find or create framework + let framework = frameworks.find((f) => f.name === frameworkName); + if (!framework) { + framework = { + name: frameworkName, + pass: 0, + fail: 0, + manual: 0, + categories: [], + }; + frameworks.push(framework); + } + + const normalizedSectionName = sectionName.replace(/^(\d+)\s/, "$1. "); + let category = framework.categories.find( + (c) => c.name === normalizedSectionName, + ); + + if (!category) { + category = { + name: normalizedSectionName, + pass: 0, + fail: 0, + manual: 0, + controls: [], + }; + framework.categories.push(category); + } + + // Create a control for this requirement (each requirement is its own control) + const controlLabel = `${id} - ${description}`; + const control = { + label: controlLabel, + pass: 0, + fail: 0, + manual: 0, + requirements: [] as Requirement[], + }; + + // Create requirement + const finalStatus: RequirementStatus = status as RequirementStatus; + const requirement: Requirement = { + name: requirementName, + description: attrs.Description, + status: finalStatus, + check_ids: checks, + pass: finalStatus === "PASS" ? 1 : 0, + fail: finalStatus === "FAIL" ? 1 : 0, + manual: finalStatus === "MANUAL" ? 1 : 0, + profile: attrs.Profile, + subsection: attrs.SubSection || "", + assessment_status: attrs.AssessmentStatus, + rationale_statement: attrs.RationaleStatement, + impact_statement: attrs.ImpactStatement, + remediation_procedure: attrs.RemediationProcedure, + audit_procedure: attrs.AuditProcedure, + additional_information: attrs.AdditionalInformation, + default_value: attrs.DefaultValue || "", + references: attrs.References, + }; + + control.requirements.push(requirement); + + // Update control counters + if (requirement.status === "MANUAL") { + control.manual++; + } else if (requirement.status === "PASS") { + control.pass++; + } else if (requirement.status === "FAIL") { + control.fail++; + } + + category.controls.push(control); + } + + // Calculate counters for categories and frameworks + frameworks.forEach((framework) => { + framework.pass = 0; + framework.fail = 0; + framework.manual = 0; + + framework.categories.forEach((category) => { + category.pass = 0; + category.fail = 0; + category.manual = 0; + + category.controls.forEach((control) => { + category.pass += control.pass; + category.fail += control.fail; + category.manual += control.manual; + }); + + framework.pass += category.pass; + framework.fail += category.fail; + framework.manual += category.manual; + }); + }); + + return frameworks; +}; + +export const toAccordionItems = ( + data: Framework[], + scanId: string | undefined, +): AccordionItemProps[] => { + return data.flatMap((framework) => + framework.categories.map((category) => { + return { + key: `${framework.name}-${category.name}`, + title: ( + + ), + content: "", + items: category.controls.map((control, i: number) => { + const requirement = control.requirements[0]; // Each control has one requirement + const itemKey = `${framework.name}-${category.name}-control-${i}`; + + return { + key: itemKey, + title: ( + + ), + content: ( + + ), + items: [], + }; + }), + }; + }), + ); +}; diff --git a/ui/lib/compliance/commons.ts b/ui/lib/compliance/commons.ts new file mode 100644 index 0000000000..9befd489c7 --- /dev/null +++ b/ui/lib/compliance/commons.ts @@ -0,0 +1,263 @@ +import React from "react"; + +import { AWSWellArchitectedCustomDetails } from "@/components/compliance/compliance-custom-details/aws-well-architected-details"; +import { CISCustomDetails } from "@/components/compliance/compliance-custom-details/cis-details"; +import { ENSCustomDetails } from "@/components/compliance/compliance-custom-details/ens-details"; +import { GenericCustomDetails } from "@/components/compliance/compliance-custom-details/generic-details"; +import { ISOCustomDetails } from "@/components/compliance/compliance-custom-details/iso-details"; +import { KISACustomDetails } from "@/components/compliance/compliance-custom-details/kisa-details"; +import { MITRECustomDetails } from "@/components/compliance/compliance-custom-details/mitre-details"; +import { ThreatCustomDetails } from "@/components/compliance/compliance-custom-details/threat-details"; +import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; +import { + AttributesData, + CategoryData, + FailedSection, + Framework, + Requirement, + RequirementsData, +} from "@/types/compliance"; + +import { + mapComplianceData as mapAWSWellArchitectedComplianceData, + toAccordionItems as toAWSWellArchitectedAccordionItems, +} from "./aws-well-architected"; +import { + mapComplianceData as mapCISComplianceData, + toAccordionItems as toCISAccordionItems, +} from "./cis"; +import { + mapComplianceData as mapENSComplianceData, + toAccordionItems as toENSAccordionItems, +} from "./ens"; +import { + mapComplianceData as mapGenericComplianceData, + toAccordionItems as toGenericAccordionItems, +} from "./generic"; +import { + mapComplianceData as mapISOComplianceData, + toAccordionItems as toISOAccordionItems, +} from "./iso"; +import { + mapComplianceData as mapKISAComplianceData, + toAccordionItems as toKISAAccordionItems, +} from "./kisa"; +import { + calculateCategoryHeatmapData as calculateMITRECategoryHeatmapData, + getTopFailedSections as getMITRETopFailedSections, + mapComplianceData as mapMITREComplianceData, + toAccordionItems as toMITREAccordionItems, +} from "./mitre"; +import { + mapComplianceData as mapThetaComplianceData, + toAccordionItems as toThetaAccordionItems, +} from "./threat"; + +export interface ComplianceMapper { + mapComplianceData: ( + attributesData: AttributesData, + requirementsData: RequirementsData, + filter?: string, + ) => Framework[]; + toAccordionItems: ( + data: Framework[], + scanId: string | undefined, + ) => AccordionItemProps[]; + getTopFailedSections: (mappedData: Framework[]) => FailedSection[]; + calculateCategoryHeatmapData: (complianceData: Framework[]) => CategoryData[]; + getDetailsComponent: (requirement: Requirement) => React.ReactNode; +} + +// Common function for getting top failed sections +export const getTopFailedSections = ( + mappedData: Framework[], +): FailedSection[] => { + const failedSectionMap = new Map(); + + mappedData.forEach((framework) => { + framework.categories.forEach((category) => { + category.controls.forEach((control) => { + control.requirements.forEach((requirement) => { + if (requirement.status === "FAIL") { + const sectionName = category.name; + + if (!failedSectionMap.has(sectionName)) { + failedSectionMap.set(sectionName, { total: 0, types: {} }); + } + + const sectionData = failedSectionMap.get(sectionName); + sectionData.total += 1; + + const type = requirement.type || "Fails"; + + sectionData.types[type as string] = + (sectionData.types[type as string] || 0) + 1; + } + }); + }); + }); + }); + + // Convert in descending order and slice top 5 + return Array.from(failedSectionMap.entries()) + .map(([name, data]) => ({ name, ...data })) + .sort((a, b) => b.total - a.total) + .slice(0, 5); // Top 5 +}; + +// Registry of compliance mappers +const complianceMappers: Record = { + ENS: { + mapComplianceData: mapENSComplianceData, + toAccordionItems: toENSAccordionItems, + getTopFailedSections, + calculateCategoryHeatmapData: (data: Framework[]) => + calculateCategoryHeatmapData(data), + getDetailsComponent: (requirement: Requirement) => + React.createElement(ENSCustomDetails, { requirement }), + }, + ISO27001: { + mapComplianceData: mapISOComplianceData, + toAccordionItems: toISOAccordionItems, + getTopFailedSections, + calculateCategoryHeatmapData: (data: Framework[]) => + calculateCategoryHeatmapData(data), + getDetailsComponent: (requirement: Requirement) => + React.createElement(ISOCustomDetails, { requirement }), + }, + CIS: { + mapComplianceData: mapCISComplianceData, + toAccordionItems: toCISAccordionItems, + getTopFailedSections, + calculateCategoryHeatmapData: (data: Framework[]) => + calculateCategoryHeatmapData(data), + getDetailsComponent: (requirement: Requirement) => + React.createElement(CISCustomDetails, { requirement }), + }, + "AWS-Well-Architected-Framework-Security-Pillar": { + mapComplianceData: mapAWSWellArchitectedComplianceData, + toAccordionItems: toAWSWellArchitectedAccordionItems, + getTopFailedSections, + calculateCategoryHeatmapData: (data: Framework[]) => + calculateCategoryHeatmapData(data), + getDetailsComponent: (requirement: Requirement) => + React.createElement(AWSWellArchitectedCustomDetails, { requirement }), + }, + "AWS-Well-Architected-Framework-Reliability-Pillar": { + mapComplianceData: mapAWSWellArchitectedComplianceData, + toAccordionItems: toAWSWellArchitectedAccordionItems, + getTopFailedSections, + calculateCategoryHeatmapData: (data: Framework[]) => + calculateCategoryHeatmapData(data), + getDetailsComponent: (requirement: Requirement) => + React.createElement(AWSWellArchitectedCustomDetails, { requirement }), + }, + "KISA-ISMS-P": { + mapComplianceData: mapKISAComplianceData, + toAccordionItems: toKISAAccordionItems, + getTopFailedSections, + calculateCategoryHeatmapData: (data: Framework[]) => + calculateCategoryHeatmapData(data), + getDetailsComponent: (requirement: Requirement) => + React.createElement(KISACustomDetails, { requirement }), + }, + "MITRE-ATTACK": { + mapComplianceData: mapMITREComplianceData, + toAccordionItems: toMITREAccordionItems, + getTopFailedSections: getMITRETopFailedSections, + calculateCategoryHeatmapData: calculateMITRECategoryHeatmapData, + getDetailsComponent: (requirement: Requirement) => + React.createElement(MITRECustomDetails, { requirement }), + }, + ProwlerThreatScore: { + mapComplianceData: mapThetaComplianceData, + toAccordionItems: toThetaAccordionItems, + getTopFailedSections, + calculateCategoryHeatmapData: (complianceData: Framework[]) => + calculateCategoryHeatmapData(complianceData), + getDetailsComponent: (requirement: Requirement) => + React.createElement(ThreatCustomDetails, { requirement }), + }, +}; + +// Default mapper (fallback to generic for maximum compatibility) +const defaultMapper: ComplianceMapper = { + mapComplianceData: mapGenericComplianceData, + toAccordionItems: toGenericAccordionItems, + getTopFailedSections, + calculateCategoryHeatmapData: (data: Framework[]) => + calculateCategoryHeatmapData(data), + getDetailsComponent: (requirement: Requirement) => + React.createElement(GenericCustomDetails, { requirement }), +}; + +/** + * Get the appropriate compliance mapper based on the framework name + * @param framework - The framework name (e.g., "ENS", "ISO27001", "CIS") + * @returns ComplianceMapper object with specific functions for the framework + */ +export const getComplianceMapper = (framework?: string): ComplianceMapper => { + if (!framework) { + return defaultMapper; + } + + return complianceMappers[framework] || defaultMapper; +}; + +export const calculateCategoryHeatmapData = ( + complianceData: Framework[], +): CategoryData[] => { + if (!complianceData?.length) { + return []; + } + + try { + const categoryMap = new Map< + string, + { pass: number; fail: number; manual: number } + >(); + + // Aggregate data by category + complianceData.forEach((framework) => { + framework.categories.forEach((category) => { + const existing = categoryMap.get(category.name) || { + pass: 0, + fail: 0, + manual: 0, + }; + categoryMap.set(category.name, { + pass: existing.pass + category.pass, + fail: existing.fail + category.fail, + manual: existing.manual + category.manual, + }); + }); + }); + + const categoryData: CategoryData[] = Array.from(categoryMap.entries()).map( + ([name, stats]) => { + const totalRequirements = stats.pass + stats.fail + stats.manual; + const failurePercentage = + totalRequirements > 0 + ? Math.round((stats.fail / totalRequirements) * 100) + : 0; + + return { + name, + failurePercentage, + totalRequirements, + failedRequirements: stats.fail, + }; + }, + ); + + const filteredData = categoryData + .filter((category) => category.totalRequirements > 0) + .sort((a, b) => b.failurePercentage - a.failurePercentage) + .slice(0, 9); // Show top 9 categories + + return filteredData; + } catch (error) { + console.error("Error calculating category heatmap data:", error); + return []; + } +}; diff --git a/ui/lib/compliance/ens.tsx b/ui/lib/compliance/ens.tsx new file mode 100644 index 0000000000..a3103035a3 --- /dev/null +++ b/ui/lib/compliance/ens.tsx @@ -0,0 +1,249 @@ +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; +import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; +import { FindingStatus } from "@/components/ui/table/status-finding-badge"; +import { + AttributesData, + ENSAttributesMetadata, + Framework, + Requirement, + RequirementItemData, + RequirementsData, + RequirementStatus, +} from "@/types/compliance"; + +export const translateType = (type: string) => { + if (!type) { + return ""; + } + + switch (type.toLowerCase()) { + case "requisito": + return "Requirement"; + case "recomendacion": + return "Recommendation"; + case "refuerzo": + return "Reinforcement"; + case "medida": + return "Measure"; + default: + return type; + } +}; + +export const mapComplianceData = ( + attributesData: AttributesData, + requirementsData: RequirementsData, +): Framework[] => { + const attributes = attributesData?.data || []; + const requirements = requirementsData?.data || []; + + // Create a map for quick lookup of requirements by id + const requirementsMap = new Map(); + requirements.forEach((req: RequirementItemData) => { + requirementsMap.set(req.id, req); + }); + + const frameworks: Framework[] = []; + + // Process attributes and merge with requirements data + for (const attributeItem of attributes) { + const id = attributeItem.id; + const attrs = attributeItem.attributes?.attributes + ?.metadata?.[0] as ENSAttributesMetadata; + + if (!attrs) continue; + + // Get corresponding requirement data + const requirementData = requirementsMap.get(id); + if (!requirementData) continue; + + const frameworkName = attrs.Marco; + const categoryName = attrs.Categoria; + const groupControl = attrs.IdGrupoControl; + const type = attrs.Tipo; + const description = attributeItem.attributes.description; + const status = requirementData.attributes.status || ""; + const controlDescription = attrs.DescripcionControl || ""; + const checks = attributeItem.attributes.attributes.check_ids || []; + const isManual = attrs.ModoEjecucion === "manual"; + const requirementName = id; + const groupControlLabel = `${groupControl} - ${description}`; + + // Find or create framework + let framework = frameworks.find((f) => f.name === frameworkName); + if (!framework) { + framework = { + name: frameworkName, + pass: 0, + fail: 0, + manual: 0, + categories: [], + }; + frameworks.push(framework); + } + + // Find or create category + let category = framework.categories.find((c) => c.name === categoryName); + if (!category) { + category = { + name: categoryName, + pass: 0, + fail: 0, + manual: 0, + controls: [], + }; + framework.categories.push(category); + } + + // Find or create control + let control = category.controls.find((c) => c.label === groupControlLabel); + if (!control) { + control = { + label: groupControlLabel, + pass: 0, + fail: 0, + manual: 0, + requirements: [], + }; + category.controls.push(control); + } + + // Create requirement + const finalStatus: RequirementStatus = isManual + ? "MANUAL" + : (status as RequirementStatus); + const requirement: Requirement = { + name: requirementName, + description: controlDescription, + status: finalStatus, + type, + check_ids: checks, + pass: finalStatus === "PASS" ? 1 : 0, + fail: finalStatus === "FAIL" ? 1 : 0, + manual: finalStatus === "MANUAL" ? 1 : 0, + nivel: attrs.Nivel || "", + dimensiones: attrs.Dimensiones || [], + }; + + control.requirements.push(requirement); + } + + // Calculate counters + frameworks.forEach((framework) => { + framework.pass = 0; + framework.fail = 0; + framework.manual = 0; + + framework.categories.forEach((category) => { + category.pass = 0; + category.fail = 0; + category.manual = 0; + + category.controls.forEach((control) => { + control.pass = 0; + control.fail = 0; + control.manual = 0; + + control.requirements.forEach((requirement) => { + if (requirement.status === "MANUAL") { + control.manual++; + } else if (requirement.status === "PASS") { + control.pass++; + } else if (requirement.status === "FAIL") { + control.fail++; + } + }); + + category.pass += control.pass; + category.fail += control.fail; + category.manual += control.manual; + }); + + framework.pass += category.pass; + framework.fail += category.fail; + framework.manual += category.manual; + }); + }); + + return frameworks; +}; + +export const toAccordionItems = ( + data: Framework[], + scanId: string | undefined, +): AccordionItemProps[] => { + return data.map((framework) => { + return { + key: framework.name, + title: ( + + ), + content: "", + items: framework.categories.map((category) => { + return { + key: `${framework.name}-${category.name}`, + title: ( + + ), + content: "", + items: category.controls.map((control, i: number) => { + return { + key: `${framework.name}-${category.name}-control-${i}`, + title: ( + + ), + content: "", + items: control.requirements.map((requirement, j: number) => { + const itemKey = `${framework.name}-${category.name}-control-${i}-req-${j}`; + + return { + key: itemKey, + title: ( + + ), + content: ( + + ), + }; + }), + isDisabled: + control.pass === 0 && + control.fail === 0 && + control.manual === 0, + }; + }), + }; + }), + }; + }); +}; diff --git a/ui/lib/compliance/generic.tsx b/ui/lib/compliance/generic.tsx new file mode 100644 index 0000000000..b46188f693 --- /dev/null +++ b/ui/lib/compliance/generic.tsx @@ -0,0 +1,193 @@ +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; +import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; +import { FindingStatus } from "@/components/ui/table/status-finding-badge"; +import { + AttributesData, + Framework, + GenericAttributesMetadata, + Requirement, + RequirementItemData, + RequirementsData, + RequirementStatus, +} from "@/types/compliance"; + +export const mapComplianceData = ( + attributesData: AttributesData, + requirementsData: RequirementsData, +): Framework[] => { + const attributes = attributesData?.data || []; + const requirements = requirementsData?.data || []; + + // Create a map for quick lookup of requirements by id + const requirementsMap = new Map(); + requirements.forEach((req: RequirementItemData) => { + requirementsMap.set(req.id, req); + }); + + const frameworks: Framework[] = []; + + // Process attributes and merge with requirements data + for (const attributeItem of attributes) { + const id = attributeItem.id; + const metadataArray = attributeItem.attributes?.attributes + ?.metadata as unknown as GenericAttributesMetadata[]; + const attrs = metadataArray?.[0]; + if (!attrs) continue; + + // Get corresponding requirement data + const requirementData = requirementsMap.get(id); + if (!requirementData) continue; + + const frameworkName = attributeItem.attributes.framework; + const sectionName = attrs.Section; // Level 1: Section -> Category + const requirementName = attributeItem.attributes.name || id; // Level 2: name -> Control + const description = attributeItem.attributes.description; + const status = requirementData.attributes.status || ""; + const checks = attributeItem.attributes.attributes.check_ids || []; + + if (!sectionName) { + continue; + } + + // Find or create framework + let framework = frameworks.find((f) => f.name === frameworkName); + if (!framework) { + framework = { + name: frameworkName, + pass: 0, + fail: 0, + manual: 0, + categories: [], + }; + frameworks.push(framework); + } + + // Find or create category (Section) + let category = framework.categories.find((c) => c.name === sectionName); + if (!category) { + category = { + name: sectionName, + pass: 0, + fail: 0, + manual: 0, + controls: [], + }; + framework.categories.push(category); + } + + // Create a control for this requirement (each requirement is its own control in this generic approach) + const control = { + label: requirementName, + pass: 0, + fail: 0, + manual: 0, + requirements: [] as Requirement[], + }; + + // Create requirement + const finalStatus: RequirementStatus = status as RequirementStatus; + const requirement: Requirement = { + name: requirementName, + description: description, + status: finalStatus, + check_ids: checks, + pass: finalStatus === "PASS" ? 1 : 0, + fail: finalStatus === "FAIL" ? 1 : 0, + manual: finalStatus === "MANUAL" ? 1 : 0, + item_id: attrs.ItemId, + subsection: attrs.SubSection, + subgroup: attrs.SubGroup || undefined, + service: attrs.Service || undefined, + type: attrs.Type || undefined, + }; + + control.requirements.push(requirement); + + // Update control counters + if (requirement.status === "MANUAL") { + control.manual++; + } else if (requirement.status === "PASS") { + control.pass++; + } else if (requirement.status === "FAIL") { + control.fail++; + } + + category.controls.push(control); + } + + // Calculate counters for categories and frameworks + frameworks.forEach((framework) => { + framework.pass = 0; + framework.fail = 0; + framework.manual = 0; + + framework.categories.forEach((category) => { + category.pass = 0; + category.fail = 0; + category.manual = 0; + + category.controls.forEach((control) => { + category.pass += control.pass; + category.fail += control.fail; + category.manual += control.manual; + }); + + framework.pass += category.pass; + framework.fail += category.fail; + framework.manual += category.manual; + }); + }); + + return frameworks; +}; + +export const toAccordionItems = ( + data: Framework[], + scanId: string | undefined, +): AccordionItemProps[] => { + return data.flatMap((framework) => + framework.categories.map((category) => { + return { + key: `${framework.name}-${category.name}`, + title: ( + + ), + content: "", + items: category.controls.map((control, i: number) => { + const requirement = control.requirements[0]; // Each control has one requirement + const itemKey = `${framework.name}-${category.name}-control-${i}`; + + return { + key: itemKey, + title: ( + + ), + content: ( + + ), + items: [], + }; + }), + }; + }), + ); +}; diff --git a/ui/lib/compliance/iso.tsx b/ui/lib/compliance/iso.tsx new file mode 100644 index 0000000000..4fdfd0fc31 --- /dev/null +++ b/ui/lib/compliance/iso.tsx @@ -0,0 +1,200 @@ +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; +import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; +import { FindingStatus } from "@/components/ui/table/status-finding-badge"; +import { + AttributesData, + Framework, + ISO27001AttributesMetadata, + Requirement, + RequirementItemData, + RequirementsData, + RequirementStatus, +} from "@/types/compliance"; + +export const mapComplianceData = ( + attributesData: AttributesData, + requirementsData: RequirementsData, +): Framework[] => { + const attributes = attributesData?.data || []; + const requirements = requirementsData?.data || []; + + // Create a map for quick lookup of requirements by id + const requirementsMap = new Map(); + requirements.forEach((req: RequirementItemData) => { + requirementsMap.set(req.id, req); + }); + + const frameworks: Framework[] = []; + + // Process attributes and merge with requirements data + for (const attributeItem of attributes) { + const id = attributeItem.id; + const metadataArray = attributeItem.attributes?.attributes + ?.metadata as unknown as ISO27001AttributesMetadata[]; + const attrs = metadataArray?.[0]; + if (!attrs) continue; + + // Get corresponding requirement data + const requirementData = requirementsMap.get(id); + if (!requirementData) continue; + + const frameworkName = attributeItem.attributes.framework; + const categoryName = attrs.Category; + const controlLabel = `${attrs.Objetive_ID} - ${attrs.Objetive_Name}`; + const description = attributeItem.attributes.description; + const status = requirementData.attributes.status || ""; + const checks = attributeItem.attributes.attributes.check_ids || []; + const requirementName = id; + const objetiveName = attrs.Objetive_Name; + const checkSummary = attrs.Check_Summary; + + // Find or create framework + let framework = frameworks.find((f) => f.name === frameworkName); + if (!framework) { + framework = { + name: frameworkName, + pass: 0, + fail: 0, + manual: 0, + categories: [], + }; + frameworks.push(framework); + } + + // Find or create category + let category = framework.categories.find((c) => c.name === categoryName); + if (!category) { + category = { + name: categoryName, + pass: 0, + fail: 0, + manual: 0, + controls: [], + }; + framework.categories.push(category); + } + + // Find or create control + let control = category.controls.find((c) => c.label === controlLabel); + if (!control) { + control = { + label: controlLabel, + pass: 0, + fail: 0, + manual: 0, + requirements: [], + }; + category.controls.push(control); + } + + // Create requirement + const finalStatus: RequirementStatus = status as RequirementStatus; + const requirement: Requirement = { + name: requirementName, + description: description, + status: finalStatus, + check_ids: checks, + pass: finalStatus === "PASS" ? 1 : 0, + fail: finalStatus === "FAIL" ? 1 : 0, + manual: finalStatus === "MANUAL" ? 1 : 0, + objetive_name: objetiveName, + check_summary: checkSummary, + control_label: controlLabel, + }; + + control.requirements.push(requirement); + } + + // Calculate counters + frameworks.forEach((framework) => { + framework.pass = 0; + framework.fail = 0; + framework.manual = 0; + + framework.categories.forEach((category) => { + category.pass = 0; + category.fail = 0; + category.manual = 0; + + category.controls.forEach((control) => { + control.pass = 0; + control.fail = 0; + control.manual = 0; + + control.requirements.forEach((requirement) => { + if (requirement.status === "MANUAL") { + control.manual++; + } else if (requirement.status === "PASS") { + control.pass++; + } else if (requirement.status === "FAIL") { + control.fail++; + } + }); + + category.pass += control.pass; + category.fail += control.fail; + category.manual += control.manual; + }); + + framework.pass += category.pass; + framework.fail += category.fail; + framework.manual += category.manual; + }); + }); + + return frameworks; +}; + +export const toAccordionItems = ( + data: Framework[], + scanId: string | undefined, +): AccordionItemProps[] => { + return data.flatMap((framework) => + framework.categories.map((category) => { + const allRequirements = category.controls.flatMap( + (control) => control.requirements, + ); + + return { + key: `${framework.name}-${category.name}`, + title: ( + + ), + content: "", + items: allRequirements.map((requirement, j: number) => { + const itemKey = `${framework.name}-${category.name}-req-${j}`; + + return { + key: itemKey, + title: ( + + ), + content: ( + + ), + items: [], + }; + }), + }; + }), + ); +}; diff --git a/ui/lib/compliance/kisa.tsx b/ui/lib/compliance/kisa.tsx new file mode 100644 index 0000000000..70545ead92 --- /dev/null +++ b/ui/lib/compliance/kisa.tsx @@ -0,0 +1,214 @@ +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; +import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; +import { FindingStatus } from "@/components/ui/table/status-finding-badge"; +import { + AttributesData, + Framework, + KISAAttributesMetadata, + Requirement, + RequirementItemData, + RequirementsData, + RequirementStatus, +} from "@/types/compliance"; + +export const mapComplianceData = ( + attributesData: AttributesData, + requirementsData: RequirementsData, +): Framework[] => { + const attributes = attributesData?.data || []; + const requirements = requirementsData?.data || []; + + // Create a map for quick lookup of requirements by id + const requirementsMap = new Map(); + requirements.forEach((req: RequirementItemData) => { + requirementsMap.set(req.id, req); + }); + + const frameworks: Framework[] = []; + + // Process attributes and merge with requirements data + for (const attributeItem of attributes) { + const id = attributeItem.id; + const metadataArray = attributeItem.attributes?.attributes + ?.metadata as unknown as KISAAttributesMetadata[]; + const attrs = metadataArray?.[0]; + if (!attrs) continue; + + // Get corresponding requirement data + const requirementData = requirementsMap.get(id); + if (!requirementData) continue; + + const frameworkName = attributeItem.attributes.framework; + const categoryName = attrs.Domain; // Level 1: Domain + const controlLabel = attrs.Subdomain; // Level 2: Subdomain + const sectionName = attrs.Section; // Level 3: Section + const description = attributeItem.attributes.description; + const status = requirementData.attributes.status || ""; + const checks = attributeItem.attributes.attributes.check_ids || []; + const requirementName = id; + + // Find or create framework + let framework = frameworks.find((f) => f.name === frameworkName); + if (!framework) { + framework = { + name: frameworkName, + pass: 0, + fail: 0, + manual: 0, + categories: [], + }; + frameworks.push(framework); + } + + // Find or create category (Domain) + let category = framework.categories.find((c) => c.name === categoryName); + if (!category) { + category = { + name: categoryName, + pass: 0, + fail: 0, + manual: 0, + controls: [], + }; + framework.categories.push(category); + } + + // Find or create control (Subdomain) + let control = category.controls.find((c) => c.label === controlLabel); + if (!control) { + control = { + label: controlLabel, + pass: 0, + fail: 0, + manual: 0, + requirements: [], + }; + category.controls.push(control); + } + + // Create requirement (Section) + const finalStatus: RequirementStatus = status as RequirementStatus; + const requirement: Requirement = { + name: requirementName, + description: description, + status: finalStatus, + check_ids: checks, + pass: finalStatus === "PASS" ? 1 : 0, + fail: finalStatus === "FAIL" ? 1 : 0, + manual: finalStatus === "MANUAL" ? 1 : 0, + section: sectionName, + audit_checklist: attrs.AuditChecklist, + related_regulations: attrs.RelatedRegulations, + audit_evidence: attrs.AuditEvidence, + non_compliance_cases: attrs.NonComplianceCases, + }; + + control.requirements.push(requirement); + } + + // Calculate counters + frameworks.forEach((framework) => { + framework.pass = 0; + framework.fail = 0; + framework.manual = 0; + + framework.categories.forEach((category) => { + category.pass = 0; + category.fail = 0; + category.manual = 0; + + category.controls.forEach((control) => { + control.pass = 0; + control.fail = 0; + control.manual = 0; + + control.requirements.forEach((requirement) => { + if (requirement.status === "MANUAL") { + control.manual++; + } else if (requirement.status === "PASS") { + control.pass++; + } else if (requirement.status === "FAIL") { + control.fail++; + } + }); + + category.pass += control.pass; + category.fail += control.fail; + category.manual += control.manual; + }); + + framework.pass += category.pass; + framework.fail += category.fail; + framework.manual += category.manual; + }); + }); + + return frameworks; +}; + +export const toAccordionItems = ( + data: Framework[], + scanId: string | undefined, +): AccordionItemProps[] => { + return data.flatMap((framework) => + framework.categories.map((category) => { + return { + key: `${framework.name}-${category.name}`, + title: ( + + ), + content: "", + items: category.controls.map((control, i: number) => { + return { + key: `${framework.name}-${category.name}-control-${i}`, + title: ( + + ), + content: "", + items: control.requirements.map((requirement, j: number) => { + const itemKey = `${framework.name}-${category.name}-control-${i}-req-${j}`; + + return { + key: itemKey, + title: ( + + ), + content: ( + + ), + items: [], + }; + }), + isDisabled: + control.pass === 0 && control.fail === 0 && control.manual === 0, + }; + }), + }; + }), + ); +}; diff --git a/ui/lib/compliance/mitre.tsx b/ui/lib/compliance/mitre.tsx new file mode 100644 index 0000000000..480cb8c967 --- /dev/null +++ b/ui/lib/compliance/mitre.tsx @@ -0,0 +1,252 @@ +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; +import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; +import { FindingStatus } from "@/components/ui/table/status-finding-badge"; +import { + AttributesData, + CategoryData, + FailedSection, + Framework, + MITREAttributesMetadata, + Requirement, + RequirementItemData, + RequirementsData, + RequirementStatus, +} from "@/types/compliance"; + +export const mapComplianceData = ( + attributesData: AttributesData, + requirementsData: RequirementsData, +): Framework[] => { + const attributes = attributesData?.data || []; + const requirements = requirementsData?.data || []; + + // Create a map for quick lookup of requirements by id + const requirementsMap = new Map(); + requirements.forEach((req: RequirementItemData) => { + requirementsMap.set(req.id, req); + }); + + const frameworks: Framework[] = []; + + // Process attributes and merge with requirements data + for (const attributeItem of attributes) { + const id = attributeItem.id; + const metadataArray = attributeItem.attributes?.attributes + ?.metadata as unknown as MITREAttributesMetadata[]; + + if (!metadataArray || metadataArray.length === 0) continue; + + // Get corresponding requirement data + const requirementData = requirementsMap.get(id); + if (!requirementData) continue; + + const frameworkName = attributeItem.attributes.framework; + const techniqueName = attributeItem.attributes.name || id; + const description = attributeItem.attributes.description; + const status = requirementData.attributes.status || ""; + const checks = attributeItem.attributes.attributes.check_ids || []; + const tactics = attributeItem.attributes.tactics || []; + const subtechniques = attributeItem.attributes.subtechniques || []; + const platforms = attributeItem.attributes.platforms || []; + const techniqueUrl = attributeItem.attributes.technique_url || ""; + const requirementName = `${id} - ${techniqueName}`; + + // Find or create framework + let framework = frameworks.find((f) => f.name === frameworkName); + if (!framework) { + framework = { + name: frameworkName, + pass: 0, + fail: 0, + manual: 0, + categories: [], + }; + frameworks.push(framework); + } + + // Create requirement directly (flat structure - no categories) + const finalStatus: RequirementStatus = status as RequirementStatus; + const requirement: Requirement = { + name: requirementName, + description: description, + status: finalStatus, + check_ids: checks, + pass: finalStatus === "PASS" ? 1 : 0, + fail: finalStatus === "FAIL" ? 1 : 0, + manual: finalStatus === "MANUAL" ? 1 : 0, + // MITRE specific fields + technique_id: id, + technique_name: techniqueName, + tactics: tactics, + subtechniques: subtechniques, + platforms: platforms, + technique_url: techniqueUrl, + cloud_services: metadataArray.map((m) => { + // Dynamically find the service field (AWSService, GCPService, AzureService, etc.) + const serviceKey = Object.keys(m).find((key) => + key.toLowerCase().includes("service"), + ); + const serviceName = serviceKey ? m[serviceKey] : "Unknown Service"; + + return { + service: serviceName, + category: m.Category, + value: m.Value, + comment: m.Comment, + }; + }), + }; + + // Add requirement directly to framework (store in a special property) + (framework as any).requirements = (framework as any).requirements || []; + (framework as any).requirements.push(requirement); + + // Update framework counters + if (requirement.status === "MANUAL") { + framework.manual++; + } else if (requirement.status === "PASS") { + framework.pass++; + } else if (requirement.status === "FAIL") { + framework.fail++; + } + } + + return frameworks; +}; + +export const toAccordionItems = ( + data: Framework[], + scanId: string | undefined, +): AccordionItemProps[] => { + return data.flatMap((framework) => { + const requirements = (framework as any).requirements || []; + + return requirements.map((requirement: Requirement, i: number) => { + const itemKey = `${framework.name}-req-${i}`; + + return { + key: itemKey, + title: ( + + ), + content: ( + + ), + items: [], + }; + }); + }); +}; + +// Custom function for MITRE to get top failed sections grouped by tactics +export const getTopFailedSections = ( + mappedData: Framework[], +): FailedSection[] => { + const failedSectionMap = new Map(); + + mappedData.forEach((framework) => { + const requirements = (framework as any).requirements || []; + + requirements.forEach((requirement: Requirement) => { + if (requirement.status === "FAIL") { + const tactics = (requirement.tactics as string[]) || []; + + tactics.forEach((tactic) => { + if (!failedSectionMap.has(tactic)) { + failedSectionMap.set(tactic, { total: 0, types: {} }); + } + + const sectionData = failedSectionMap.get(tactic); + sectionData.total += 1; + + const type = "Fails"; + sectionData.types[type] = (sectionData.types[type] || 0) + 1; + }); + } + }); + }); + + // Convert in descending order and slice top 5 + return Array.from(failedSectionMap.entries()) + .map(([name, data]) => ({ name, ...data })) + .sort((a, b) => b.total - a.total) + .slice(0, 5); // Top 5 +}; + +// Custom function for MITRE to calculate category heatmap data grouped by tactics +export const calculateCategoryHeatmapData = ( + complianceData: Framework[], +): CategoryData[] => { + if (!complianceData?.length) { + return []; + } + + try { + const tacticMap = new Map< + string, + { pass: number; fail: number; manual: number } + >(); + + // Aggregate data by tactics + complianceData.forEach((framework) => { + const requirements = (framework as any).requirements || []; + + requirements.forEach((requirement: Requirement) => { + const tactics = (requirement.tactics as string[]) || []; + + tactics.forEach((tactic) => { + const existing = tacticMap.get(tactic) || { + pass: 0, + fail: 0, + manual: 0, + }; + + tacticMap.set(tactic, { + pass: existing.pass + requirement.pass, + fail: existing.fail + requirement.fail, + manual: existing.manual + requirement.manual, + }); + }); + }); + }); + + const categoryData: CategoryData[] = Array.from(tacticMap.entries()).map( + ([name, stats]) => { + const totalRequirements = stats.pass + stats.fail + stats.manual; + const failurePercentage = + totalRequirements > 0 + ? Math.round((stats.fail / totalRequirements) * 100) + : 0; + + return { + name, + failurePercentage, + totalRequirements, + failedRequirements: stats.fail, + }; + }, + ); + + const filteredData = categoryData + .filter((category) => category.totalRequirements > 0) + .sort((a, b) => b.failurePercentage - a.failurePercentage) + .slice(0, 9); // Show top 9 tactics + + return filteredData; + } catch (error) { + console.error("Error calculating MITRE category heatmap data:", error); + return []; + } +}; diff --git a/ui/lib/compliance/threat.tsx b/ui/lib/compliance/threat.tsx new file mode 100644 index 0000000000..df59f11685 --- /dev/null +++ b/ui/lib/compliance/threat.tsx @@ -0,0 +1,246 @@ +import { ClientAccordionContent } from "@/components/compliance/compliance-accordion/client-accordion-content"; +import { ComplianceAccordionRequirementTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-requeriment-title"; +import { ComplianceAccordionTitle } from "@/components/compliance/compliance-accordion/compliance-accordion-title"; +import { AccordionItemProps } from "@/components/ui/accordion/Accordion"; +import { FindingStatus } from "@/components/ui/table/status-finding-badge"; +import { + AttributesData, + Framework, + Requirement, + RequirementItemData, + RequirementsData, + RequirementStatus, + ThreatAttributesMetadata, +} from "@/types/compliance"; + +export const mapComplianceData = ( + attributesData: AttributesData, + requirementsData: RequirementsData, +): Framework[] => { + const attributes = attributesData?.data || []; + const requirements = requirementsData?.data || []; + + // Create a map for quick lookup of requirements by id + const requirementsMap = new Map(); + requirements.forEach((req: RequirementItemData) => { + requirementsMap.set(req.id, req); + }); + + const frameworks: Framework[] = []; + + // Process attributes and merge with requirements data + for (const attributeItem of attributes) { + const id = attributeItem.id; + const metadataArray = attributeItem.attributes?.attributes + ?.metadata as unknown as ThreatAttributesMetadata[]; + const attrs = metadataArray?.[0]; + if (!attrs) continue; + + // Get corresponding requirement data + const requirementData = requirementsMap.get(id); + if (!requirementData) continue; + + const frameworkName = attributeItem.attributes.framework; + const sectionName = attrs.Section; + const subSectionName = attrs.SubSection; + const title = attrs.Title; + const description = attributeItem.attributes.description; + const status = requirementData.attributes.status || ""; + const checks = attributeItem.attributes.attributes.check_ids || []; + const requirementName = id; + const levelOfRisk = attrs.LevelOfRisk; + const weight = attrs.Weight; + const attributeDescription = attrs.AttributeDescription; + const additionalInformation = attrs.AdditionalInformation; + + // Calculate score: if PASS = levelOfRisk * weight, if FAIL = 0 + const score = status === "PASS" ? levelOfRisk * weight : 0; + + // Find or create framework + let framework = frameworks.find((f) => f.name === frameworkName); + if (!framework) { + framework = { + name: frameworkName, + pass: 0, + fail: 0, + manual: 0, + categories: [], + }; + frameworks.push(framework); + } + + // Find or create category (Section) + let category = framework.categories.find((c) => c.name === sectionName); + if (!category) { + category = { + name: sectionName, + pass: 0, + fail: 0, + manual: 0, + controls: [], + }; + framework.categories.push(category); + } + + // Find or create control (SubSection) + let control = category.controls.find((c) => c.label === subSectionName); + if (!control) { + control = { + label: subSectionName, + pass: 0, + fail: 0, + manual: 0, + requirements: [], + }; + category.controls.push(control); + } + + // Create requirement + const finalStatus: RequirementStatus = status as RequirementStatus; + const requirement: Requirement = { + name: requirementName, + description: description, + status: finalStatus, + check_ids: checks, + pass: finalStatus === "PASS" ? 1 : 0, + fail: finalStatus === "FAIL" ? 1 : 0, + manual: finalStatus === "MANUAL" ? 1 : 0, + title: title, + levelOfRisk: levelOfRisk, + weight: weight, + score: score, + attributeDescription: attributeDescription, + additionalInformation: additionalInformation, + }; + + control.requirements.push(requirement); + } + + // Calculate counters and percentualScore + frameworks.forEach((framework) => { + framework.pass = 0; + framework.fail = 0; + framework.manual = 0; + + framework.categories.forEach((category) => { + category.pass = 0; + category.fail = 0; + category.manual = 0; + + // Calculate total score for this section and maximum possible score + let totalSectionScore = 0; + let maxPossibleSectionScore = 0; + + category.controls.forEach((control) => { + control.pass = 0; + control.fail = 0; + control.manual = 0; + + control.requirements.forEach((requirement) => { + if (requirement.status === "MANUAL") { + control.manual++; + } else if (requirement.status === "PASS") { + control.pass++; + } else if (requirement.status === "FAIL") { + control.fail++; + } + + // Add to total section score (actual score obtained) + totalSectionScore += (requirement.score as number) || 0; + + // Add to maximum possible score (weight * levelOfRisk for each requirement) + const levelOfRisk = (requirement.levelOfRisk as number) || 0; + const weight = (requirement.weight as number) || 0; + maxPossibleSectionScore += levelOfRisk * weight; + }); + + category.pass += control.pass; + category.fail += control.fail; + category.manual += control.manual; + }); + + // Calculate percentualScore for this section: (suma de scores obtenidos / suma de weight * levelOfRisk) * 100 + const percentualScore = + maxPossibleSectionScore > 0 + ? (totalSectionScore / maxPossibleSectionScore) * 100 + : 0; + + // Add percentualScore to category (we can extend the type or use a custom property) + (category as any).percentualScore = + Math.round(percentualScore * 100) / 100; // Round to 2 decimal places + + framework.pass += category.pass; + framework.fail += category.fail; + framework.manual += category.manual; + }); + }); + + return frameworks; +}; + +export const toAccordionItems = ( + data: Framework[], + scanId: string | undefined, +): AccordionItemProps[] => { + return data.flatMap((framework) => + framework.categories.map((category) => { + const percentualScore = (category as any).percentualScore || 0; + + return { + key: `${framework.name}-${category.name}`, + title: ( + + ), + content: "", + items: category.controls.map((control, i: number) => { + return { + key: `${framework.name}-${category.name}-control-${i}`, + title: ( + + ), + content: "", + items: control.requirements.map((requirement, j: number) => { + const itemKey = `${framework.name}-${category.name}-control-${i}-req-${j}`; + + return { + key: itemKey, + title: ( + + ), + content: ( + + ), + items: [], + }; + }), + isDisabled: + control.pass === 0 && control.fail === 0 && control.manual === 0, + }; + }), + }; + }), + ); +}; diff --git a/ui/lib/error-mappings.ts b/ui/lib/error-mappings.ts new file mode 100644 index 0000000000..582dfd5696 --- /dev/null +++ b/ui/lib/error-mappings.ts @@ -0,0 +1,31 @@ +import { + ErrorPointers, + ProviderCredentialFields, +} from "./provider-credentials/provider-credential-fields"; + +/** + * Error pointer to field name mappings for different types of forms + * These can be imported and used with the useFormServerErrors hook + */ + +// Mapping for provider credentials forms +export const PROVIDER_CREDENTIALS_ERROR_MAPPING: Record = { + [ErrorPointers.AWS_ACCESS_KEY_ID]: ProviderCredentialFields.AWS_ACCESS_KEY_ID, + [ErrorPointers.AWS_SECRET_ACCESS_KEY]: + ProviderCredentialFields.AWS_SECRET_ACCESS_KEY, + [ErrorPointers.AWS_SESSION_TOKEN]: ProviderCredentialFields.AWS_SESSION_TOKEN, + [ErrorPointers.CLIENT_ID]: ProviderCredentialFields.CLIENT_ID, + [ErrorPointers.CLIENT_SECRET]: ProviderCredentialFields.CLIENT_SECRET, + [ErrorPointers.USER]: ProviderCredentialFields.USER, + [ErrorPointers.PASSWORD]: ProviderCredentialFields.PASSWORD, + [ErrorPointers.TENANT_ID]: ProviderCredentialFields.TENANT_ID, + [ErrorPointers.KUBECONFIG_CONTENT]: + ProviderCredentialFields.KUBECONFIG_CONTENT, + [ErrorPointers.REFRESH_TOKEN]: ProviderCredentialFields.REFRESH_TOKEN, + [ErrorPointers.ROLE_ARN]: ProviderCredentialFields.ROLE_ARN, + [ErrorPointers.EXTERNAL_ID]: ProviderCredentialFields.EXTERNAL_ID, + [ErrorPointers.SESSION_DURATION]: ProviderCredentialFields.SESSION_DURATION, + [ErrorPointers.ROLE_SESSION_NAME]: ProviderCredentialFields.ROLE_SESSION_NAME, + [ErrorPointers.SERVICE_ACCOUNT_KEY]: + ProviderCredentialFields.SERVICE_ACCOUNT_KEY, +}; diff --git a/ui/lib/helper-filters.ts b/ui/lib/helper-filters.ts index fe17185746..5668e24339 100644 --- a/ui/lib/helper-filters.ts +++ b/ui/lib/helper-filters.ts @@ -1,3 +1,6 @@ +import { ProviderProps, ProvidersApiResponse, ScanProps } from "@/types"; +import { ScanEntity } from "@/types/scans"; + /** * Extracts normalized filters and search query from the URL search params. * Used Server Side Rendering (SSR). There is a hook (useUrlFilters) for client side. @@ -44,3 +47,54 @@ export const extractSortAndKey = (searchParams: Record) => { return { searchParamsKey, rawSort, encodedSort }; }; + +export const isScanEntity = (entity: ScanEntity) => { + return entity && entity.providerInfo && entity.attributes; +}; + +/** + * Creates a scan details mapping for filters from completed scans. + * Used to provide detailed information for scan filters in the UI. + */ +export const createScanDetailsMapping = ( + completedScans: ScanProps[], + providersData?: ProvidersApiResponse, +) => { + if (!completedScans || completedScans.length === 0) { + return []; + } + + const scanMappings = completedScans.map((scan: ScanProps) => { + // Get provider info from providerInfo if available, or find from providers data + let providerInfo = scan.providerInfo; + + if (!providerInfo && scan.relationships?.provider?.data?.id) { + const provider = providersData?.data?.find( + (p: ProviderProps) => p.id === scan.relationships.provider.data.id, + ); + if (provider) { + providerInfo = { + provider: provider.attributes.provider, + alias: provider.attributes.alias, + uid: provider.attributes.uid, + }; + } + } + + return { + [scan.id]: { + providerInfo: { + provider: providerInfo?.provider || "aws", + alias: providerInfo?.alias, + uid: providerInfo?.uid, + }, + attributes: { + name: scan.attributes.name, + completed_at: scan.attributes.completed_at, + }, + }, + }; + }); + + return scanMappings; +}; diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index db664e2a18..69ba6d36b4 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -7,6 +7,44 @@ import { AuthSocialProvider, MetaDataProps, PermissionInfo } from "@/types"; export const baseUrl = process.env.AUTH_URL || "http://localhost:3000"; export const apiBaseUrl = process.env.API_BASE_URL; +/** + * Extracts a form value from a FormData object + * @param formData - The FormData object to extract from + * @param field - The name of the field to extract + * @returns The value of the field + */ +export const getFormValue = (formData: FormData, field: string) => + formData.get(field); + +/** + * Filters out empty values from an object + * @param obj - Object to filter + * @returns New object with empty values removed + * Avoids sending empty values to the API + */ +export function filterEmptyValues( + obj: Record, +): Record { + return Object.fromEntries( + Object.entries(obj).filter(([_, value]) => { + // Keep number 0 and boolean false as they are valid values + if (value === 0 || value === false) return true; + + // Filter out null, undefined, empty strings, and empty arrays + if (value === null || value === undefined) return false; + if (typeof value === "string" && value.trim() === "") return false; + if (Array.isArray(value) && value.length === 0) return false; + + return true; + }), + ); +} + +/** + * Returns the authentication headers for API requests + * @param options - Optional configuration options + * @returns Authentication headers with Accept and Authorization + */ export const getAuthHeaders = async (options?: { contentType?: boolean }) => { const session = await auth(); @@ -61,14 +99,22 @@ export const downloadScanZip = async ( ) => { const result = await getExportsZip(scanId); - if (result?.success && result?.data) { + if (result?.pending) { + toast({ + title: "The report is still being generated", + description: "Please try again in a few minutes.", + }); + return; + } + + if (result?.success && result.data) { const binaryString = window.atob(result.data); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } - const blob = new Blob([bytes], { type: "application/zip" }); + const blob = new Blob([bytes], { type: "application/zip" }); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -82,11 +128,11 @@ export const downloadScanZip = async ( title: "Download Complete", description: "Your scan report has been downloaded successfully.", }); - } else if (result?.error) { + } else { toast({ variant: "destructive", title: "Download Failed", - description: result.error, + description: result?.error || "An unknown error occurred.", }); } }; @@ -95,37 +141,64 @@ export const downloadComplianceCsv = async ( scanId: string, complianceId: string, toast: ReturnType["toast"], -) => { +): Promise => { const result = await getComplianceCsv(scanId, complianceId); - if (result?.success && result?.data) { - const binaryString = window.atob(result.data); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - const blob = new Blob([bytes], { type: "text/csv" }); - - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = result.filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - + if (result?.pending) { toast({ - title: "Download Complete", - description: "The compliance report has been downloaded successfully.", + title: "The report is still being generated", + description: "Please try again in a few minutes.", }); - } else if (result?.error) { + return; + } + + if (result?.success && result.data) { + try { + const binaryString = window.atob(result.data); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + const blob = new Blob([bytes], { type: "text/csv" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = result.filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + toast({ + title: "Download Complete", + description: "The compliance report has been downloaded successfully.", + }); + } catch (error) { + toast({ + variant: "destructive", + title: "Download Failed", + description: "An error occurred while processing the file.", + }); + } + return; + } + + if (result?.error) { toast({ variant: "destructive", title: "Download Failed", description: result.error, }); + return; } + + // Unexpected case + toast({ + variant: "destructive", + title: "Download Failed", + description: "Unexpected response. Please try again later.", + }); }; export const isGoogleOAuthEnabled = @@ -136,13 +209,12 @@ export const isGithubOAuthEnabled = !!process.env.SOCIAL_GITHUB_OAUTH_CLIENT_ID && !!process.env.SOCIAL_GITHUB_OAUTH_CLIENT_SECRET; -export async function checkTaskStatus( +export const checkTaskStatus = async ( taskId: string, -): Promise<{ completed: boolean; error?: string }> { - const MAX_RETRIES = 20; // Define the maximum number of attempts before stopping the polling - const RETRY_DELAY = 1000; // Delay time between each poll (in milliseconds) - - for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + maxRetries: number = 20, + retryDelay: number = 1500, +): Promise<{ completed: boolean; error?: string }> => { + for (let attempt = 0; attempt < maxRetries; attempt++) { const task = await getTask(taskId); if (task.error) { @@ -162,7 +234,7 @@ export async function checkTaskStatus( case "scheduled": case "executing": // Continue waiting if the task is still in progress - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); + await new Promise((resolve) => setTimeout(resolve, retryDelay)); break; default: return { completed: false, error: "Unexpected task state" }; @@ -170,7 +242,7 @@ export async function checkTaskStatus( } return { completed: false, error: "Max retries exceeded" }; -} +}; export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/ui/lib/index.ts b/ui/lib/index.ts index 6e17065079..8d50d049be 100644 --- a/ui/lib/index.ts +++ b/ui/lib/index.ts @@ -1,3 +1,4 @@ +export * from "./error-mappings"; export * from "./external-urls"; export * from "./helper"; export * from "./helper-filters"; diff --git a/ui/lib/permissions.ts b/ui/lib/permissions.ts index 6e0862637d..7d1d4448e8 100644 --- a/ui/lib/permissions.ts +++ b/ui/lib/permissions.ts @@ -1,4 +1,24 @@ -import { RolePermissionAttributes } from "@/types/users/users"; +import { RolePermissionAttributes } from "@/types/users"; + +export const isUserOwnerAndHasManageAccount = ( + roles: any[], + memberships: any[], + userId: string, +): boolean => { + const isOwner = memberships.some( + (membership) => + membership.attributes.role === "owner" && + membership.relationships?.user?.data?.id === userId, + ); + + const hasManageAccount = roles.some( + (role) => + role.attributes.manage_account === true && + role.relationships?.users?.data?.some((user: any) => user.id === userId), + ); + + return isOwner && hasManageAccount; +}; /** * Get the permissions for a user role diff --git a/ui/lib/provider-credentials/build-crendentials.ts b/ui/lib/provider-credentials/build-crendentials.ts new file mode 100644 index 0000000000..2c0f6ee3de --- /dev/null +++ b/ui/lib/provider-credentials/build-crendentials.ts @@ -0,0 +1,229 @@ +import { revalidatePath } from "next/cache"; + +import { + filterEmptyValues, + getErrorMessage, + getFormValue, + parseStringify, +} from "@/lib"; +import { ProviderType } from "@/types"; + +import { ProviderCredentialFields } from "./provider-credential-fields"; + +// Helper functions for each provider type +export const buildAWSSecret = (formData: FormData, isRole: boolean) => { + if (isRole) { + const secret = { + [ProviderCredentialFields.ROLE_ARN]: getFormValue( + formData, + ProviderCredentialFields.ROLE_ARN, + ), + [ProviderCredentialFields.EXTERNAL_ID]: getFormValue( + formData, + ProviderCredentialFields.EXTERNAL_ID, + ), + [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: getFormValue( + formData, + ProviderCredentialFields.AWS_ACCESS_KEY_ID, + ), + [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: getFormValue( + formData, + ProviderCredentialFields.AWS_SECRET_ACCESS_KEY, + ), + [ProviderCredentialFields.AWS_SESSION_TOKEN]: getFormValue( + formData, + ProviderCredentialFields.AWS_SESSION_TOKEN, + ), + session_duration: + parseInt( + getFormValue( + formData, + ProviderCredentialFields.SESSION_DURATION, + ) as string, + 10, + ) || 3600, + [ProviderCredentialFields.ROLE_SESSION_NAME]: getFormValue( + formData, + ProviderCredentialFields.ROLE_SESSION_NAME, + ), + }; + return filterEmptyValues(secret); + } + + const secret = { + [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: getFormValue( + formData, + ProviderCredentialFields.AWS_ACCESS_KEY_ID, + ), + [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: getFormValue( + formData, + ProviderCredentialFields.AWS_SECRET_ACCESS_KEY, + ), + [ProviderCredentialFields.AWS_SESSION_TOKEN]: getFormValue( + formData, + ProviderCredentialFields.AWS_SESSION_TOKEN, + ), + }; + return filterEmptyValues(secret); +}; + +export const buildAzureSecret = (formData: FormData) => { + const secret = { + [ProviderCredentialFields.CLIENT_ID]: getFormValue( + formData, + ProviderCredentialFields.CLIENT_ID, + ), + [ProviderCredentialFields.CLIENT_SECRET]: getFormValue( + formData, + ProviderCredentialFields.CLIENT_SECRET, + ), + [ProviderCredentialFields.TENANT_ID]: getFormValue( + formData, + ProviderCredentialFields.TENANT_ID, + ), + }; + return filterEmptyValues(secret); +}; + +export const buildM365Secret = (formData: FormData) => { + const secret = { + ...buildAzureSecret(formData), + [ProviderCredentialFields.USER]: getFormValue( + formData, + ProviderCredentialFields.USER, + ), + [ProviderCredentialFields.PASSWORD]: getFormValue( + formData, + ProviderCredentialFields.PASSWORD, + ), + }; + return filterEmptyValues(secret); +}; + +export const buildGCPSecret = ( + formData: FormData, + isServiceAccount: boolean, +) => { + if (isServiceAccount) { + const serviceAccountKeyRaw = getFormValue( + formData, + ProviderCredentialFields.SERVICE_ACCOUNT_KEY, + ) as string; + + try { + return { + service_account_key: JSON.parse(serviceAccountKeyRaw), + }; + } catch (error) { + console.error("Invalid service account key JSON:", error); + throw new Error("Invalid service account key format"); + } + } + + const secret = { + [ProviderCredentialFields.CLIENT_ID]: getFormValue( + formData, + ProviderCredentialFields.CLIENT_ID, + ), + [ProviderCredentialFields.CLIENT_SECRET]: getFormValue( + formData, + ProviderCredentialFields.CLIENT_SECRET, + ), + [ProviderCredentialFields.REFRESH_TOKEN]: getFormValue( + formData, + ProviderCredentialFields.REFRESH_TOKEN, + ), + }; + return filterEmptyValues(secret); +}; + +export const buildKubernetesSecret = (formData: FormData) => { + const secret = { + [ProviderCredentialFields.KUBECONFIG_CONTENT]: getFormValue( + formData, + ProviderCredentialFields.KUBECONFIG_CONTENT, + ), + }; + return filterEmptyValues(secret); +}; + +// Main function to build secret configuration +export const buildSecretConfig = ( + formData: FormData, + providerType: ProviderType, +) => { + const isRole = formData.get(ProviderCredentialFields.ROLE_ARN) !== null; + const isServiceAccount = + formData.get(ProviderCredentialFields.SERVICE_ACCOUNT_KEY) !== null; + + const secretBuilders = { + aws: () => ({ + secretType: isRole ? "role" : "static", + secret: buildAWSSecret(formData, isRole), + }), + azure: () => ({ + secretType: "static", + secret: buildAzureSecret(formData), + }), + m365: () => ({ + secretType: "static", + secret: buildM365Secret(formData), + }), + gcp: () => ({ + secretType: isServiceAccount ? "service_account" : "static", + secret: buildGCPSecret(formData, isServiceAccount), + }), + kubernetes: () => ({ + secretType: "static", + secret: buildKubernetesSecret(formData), + }), + }; + + const builder = secretBuilders[providerType]; + if (!builder) { + throw new Error(`Unsupported provider type: ${providerType}`); + } + + return builder(); +}; + +// Helper function to build secret for update (reuses existing logic) +export const buildUpdateSecretConfig = ( + formData: FormData, + providerType: ProviderType, +) => { + // Reuse the same secret building logic as add, but only return the secret + const { secret } = buildSecretConfig(formData, providerType); + + // Handle special case for M365 password field inconsistency + if (providerType === "m365") { + return { + ...secret, + password: formData.get(ProviderCredentialFields.PASSWORD), + }; + } + + return secret; +}; + +// Helper function to handle API responses consistently +export const handleApiResponse = async ( + response: Response, + pathToRevalidate?: string, +) => { + const data = await response.json(); + + if (pathToRevalidate) { + revalidatePath(pathToRevalidate); + } + + return parseStringify(data); +}; + +// Helper function to handle API errors consistently +export const handleApiError = (error: unknown) => { + console.error(error); + return { + error: getErrorMessage(error), + }; +}; diff --git a/ui/lib/provider-credentials/provider-credential-fields.ts b/ui/lib/provider-credentials/provider-credential-fields.ts new file mode 100644 index 0000000000..5cbd6605b9 --- /dev/null +++ b/ui/lib/provider-credentials/provider-credential-fields.ts @@ -0,0 +1,64 @@ +/** + * Centralized credential field names to avoid hardcoded strings + * and provide type safety across the application + */ + +// Provider credential field names +export const ProviderCredentialFields = { + CREDENTIALS_TYPE: "credentials_type", + CREDENTIALS_TYPE_AWS: "aws-sdk-default", + CREDENTIALS_TYPE_ACCESS_SECRET_KEY: "access-secret-key", + // Base fields for all providers + PROVIDER_ID: "providerId", + PROVIDER_TYPE: "providerType", + PROVIDER_ALIAS: "providerAlias", + + // AWS fields + AWS_ACCESS_KEY_ID: "aws_access_key_id", + AWS_SECRET_ACCESS_KEY: "aws_secret_access_key", + AWS_SESSION_TOKEN: "aws_session_token", + ROLE_ARN: "role_arn", + EXTERNAL_ID: "external_id", + SESSION_DURATION: "session_duration", + ROLE_SESSION_NAME: "role_session_name", + + // Azure/M365 fields + CLIENT_ID: "client_id", + CLIENT_SECRET: "client_secret", + TENANT_ID: "tenant_id", + USER: "user", + PASSWORD: "password", + + // GCP fields + REFRESH_TOKEN: "refresh_token", + SERVICE_ACCOUNT_KEY: "service_account_key", + + // Kubernetes fields + KUBECONFIG_CONTENT: "kubeconfig_content", +} as const; + +// Type for credential field values +export type ProviderCredentialField = + (typeof ProviderCredentialFields)[keyof typeof ProviderCredentialFields]; + +// API error pointer paths +export const ErrorPointers = { + // Secret fields + AWS_ACCESS_KEY_ID: "/data/attributes/secret/aws_access_key_id", + AWS_SECRET_ACCESS_KEY: "/data/attributes/secret/aws_secret_access_key", + AWS_SESSION_TOKEN: "/data/attributes/secret/aws_session_token", + CLIENT_ID: "/data/attributes/secret/client_id", + CLIENT_SECRET: "/data/attributes/secret/client_secret", + USER: "/data/attributes/secret/user", + PASSWORD: "/data/attributes/secret/password", + TENANT_ID: "/data/attributes/secret/tenant_id", + KUBECONFIG_CONTENT: "/data/attributes/secret/kubeconfig_content", + REFRESH_TOKEN: "/data/attributes/secret/refresh_token", + ROLE_ARN: "/data/attributes/secret/role_arn", + EXTERNAL_ID: "/data/attributes/secret/external_id", + SESSION_DURATION: "/data/attributes/secret/session_duration", + ROLE_SESSION_NAME: "/data/attributes/secret/role_session_name", + SERVICE_ACCOUNT_KEY: "/data/attributes/secret/service_account_key", +} as const; + +export type ErrorPointer = (typeof ErrorPointers)[keyof typeof ErrorPointers]; diff --git a/ui/lib/provider-helpers.ts b/ui/lib/provider-helpers.ts new file mode 100644 index 0000000000..d1247c3eb4 --- /dev/null +++ b/ui/lib/provider-helpers.ts @@ -0,0 +1,40 @@ +import { + ProviderEntity, + ProviderProps, + ProvidersApiResponse, +} from "@/types/providers"; + +export const extractProviderUIDs = ( + providersData: ProvidersApiResponse, +): string[] => { + if (!providersData?.data) return []; + + return Array.from( + new Set( + providersData.data + .map((provider: ProviderProps) => provider.attributes?.uid) + .filter(Boolean), + ), + ); +}; + +export const createProviderDetailsMapping = ( + providerUIDs: string[], + providersData: ProvidersApiResponse, +): Array<{ [uid: string]: ProviderEntity }> => { + if (!providersData?.data) return []; + + return providerUIDs.map((uid) => { + const provider = providersData.data.find( + (p: { attributes: { uid: string } }) => p.attributes?.uid === uid, + ); + + return { + [uid]: { + provider: provider?.attributes?.provider || "aws", + uid: uid, + alias: provider?.attributes?.alias ?? null, + }, + }; + }); +}; diff --git a/ui/package-lock.json b/ui/package-lock.json index b00fb5b597..623a48e85c 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -22,6 +22,7 @@ "@radix-ui/react-toast": "^1.2.4", "@react-aria/ssr": "3.9.4", "@react-aria/visually-hidden": "3.8.12", + "@tailwindcss/typography": "^0.5.16", "@tanstack/react-table": "^8.19.3", "add": "^2.0.6", "alert": "^6.0.2", @@ -36,13 +37,14 @@ "jose": "^5.9.3", "jwt-decode": "^4.0.0", "lucide-react": "^0.471.0", - "next": "^14.2.26", + "next": "14.2.29", "next-auth": "^5.0.0-beta.25", "next-themes": "^0.2.1", "radix-ui": "^1.1.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.52.2", + "react-markdown": "^10.1.0", "recharts": "^2.15.2", "server-only": "^0.0.1", "shadcn-ui": "^0.2.3", @@ -986,9 +988,9 @@ } }, "node_modules/@next/env": { - "version": "14.2.27", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.27.tgz", - "integrity": "sha512-VLGHu7aBMK0rmSEPjx6qb4njGYfEfN5HpeYV32II1dNZZvPxqa+RfWVgPf4q6hmicavceAGeQneTxofx7Zm/yw==", + "version": "14.2.29", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.29.tgz", + "integrity": "sha512-UzgLR2eBfhKIQt0aJ7PWH7XRPYw7SXz0Fpzdl5THjUnvxy4kfBk9OU4RNPNiETewEEtaBcExNFNn1QWH8wQTjg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1002,9 +1004,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.27", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.27.tgz", - "integrity": "sha512-WKJsKqY1f8NkwcfVbUtoTjJ5e8Q2kEFhjM7tVFA3jqesetBR2EegUTed1Ov7lZebQ7XRrphAg665egiCLc9+Iw==", + "version": "14.2.29", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.29.tgz", + "integrity": "sha512-wWtrAaxCVMejxPHFb1SK/PVV1WDIrXGs9ki0C/kUM8ubKHQm+3hU9MouUywCw8Wbhj3pewfHT2wjunLEr/TaLA==", "cpu": [ "arm64" ], @@ -1018,9 +1020,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.2.27", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.27.tgz", - "integrity": "sha512-fsXAM07rt7FQ/dpPFk+YZ8LVQ48xP37KzPFAwdQFmVzNLgizdUyNSg9zwu7l0ziMtHFBofYgBXayg9qAxIhorQ==", + "version": "14.2.29", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.29.tgz", + "integrity": "sha512-7Z/jk+6EVBj4pNLw/JQrvZVrAh9Bv8q81zCFSfvTMZ51WySyEHWVpwCEaJY910LyBftv2F37kuDPQm0w9CEXyg==", "cpu": [ "x64" ], @@ -1034,9 +1036,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.27", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.27.tgz", - "integrity": "sha512-yMIvV5nTOk4p0TDhd9DU6QswEm8YjZnr1o9ZI7A6jh25JHvPsIvjgyTVSlnrGFKlNNtOOJCIv5mwVU7+4VZvsg==", + "version": "14.2.29", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.29.tgz", + "integrity": "sha512-o6hrz5xRBwi+G7JFTHc+RUsXo2lVXEfwh4/qsuWBMQq6aut+0w98WEnoNwAwt7hkEqegzvazf81dNiwo7KjITw==", "cpu": [ "arm64" ], @@ -1050,9 +1052,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.27", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.27.tgz", - "integrity": "sha512-gpOtTwE9GUkp+VNPwTYFn1fN1UINQTulbgO8UJzBgi77g/+T+yQxfBsKUy2H96aKjoMT/AYfn3yyomNXXVoZZg==", + "version": "14.2.29", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.29.tgz", + "integrity": "sha512-9i+JEHBOVgqxQ92HHRFlSW1EQXqa/89IVjtHgOqsShCcB/ZBjTtkWGi+SGCJaYyWkr/lzu51NTMCfKuBf7ULNw==", "cpu": [ "arm64" ], @@ -1066,9 +1068,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.27", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.27.tgz", - "integrity": "sha512-b7bogYfYyutEhyDST5qpBkmVENZz1mVOO2632KerJjwWgr2cdycAPU33VmpTVvTs/fT8+oeoUuZ31aQV6cUhpw==", + "version": "14.2.29", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.29.tgz", + "integrity": "sha512-B7JtMbkUwHijrGBOhgSQu2ncbCYq9E7PZ7MX58kxheiEOwdkM+jGx0cBb+rN5AeqF96JypEppK6i/bEL9T13lA==", "cpu": [ "x64" ], @@ -1082,9 +1084,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.27", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.27.tgz", - "integrity": "sha512-yGZX038wBDSRJ7tbZ6OFZtCUvLXZDVpw9rEgNUK/0PL++65hENaiMXhxJtupeCFqzHdMuJwrCnEW29saF4NmEw==", + "version": "14.2.29", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.29.tgz", + "integrity": "sha512-yCcZo1OrO3aQ38B5zctqKU1Z3klOohIxug6qdiKO3Q3qNye/1n6XIs01YJ+Uf+TdpZQ0fNrOQI2HrTLF3Zprnw==", "cpu": [ "x64" ], @@ -1098,9 +1100,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.27", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.27.tgz", - "integrity": "sha512-yRY9RzPpk+Jex4DthdKja8C3evh6jB+22AeVd6yTbcnGAFGXQWJTs6DfEcEfeFpqIEcIGbWYq7pinz6vRv7tJA==", + "version": "14.2.29", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.29.tgz", + "integrity": "sha512-WnrfeOEtTVidI9Z6jDLy+gxrpDcEJtZva54LYC0bSKQqmyuHzl0ego+v0F/v2aXq0am67BRqo/ybmmt45Tzo4A==", "cpu": [ "arm64" ], @@ -1114,9 +1116,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.27", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.27.tgz", - "integrity": "sha512-VSqDGpoKoUgkE2Ba4/89ZchktDOwPhKy3HgQgTf3gOhK/ZEibujLJN4qzp3rSIadn4cXUID3PmuEEM6uRPA7nQ==", + "version": "14.2.29", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.29.tgz", + "integrity": "sha512-vkcriFROT4wsTdSeIzbxaZjTNTFKjSYmLd8q/GVH3Dn8JmYjUKOuKXHK8n+lovW/kdcpIvydO5GtN+It2CvKWA==", "cpu": [ "ia32" ], @@ -1130,9 +1132,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.27", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.27.tgz", - "integrity": "sha512-bK46G4uS5SVlq88FnxyupRuuaCfHPtB/7heBRAZCiHD9GdVktadjiQPCzlXWTKgv6pJxP8bE7J9GGDwodtn9Mw==", + "version": "14.2.29", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.29.tgz", + "integrity": "sha512-iPPwUEKnVs7pwR0EBLJlwxLD7TTHWS/AoVZx1l9ZQzfQciqaFEr5AlYzA2uB6Fyby1IF18t4PL0nTpB+k4Tzlw==", "cpu": [ "x64" ], @@ -7418,6 +7420,34 @@ "tslib": "^2.4.0" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", + "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", + "license": "MIT", + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@tanstack/react-table": { "version": "8.19.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.19.3.tgz", @@ -7539,6 +7569,39 @@ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -7560,6 +7623,21 @@ "@types/lodash": "*" } }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.5.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.7.tgz", @@ -7569,14 +7647,12 @@ "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "devOptional": true + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, "node_modules/@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -7591,6 +7667,12 @@ "@types/react": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -7785,8 +7867,7 @@ "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" }, "node_modules/acorn": { "version": "8.12.1", @@ -8178,6 +8259,16 @@ "deep-equal": "^2.0.5" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -8368,6 +8459,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -8384,6 +8485,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -8982,6 +9123,16 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -9269,6 +9420,19 @@ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" }, + "node_modules/decode-named-character-reference": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz", + "integrity": "sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-equal": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", @@ -9360,6 +9524,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", @@ -9374,6 +9547,19 @@ "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -10352,6 +10538,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -10390,6 +10586,12 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -10941,6 +11143,56 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/https-proxy-agent": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-6.2.1.tgz", @@ -11055,6 +11307,12 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", @@ -11088,6 +11346,30 @@ "tslib": "^2.4.0" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-arguments": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", @@ -11235,6 +11517,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -11289,6 +11581,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-interactive": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", @@ -11356,6 +11658,18 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -11887,6 +12201,12 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -11902,6 +12222,12 @@ "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, "node_modules/lodash.kebabcase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz", @@ -11915,8 +12241,7 @@ "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, "node_modules/lodash.omit": { "version": "4.5.0", @@ -12078,6 +12403,16 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -12105,6 +12440,159 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -12118,6 +12606,448 @@ "node": ">= 8" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -12239,12 +13169,12 @@ "dev": true }, "node_modules/next": { - "version": "14.2.27", - "resolved": "https://registry.npmjs.org/next/-/next-14.2.27.tgz", - "integrity": "sha512-xmTsnu6rbXVaupRmU2k3BHVpQp7kK+/Ge9XYZlwXQNS2IPP/U9ToDoO6tTSzZIV36fmDBnwKqBUd7KuFXTi0Mw==", + "version": "14.2.29", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.29.tgz", + "integrity": "sha512-s98mCOMOWLGGpGOfgKSnleXLuegvvH415qtRZXpSp00HeEgdmrxmwL9cgKU+h4XrhB16zEI5d/7BnkS3ATInsA==", "license": "MIT", "dependencies": { - "@next/env": "14.2.27", + "@next/env": "14.2.29", "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", @@ -12259,15 +13189,15 @@ "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.2.27", - "@next/swc-darwin-x64": "14.2.27", - "@next/swc-linux-arm64-gnu": "14.2.27", - "@next/swc-linux-arm64-musl": "14.2.27", - "@next/swc-linux-x64-gnu": "14.2.27", - "@next/swc-linux-x64-musl": "14.2.27", - "@next/swc-win32-arm64-msvc": "14.2.27", - "@next/swc-win32-ia32-msvc": "14.2.27", - "@next/swc-win32-x64-msvc": "14.2.27" + "@next/swc-darwin-arm64": "14.2.29", + "@next/swc-darwin-x64": "14.2.29", + "@next/swc-linux-arm64-gnu": "14.2.29", + "@next/swc-linux-arm64-musl": "14.2.29", + "@next/swc-linux-x64-gnu": "14.2.29", + "@next/swc-linux-x64-musl": "14.2.29", + "@next/swc-win32-arm64-msvc": "14.2.29", + "@next/swc-win32-ia32-msvc": "14.2.29", + "@next/swc-win32-x64-msvc": "14.2.29" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -12741,6 +13671,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -13174,6 +14129,16 @@ "react-is": "^16.13.1" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -13317,6 +14282,33 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-remove-scroll": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz", @@ -13572,6 +14564,39 @@ "url": "https://github.com/sponsors/mysticatea" } }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -14142,6 +15167,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stdin-discarder": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz", @@ -14338,6 +15373,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -14392,6 +15441,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz", + "integrity": "sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.8" + } + }, + "node_modules/style-to-object": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", + "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, "node_modules/styled-jsx": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", @@ -14603,6 +15670,26 @@ "node": ">=8.0" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -14771,6 +15858,93 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -14946,6 +16120,34 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -15235,6 +16437,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/ui/package.json b/ui/package.json index 5d16567d19..9d14e01b8a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-toast": "^1.2.4", "@react-aria/ssr": "3.9.4", "@react-aria/visually-hidden": "3.8.12", + "@tailwindcss/typography": "^0.5.16", "@tanstack/react-table": "^8.19.3", "add": "^2.0.6", "alert": "^6.0.2", @@ -28,13 +29,14 @@ "jose": "^5.9.3", "jwt-decode": "^4.0.0", "lucide-react": "^0.471.0", - "next": "^14.2.26", + "next": "14.2.29", "next-auth": "^5.0.0-beta.25", "next-themes": "^0.2.1", "radix-ui": "^1.1.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.52.2", + "react-markdown": "^10.1.0", "recharts": "^2.15.2", "server-only": "^0.0.1", "shadcn-ui": "^0.2.3", diff --git a/ui/public/ens.png b/ui/public/ens.png new file mode 100644 index 0000000000..c3e6433f31 Binary files /dev/null and b/ui/public/ens.png differ diff --git a/ui/styles/globals.css b/ui/styles/globals.css index 3d8d40186d..f3b0a3e4ae 100644 --- a/ui/styles/globals.css +++ b/ui/styles/globals.css @@ -32,6 +32,7 @@ } @layer utilities { + /* Hide scrollbar */ .no-scrollbar { scrollbar-width: none; @@ -41,3 +42,13 @@ @apply mr-2 bg-background; } } + +@layer components { + + .animate-download-icon polyline, + .animate-download-icon line { + @apply animate-drop-arrow; + transform-box: fill-box; + transform-origin: center; + } +} \ No newline at end of file diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js index 8245f1971f..503418fa2c 100644 --- a/ui/tailwind.config.js +++ b/ui/tailwind.config.js @@ -170,10 +170,16 @@ module.exports = { "50%": { left: "20%", width: "80%" }, "100%": { left: "100%", width: "100%" }, }, + dropArrow: { + "0%": { transform: "translateY(-8px)", opacity: "0" }, + "50%": { opacity: "1" }, + "100%": { transform: "translateY(0)", opacity: "1" }, + }, }, animation: { "collapsible-down": "collapsible-down 0.2s ease-out", "collapsible-up": "collapsible-up 0.2s ease-out", + "drop-arrow": "dropArrow 0.6s ease-out infinite", }, screens: { "3xl": "1920px", // Add breakpoint to optimize layouts for large screens. @@ -182,6 +188,7 @@ module.exports = { }, plugins: [ require("tailwindcss-animate"), + require("@tailwindcss/typography"), nextui({ themes: { dark: { diff --git a/ui/types/compliance.ts b/ui/types/compliance.ts new file mode 100644 index 0000000000..bbe0e7ce18 --- /dev/null +++ b/ui/types/compliance.ts @@ -0,0 +1,212 @@ +export type RequirementStatus = "PASS" | "FAIL" | "MANUAL" | "No findings"; + +export interface CompliancesOverview { + data: ComplianceOverviewData[]; +} + +export interface ComplianceOverviewData { + type: "compliance-requirements-status"; + id: string; + attributes: { + framework: string; + version: string; + requirements_passed: number; + requirements_failed: number; + requirements_manual: number; + total_requirements: number; + }; +} + +export interface Requirement { + name: string; + description: string; + status: RequirementStatus; + pass: number; + fail: number; + manual: number; + check_ids: string[]; + // This is to allow any key to be added to the requirement object + // because each compliance has different keys + [key: string]: string | string[] | number | object[] | undefined; +} + +export interface Control { + label: string; + pass: number; + fail: number; + manual: number; + requirements: Requirement[]; +} + +export interface Category { + name: string; + pass: number; + fail: number; + manual: number; + controls: Control[]; +} + +export interface Framework { + name: string; + pass: number; + fail: number; + manual: number; + categories: Category[]; +} + +export interface FailedSection { + name: string; + total: number; + types?: { [key: string]: number }; +} + +export interface RequirementsTotals { + pass: number; + fail: number; + manual: number; +} + +// API Responses types: +export interface ENSAttributesMetadata { + IdGrupoControl: string; + Marco: string; + Categoria: string; + DescripcionControl: string; + Tipo: string; + Nivel: string; + Dimensiones: string[]; + ModoEjecucion: string; + Dependencias: any[]; +} + +export interface ISO27001AttributesMetadata { + Category: string; + Objetive_ID: string; + Objetive_Name: string; + Check_Summary: string; +} + +export interface CISAttributesMetadata { + Section: string; + SubSection: string | null; + Profile: string; // "Level 1" or "Level 2" + AssessmentStatus: string; // "Manual" or "Automated" + Description: string; + RationaleStatement: string; + ImpactStatement: string; + RemediationProcedure: string; + AuditProcedure: string; + AdditionalInformation: string; + DefaultValue: string | null; + References: string; +} + +export interface AWSWellArchitectedAttributesMetadata { + Name: string; + WellArchitectedQuestionId: string; + WellArchitectedPracticeId: string; + Section: string; + SubSection: string; + LevelOfRisk: string; + AssessmentMethod: string; + Description: string; + ImplementationGuidanceUrl: string; +} + +export interface ThreatAttributesMetadata { + Title: string; + Section: string; + SubSection: string; + AttributeDescription: string; + AdditionalInformation: string; + LevelOfRisk: number; + Weight: number; +} + +export interface KISAAttributesMetadata { + Domain: string; + Subdomain: string; + Section: string; + AuditChecklist: string[]; + RelatedRegulations: string[]; + AuditEvidence: string[]; + NonComplianceCases: string[]; +} + +export interface MITREAttributesMetadata { + // Dynamic cloud service field - could be AWSService, GCPService, AzureService, etc. + [key: string]: string; + Category: string; // "Protect", "Detect", "Respond" + Value: string; // "Minimal", "Partial", "Significant" + Comment: string; +} + +export interface GenericAttributesMetadata { + ItemId: string; + Section: string; + SubSection: string; + SubGroup: string | null; + Service: string | null; + Type: string | null; +} + +export interface AttributesItemData { + type: "compliance-requirements-attributes"; + id: string; + attributes: { + name?: string; + framework: string; + version: string; + description: string; + // MITRE specific fields + tactics?: string[]; + subtechniques?: string[]; + platforms?: string[]; + technique_url?: string; + attributes: { + metadata: + | ENSAttributesMetadata[] + | ISO27001AttributesMetadata[] + | CISAttributesMetadata[] + | AWSWellArchitectedAttributesMetadata[] + | ThreatAttributesMetadata[] + | KISAAttributesMetadata[] + | MITREAttributesMetadata[] + | GenericAttributesMetadata[]; + check_ids: string[]; + }; + }; +} + +export interface RequirementItemData { + type: "compliance-requirements-details"; + id: string; + attributes: { + framework: string; + version: string; + description: string; + status: RequirementStatus; + }; +} + +export interface AttributesData { + data: AttributesItemData[]; +} + +export interface RequirementsData { + data: RequirementItemData[]; +} + +export interface RegionData { + name: string; + failurePercentage: number; + totalRequirements: number; + failedRequirements: number; +} + +export interface CategoryData { + name: string; + failurePercentage: number; + totalRequirements: number; + failedRequirements: number; +} diff --git a/ui/types/components.ts b/ui/types/components.ts index e45913efc7..6d9512f65e 100644 --- a/ui/types/components.ts +++ b/ui/types/components.ts @@ -1,7 +1,7 @@ import { LucideIcon } from "lucide-react"; import { SVGProps } from "react"; -import { ProviderType } from "./providers"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; export type IconSvgProps = SVGProps & { size?: number; @@ -44,18 +44,6 @@ export interface CollapseMenuButtonProps { isOpen: boolean | undefined; } -export interface SelectScanComplianceDataProps { - scans: (ScanProps & { - providerInfo: { - provider: ProviderType; - uid: string; - alias: string; - }; - })[]; - selectedScanId: string; - onSelectionChange: (selectedKey: string) => void; -} - export type NextUIVariants = | "solid" | "faded" @@ -193,60 +181,63 @@ export interface TaskDetails { }; } export type AWSCredentials = { - aws_access_key_id: string; - aws_secret_access_key: string; - aws_session_token: string; - secretName: string; - providerId: string; + [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: string; + [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: string; + [ProviderCredentialFields.AWS_SESSION_TOKEN]: string; + [ProviderCredentialFields.PROVIDER_ID]: string; }; export type AWSCredentialsRole = { - role_arn: string; - aws_access_key_id?: string; - aws_secret_access_key?: string; - aws_session_token?: string; - external_id?: string; - role_session_name?: string; - session_duration?: number; - credentials_type?: "aws-sdk-default" | "access-secret-key"; + [ProviderCredentialFields.ROLE_ARN]: string; + [ProviderCredentialFields.AWS_ACCESS_KEY_ID]?: string; + [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]?: string; + [ProviderCredentialFields.AWS_SESSION_TOKEN]?: string; + [ProviderCredentialFields.EXTERNAL_ID]?: string; + [ProviderCredentialFields.ROLE_SESSION_NAME]?: string; + [ProviderCredentialFields.SESSION_DURATION]?: number; + [ProviderCredentialFields.CREDENTIALS_TYPE]?: + | "aws-sdk-default" + | "access-secret-key"; }; export type AzureCredentials = { - client_id: string; - client_secret: string; - tenant_id: string; - secretName: string; - providerId: string; + [ProviderCredentialFields.CLIENT_ID]: string; + [ProviderCredentialFields.CLIENT_SECRET]: string; + [ProviderCredentialFields.TENANT_ID]: string; + [ProviderCredentialFields.PROVIDER_ID]: string; }; export type M365Credentials = { - client_id: string; - client_secret: string; - tenant_id: string; - user: string; - encrypted_password: string; - secretName: string; + [ProviderCredentialFields.CLIENT_ID]: string; + [ProviderCredentialFields.CLIENT_SECRET]: string; + [ProviderCredentialFields.TENANT_ID]: string; + [ProviderCredentialFields.USER]: string; + [ProviderCredentialFields.PASSWORD]: string; providerId: string; }; -export type GCPCredentials = { +export type GCPDefaultCredentials = { client_id: string; client_secret: string; refresh_token: string; - secretName: string; providerId: string; }; +export type GCPServiceAccountKey = { + [ProviderCredentialFields.SERVICE_ACCOUNT_KEY]: string; + [ProviderCredentialFields.PROVIDER_ID]: string; +}; + export type KubernetesCredentials = { - kubeconfig_content: string; - secretName: string; - providerId: string; + [ProviderCredentialFields.KUBECONFIG_CONTENT]: string; + [ProviderCredentialFields.PROVIDER_ID]: string; }; export type CredentialsFormSchema = | AWSCredentials | AzureCredentials - | GCPCredentials + | GCPDefaultCredentials + | GCPServiceAccountKey | KubernetesCredentials | M365Credentials; @@ -262,53 +253,6 @@ export interface ApiError { }; code: string; } -export interface CompliancesOverview { - links: { - first: string; - last: string; - next: string | null; - prev: string | null; - }; - data: ComplianceOverviewData[]; - meta: { - pagination: { - page: number; - pages: number; - count: number; - }; - version: string; - }; -} - -export interface ComplianceOverviewData { - type: "compliance-overviews"; - id: string; - attributes: { - inserted_at: string; - compliance_id: string; - framework: string; - version: string; - requirements_status: { - passed: number; - failed: number; - manual: number; - total: number; - }; - region: string; - provider_type: string; - }; - relationships: { - scan: { - data: { - type: "scans"; - id: string; - }; - }; - }; - links: { - self: string; - }; -} export interface InvitationProps { type: "invitations"; @@ -490,52 +434,9 @@ export interface UserProps { }[]; } -export interface ScanProps { - type: "scans"; - id: string; - attributes: { - name: string; - trigger: "scheduled" | "manual"; - state: - | "available" - | "scheduled" - | "executing" - | "completed" - | "failed" - | "cancelled"; - unique_resource_count: number; - progress: number; - scanner_args: { - only_logs?: boolean; - excluded_checks?: string[]; - aws_retries_max_attempts?: number; - } | null; - duration: number; - started_at: string; - inserted_at: string; - completed_at: string; - scheduled_at: string; - next_scan_at: string; - }; - relationships: { - provider: { - data: { - id: string; - type: "providers"; - }; - }; - task: { - data: { - id: string; - type: "tasks"; - }; - }; - }; - providerInfo?: { - provider: ProviderType; - uid: string; - alias: string; - }; +export interface FindingsResponse { + data: FindingProps[]; + meta: MetaDataProps; } export interface FindingProps { diff --git a/ui/types/filters.ts b/ui/types/filters.ts index a8fb78dde9..c66d0d4911 100644 --- a/ui/types/filters.ts +++ b/ui/types/filters.ts @@ -1,10 +1,17 @@ -import { ProviderAccountProps } from "./providers"; +import { ProviderEntity } from "./providers"; +import { ScanEntity } from "./scans"; + +export type FilterEntity = ProviderEntity | ScanEntity; export interface FilterOption { key: string; labelCheckboxGroup: string; values: string[]; - valueLabelMapping?: Array<{ [uid: string]: ProviderAccountProps }>; + valueLabelMapping?: Array<{ [uid: string]: FilterEntity }>; + index?: number; + showSelectAll?: boolean; + defaultToSelectAll?: boolean; + defaultValues?: string[]; } export interface CustomDropdownFilterProps { diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts index b0c64dfa24..5feca84f37 100644 --- a/ui/types/formSchemas.ts +++ b/ui/types/formSchemas.ts @@ -1,5 +1,9 @@ import { z } from "zod"; +import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields"; + +import { ProviderType } from "./providers"; + export const addRoleFormSchema = z.object({ name: z.string().min(1, "Name is required"), manage_users: z.boolean().default(false), @@ -40,7 +44,7 @@ export const editScanFormSchema = (currentName: string) => export const onDemandScanFormSchema = () => z.object({ - providerId: z.string(), + [ProviderCredentialFields.PROVIDER_ID]: z.string(), scanName: z.string().optional(), scannerArgs: z .object({ @@ -71,30 +75,30 @@ export const addProviderFormSchema = z z.discriminatedUnion("providerType", [ z.object({ providerType: z.literal("aws"), - providerAlias: z.string(), + [ProviderCredentialFields.PROVIDER_ALIAS]: z.string(), providerUid: z.string(), }), z.object({ providerType: z.literal("azure"), - providerAlias: z.string(), + [ProviderCredentialFields.PROVIDER_ALIAS]: z.string(), providerUid: z.string(), awsCredentialsType: z.string().optional(), }), z.object({ providerType: z.literal("m365"), - providerAlias: z.string(), + [ProviderCredentialFields.PROVIDER_ALIAS]: z.string(), providerUid: z.string(), awsCredentialsType: z.string().optional(), }), z.object({ providerType: z.literal("gcp"), - providerAlias: z.string(), + [ProviderCredentialFields.PROVIDER_ALIAS]: z.string(), providerUid: z.string(), awsCredentialsType: z.string().optional(), }), z.object({ providerType: z.literal("kubernetes"), - providerAlias: z.string(), + [ProviderCredentialFields.PROVIDER_ALIAS]: z.string(), providerUid: z.string(), awsCredentialsType: z.string().optional(), }), @@ -103,48 +107,65 @@ export const addProviderFormSchema = z export const addCredentialsFormSchema = (providerType: string) => z.object({ - secretName: z.string().optional(), - providerId: z.string(), - providerType: z.string(), + [ProviderCredentialFields.PROVIDER_ID]: z.string(), + [ProviderCredentialFields.PROVIDER_TYPE]: z.string(), ...(providerType === "aws" ? { - aws_access_key_id: z + [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: z .string() .nonempty("AWS Access Key ID is required"), - aws_secret_access_key: z + [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: z .string() .nonempty("AWS Secret Access Key is required"), - aws_session_token: z.string().optional(), + [ProviderCredentialFields.AWS_SESSION_TOKEN]: z.string().optional(), } : providerType === "azure" ? { - client_id: z.string().nonempty("Client ID is required"), - client_secret: z.string().nonempty("Client Secret is required"), - tenant_id: z.string().nonempty("Tenant ID is required"), + [ProviderCredentialFields.CLIENT_ID]: z + .string() + .nonempty("Client ID is required"), + [ProviderCredentialFields.CLIENT_SECRET]: z + .string() + .nonempty("Client Secret is required"), + [ProviderCredentialFields.TENANT_ID]: z + .string() + .nonempty("Tenant ID is required"), } : providerType === "gcp" ? { - client_id: z.string().nonempty("Client ID is required"), - client_secret: z.string().nonempty("Client Secret is required"), - refresh_token: z.string().nonempty("Refresh Token is required"), + [ProviderCredentialFields.CLIENT_ID]: z + .string() + .nonempty("Client ID is required"), + [ProviderCredentialFields.CLIENT_SECRET]: z + .string() + .nonempty("Client Secret is required"), + [ProviderCredentialFields.REFRESH_TOKEN]: z + .string() + .nonempty("Refresh Token is required"), } : providerType === "kubernetes" ? { - kubeconfig_content: z + [ProviderCredentialFields.KUBECONFIG_CONTENT]: z .string() .nonempty("Kubeconfig Content is required"), } : providerType === "m365" ? { - client_id: z.string().nonempty("Client ID is required"), - client_secret: z + [ProviderCredentialFields.CLIENT_ID]: z + .string() + .nonempty("Client ID is required"), + [ProviderCredentialFields.CLIENT_SECRET]: z .string() .nonempty("Client Secret is required"), - tenant_id: z.string().nonempty("Tenant ID is required"), - user: z.string().nonempty("User is required"), - encrypted_password: z + [ProviderCredentialFields.TENANT_ID]: z .string() - .nonempty("Encrypted Password is required"), + .nonempty("Tenant ID is required"), + [ProviderCredentialFields.USER]: z + .string() + .nonempty("User is required"), + [ProviderCredentialFields.PASSWORD]: z + .string() + .nonempty("Password is required"), } : {}), }); @@ -153,24 +174,30 @@ export const addCredentialsRoleFormSchema = (providerType: string) => providerType === "aws" ? z .object({ - providerId: z.string(), - providerType: z.string(), - role_arn: z.string().nonempty("AWS Role ARN is required"), - external_id: z.string().optional(), - aws_access_key_id: z.string().optional(), - aws_secret_access_key: z.string().optional(), - aws_session_token: z.string().optional(), - session_duration: z.string().optional(), - role_session_name: z.string().optional(), - credentials_type: z.string().optional(), + [ProviderCredentialFields.PROVIDER_ID]: z.string(), + [ProviderCredentialFields.PROVIDER_TYPE]: z.string(), + [ProviderCredentialFields.ROLE_ARN]: z + .string() + .nonempty("AWS Role ARN is required"), + [ProviderCredentialFields.EXTERNAL_ID]: z.string().optional(), + [ProviderCredentialFields.AWS_ACCESS_KEY_ID]: z.string().optional(), + [ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]: z + .string() + .optional(), + [ProviderCredentialFields.AWS_SESSION_TOKEN]: z.string().optional(), + [ProviderCredentialFields.SESSION_DURATION]: z.string().optional(), + [ProviderCredentialFields.ROLE_SESSION_NAME]: z.string().optional(), + [ProviderCredentialFields.CREDENTIALS_TYPE]: z.string().optional(), }) .refine( (data) => - data.credentials_type !== "access-secret-key" || - (data.aws_access_key_id && data.aws_secret_access_key), + data[ProviderCredentialFields.CREDENTIALS_TYPE] !== + "access-secret-key" || + (data[ProviderCredentialFields.AWS_ACCESS_KEY_ID] && + data[ProviderCredentialFields.AWS_SECRET_ACCESS_KEY]), { message: "AWS Access Key ID and Secret Access Key are required.", - path: ["aws_access_key_id"], + path: [ProviderCredentialFields.AWS_ACCESS_KEY_ID], }, ) : z.object({ @@ -178,15 +205,45 @@ export const addCredentialsRoleFormSchema = (providerType: string) => providerType: z.string(), }); +export const addCredentialsServiceAccountFormSchema = ( + providerType: ProviderType, +) => + providerType === "gcp" + ? z.object({ + [ProviderCredentialFields.PROVIDER_ID]: z.string(), + [ProviderCredentialFields.PROVIDER_TYPE]: z.string(), + [ProviderCredentialFields.SERVICE_ACCOUNT_KEY]: z.string().refine( + (val) => { + try { + const parsed = JSON.parse(val); + return ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ); + } catch { + return false; + } + }, + { + message: "Invalid JSON format. Please provide a valid JSON object.", + }, + ), + }) + : z.object({ + [ProviderCredentialFields.PROVIDER_ID]: z.string(), + [ProviderCredentialFields.PROVIDER_TYPE]: z.string(), + }); + export const testConnectionFormSchema = z.object({ - providerId: z.string(), + [ProviderCredentialFields.PROVIDER_ID]: z.string(), runOnce: z.boolean().default(false), }); export const launchScanFormSchema = () => z.object({ - providerId: z.string(), - providerType: z.string(), + [ProviderCredentialFields.PROVIDER_ID]: z.string(), + [ProviderCredentialFields.PROVIDER_TYPE]: z.string(), scannerArgs: z .object({ checksToExecute: z.array(z.string()).optional(), @@ -196,7 +253,7 @@ export const launchScanFormSchema = () => export const editProviderFormSchema = (currentAlias: string) => z.object({ - alias: z + [ProviderCredentialFields.PROVIDER_ALIAS]: z .string() .refine((val) => val === "" || val.length >= 3, { message: "The alias must be empty or have at least 3 characters.", @@ -205,7 +262,7 @@ export const editProviderFormSchema = (currentAlias: string) => message: "The new alias must be different from the current one.", }) .optional(), - providerId: z.string(), + [ProviderCredentialFields.PROVIDER_ID]: z.string(), }); export const editInviteFormSchema = z.object({ diff --git a/ui/types/index.ts b/ui/types/index.ts index e35a2815da..1483946f3e 100644 --- a/ui/types/index.ts +++ b/ui/types/index.ts @@ -3,3 +3,4 @@ export * from "./components"; export * from "./filters"; export * from "./formSchemas"; export * from "./providers"; +export * from "./scans"; diff --git a/ui/types/providers.ts b/ui/types/providers.ts index 6bac15262c..b7aae5b944 100644 --- a/ui/types/providers.ts +++ b/ui/types/providers.ts @@ -45,10 +45,10 @@ export interface ProviderProps { groupNames?: string[]; } -export interface ProviderAccountProps { +export interface ProviderEntity { provider: ProviderType; uid: string; - alias: string; + alias: string | null; } export interface ProviderOverviewProps { @@ -71,3 +71,27 @@ export interface ProviderOverviewProps { version: string; }; } + +export interface ProvidersApiResponse { + links: { + first: string; + last: string; + next: string | null; + prev: string | null; + }; + data: ProviderProps[]; + included?: Array<{ + type: string; + id: string; + attributes: any; + relationships?: any; + }>; + meta: { + pagination: { + page: number; + pages: number; + count: number; + }; + version: string; + }; +} diff --git a/ui/types/scans.ts b/ui/types/scans.ts new file mode 100644 index 0000000000..707c8d893c --- /dev/null +++ b/ui/types/scans.ts @@ -0,0 +1,61 @@ +import { ProviderType } from "./providers"; + +export interface ScanProps { + type: "scans"; + id: string; + attributes: { + name: string; + trigger: "scheduled" | "manual"; + state: + | "available" + | "scheduled" + | "executing" + | "completed" + | "failed" + | "cancelled"; + unique_resource_count: number; + progress: number; + scanner_args: { + only_logs?: boolean; + excluded_checks?: string[]; + aws_retries_max_attempts?: number; + } | null; + duration: number; + started_at: string; + inserted_at: string; + completed_at: string; + scheduled_at: string; + next_scan_at: string; + }; + relationships: { + provider: { + data: { + id: string; + type: "providers"; + }; + }; + task: { + data: { + id: string; + type: "tasks"; + }; + }; + }; + providerInfo?: { + provider: ProviderType; + uid: string; + alias: string; + }; +} + +export interface ScanEntity { + providerInfo: { + provider: ProviderType; + alias?: string; + uid?: string; + }; + attributes: { + name?: string; + completed_at: string; + }; +} diff --git a/ui/types/users/users.ts b/ui/types/users.ts similarity index 100% rename from ui/types/users/users.ts rename to ui/types/users.ts diff --git a/ui/types/users/index.ts b/ui/types/users/index.ts deleted file mode 100644 index ddf77b4624..0000000000 --- a/ui/types/users/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./users"; diff --git a/util/generate_compliance_json_from_csv_for_cis15.py b/util/generate_compliance_json_from_csv_for_cis15.py index d754323ee9..ed04202d66 100644 --- a/util/generate_compliance_json_from_csv_for_cis15.py +++ b/util/generate_compliance_json_from_csv_for_cis15.py @@ -4,7 +4,7 @@ import sys # Convert a CSV file following the CIS 1.5 AWS benchmark into a Prowler v3.0 Compliance JSON file # CSV fields: -# Id, Title,Checks,Attributes_Section,Attributes_Level,Attributes_AssessmentStatus,Attributes_Description,Attributes_RationalStatement,Attributes_ImpactStatement,Attributes_RemediationProcedure,Attributes_AuditProcedure,Attributes_AdditionalInformation,Attributes_References +# ID Title Check Section # SubSection Profile Assessment Status Description Rationale Statement Impact Statement Remediation Procedure Audit Procedure Additional Information References Default Value # get the CSV filename to convert from file_name = sys.argv[1] @@ -14,18 +14,36 @@ output = {"Framework": "CIS-AWS", "Version": "1.5", "Requirements": []} with open(file_name, newline="", encoding="utf-8") as f: reader = csv.reader(f, delimiter=",") for row in reader: - attribute = { - "Section": row[3], - "Profile": row[4], - "AssessmentStatus": row[5], - "Description": row[6], - "RationaleStatement": row[7], - "ImpactStatement": row[8], - "RemediationProcedure": row[9], - "AuditProcedure": row[10], - "AdditionalInformation": row[11], - "References": row[12], - } + if len(row[4]) > 0: + attribute = { + "Section": row[3], + "SubSection": row[4], + "Profile": row[5], + "AssessmentStatus": row[6], + "Description": row[7], + "RationaleStatement": row[8], + "ImpactStatement": row[9], + "RemediationProcedure": row[10], + "AuditProcedure": row[11], + "AdditionalInformation": row[12], + "References": row[13], + "DefaultValue": row[14], + } + else: + attribute = { + "Section": row[3], + "Profile": row[5], + "AssessmentStatus": row[6], + "Description": row[7], + "RationaleStatement": row[8], + "ImpactStatement": row[9], + "RemediationProcedure": row[10], + "AuditProcedure": row[11], + "AdditionalInformation": row[12], + "References": row[13], + "DefaultValue": row[14], + } + output["Requirements"].append( { "Id": row[0],