diff --git a/.env b/.env index 048fce1831..447fd3c1b4 100644 --- a/.env +++ b/.env @@ -11,6 +11,9 @@ AUTH_TRUST_HOST=true UI_PORT=3000 # openssl rand -base64 32 AUTH_SECRET="N/c6mnaS5+SWq81+819OrzQZlmx1Vxtp/orjttJSmw8=" +# Google Tag Manager ID +NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID="" + #### Prowler API Configuration #### PROWLER_API_VERSION="stable" diff --git a/.github/labeler.yml b/.github/labeler.yml index f986a1b00c..5109f0f22b 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -27,6 +27,11 @@ provider/github: - any-glob-to-any-file: "prowler/providers/github/**" - any-glob-to-any-file: "tests/providers/github/**" +provider/iac: + - changed-files: + - any-glob-to-any-file: "prowler/providers/iac/**" + - any-glob-to-any-file: "tests/providers/iac/**" + github_actions: - changed-files: - any-glob-to-any-file: ".github/workflows/*" diff --git a/.github/workflows/api-pull-request.yml b/.github/workflows/api-pull-request.yml index 82a9fb8c60..b64fbe6826 100644 --- a/.github/workflows/api-pull-request.yml +++ b/.github/workflows/api-pull-request.yml @@ -169,8 +169,9 @@ jobs: - name: Safety working-directory: ./api if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true' + # 76352, 76353, 77323 come from SDK, but they cannot upgrade it yet. It does not affect API run: | - poetry run safety check --ignore 70612,66963,74429 + poetry run safety check --ignore 70612,66963,74429,76352,76353,77323 - name: Vulture working-directory: ./api diff --git a/.github/workflows/pull-request-check-changelog.yml b/.github/workflows/pull-request-check-changelog.yml new file mode 100644 index 0000000000..d0c41aad1d --- /dev/null +++ b/.github/workflows/pull-request-check-changelog.yml @@ -0,0 +1,86 @@ +name: Check Changelog + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +jobs: + check-changelog: + if: contains(github.event.pull_request.labels.*.name, 'no-changelog') == false + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + pull-requests: write + env: + MONITORED_FOLDERS: "api ui prowler" + + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Get list of changed files + id: changed_files + run: | + git fetch origin ${{ github.base_ref }} + git diff --name-only origin/${{ github.base_ref }}...HEAD > changed_files.txt + cat changed_files.txt + + - name: Check for folder changes and changelog presence + id: check_folders + run: | + missing_changelogs="" + + for folder in $MONITORED_FOLDERS; do + if grep -q "^${folder}/" changed_files.txt; then + echo "Detected changes in ${folder}/" + if ! grep -q "^${folder}/CHANGELOG.md$" changed_files.txt; then + echo "No changelog update found for ${folder}/" + missing_changelogs="${missing_changelogs}- \`${folder}\`\n" + fi + fi + done + + echo "missing_changelogs<> $GITHUB_OUTPUT + echo -e "${missing_changelogs}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Find existing changelog comment + if: github.event.pull_request.head.repo.full_name == github.repository + id: find_comment + uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e #v3.1.0 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: '' + + - name: Comment on PR if changelog is missing + if: github.event.pull_request.head.repo.full_name == github.repository && steps.check_folders.outputs.missing_changelogs != '' + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find_comment.outputs.comment-id }} + body: | + + ⚠️ **Changes detected in the following folders without a corresponding update to the `CHANGELOG.md`:** + + ${{ steps.check_folders.outputs.missing_changelogs }} + + Please add an entry to the corresponding `CHANGELOG.md` file to maintain a clear history of changes. + + - name: Comment on PR if all changelogs are present + if: github.event.pull_request.head.repo.full_name == github.repository && steps.check_folders.outputs.missing_changelogs == '' + uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find_comment.outputs.comment-id }} + body: | + + ✅ All necessary `CHANGELOG.md` files have been updated. Great job! 🎉 + + - name: Fail if changelog is missing + if: steps.check_folders.outputs.missing_changelogs != '' + run: | + echo "ERROR: Missing changelog updates in some folders." + exit 1 diff --git a/.github/workflows/sdk-pull-request.yml b/.github/workflows/sdk-pull-request.yml index 61768af5ef..646b72966e 100644 --- a/.github/workflows/sdk-pull-request.yml +++ b/.github/workflows/sdk-pull-request.yml @@ -212,6 +212,21 @@ jobs: run: | poetry run pytest -n auto --cov=./prowler/providers/m365 --cov-report=xml:m365_coverage.xml tests/providers/m365 + # Test IaC + - name: IaC - Check if any file has changed + id: iac-changed-files + uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c # v46.0.5 + with: + files: | + ./prowler/providers/iac/** + ./tests/providers/iac/** + .poetry.lock + + - name: IaC - Test + if: steps.iac-changed-files.outputs.any_changed == 'true' + run: | + poetry run pytest -n auto --cov=./prowler/providers/iac --cov-report=xml:iac_coverage.xml tests/providers/iac + # Common Tests - name: Lib - Test if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true' diff --git a/.github/workflows/ui-pull-request.yml b/.github/workflows/ui-pull-request.yml index f217f31c3b..847c423780 100644 --- a/.github/workflows/ui-pull-request.yml +++ b/.github/workflows/ui-pull-request.yml @@ -34,15 +34,64 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ matrix.node-version }} + cache: 'npm' + cache-dependency-path: './ui/package-lock.json' - name: Install dependencies working-directory: ./ui - run: npm install + run: npm ci - name: Run Healthcheck working-directory: ./ui run: npm run healthcheck - name: Build the application working-directory: ./ui run: npm run build + + e2e-tests: + runs-on: ubuntu-latest + env: + AUTH_SECRET: 'fallback-ci-secret-for-testing' + AUTH_TRUST_HOST: true + NEXTAUTH_URL: http://localhost:3000 + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '20.x' + cache: 'npm' + cache-dependency-path: './ui/package-lock.json' + - name: Install dependencies + working-directory: ./ui + run: npm ci + - name: Cache Playwright browsers + uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('ui/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-playwright- + - name: Install Playwright browsers + working-directory: ./ui + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: npm run test:e2e:install + - name: Build the application + working-directory: ./ui + run: npm run build + - name: Run Playwright tests + working-directory: ./ui + run: npm run test:e2e + - name: Upload Playwright report + uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + if: failure() + with: + name: playwright-report + path: ui/playwright-report/ + retention-days: 30 + test-container-build: runs-on: ubuntu-latest steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0382dd901d..5f13fda438 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -115,7 +115,7 @@ repos: - id: safety name: safety description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities" - entry: bash -c 'safety check --ignore 70612,66963,74429' + entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353' language: system - id: vulture diff --git a/README.md b/README.md index 5323e98135..84c661835c 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ prowler dashboard | Azure | 142 | 18 | 10 | 3 | | Kubernetes | 83 | 7 | 5 | 7 | | GitHub | 16 | 2 | 1 | 0 | -| M365 | 69 | 7 | 2 | 2 | +| M365 | 69 | 7 | 3 | 2 | | NHN (Unofficial) | 6 | 2 | 1 | 0 | > [!Note] diff --git a/api/.gitignore b/api/.gitignore deleted file mode 100644 index a215af5677..0000000000 --- a/api/.gitignore +++ /dev/null @@ -1,168 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.pyc -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal -/_data/ - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -*.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -.idea/ - -# VSCode -.vscode/ diff --git a/api/.pre-commit-config.yaml b/api/.pre-commit-config.yaml deleted file mode 100644 index 1cd04529c3..0000000000 --- a/api/.pre-commit-config.yaml +++ /dev/null @@ -1,91 +0,0 @@ -repos: - ## GENERAL - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 - hooks: - - id: check-merge-conflict - - id: check-yaml - args: ["--unsafe"] - - id: check-json - - id: end-of-file-fixer - - id: trailing-whitespace - - id: no-commit-to-branch - - id: pretty-format-json - args: ["--autofix", "--no-sort-keys", "--no-ensure-ascii"] - exclude: 'src/backend/api/fixtures/dev/.*\.json$' - - ## TOML - - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks - rev: v2.13.0 - hooks: - - id: pretty-format-toml - args: [--autofix] - files: pyproject.toml - - ## BASH - - repo: https://github.com/koalaman/shellcheck-precommit - rev: v0.10.0 - hooks: - - id: shellcheck - exclude: contrib - ## PYTHON - - repo: https://github.com/astral-sh/ruff-pre-commit - # Ruff version. - rev: v0.5.0 - hooks: - # Run the linter. - - id: ruff - args: [ --fix ] - # Run the formatter. - - id: ruff-format - - - repo: https://github.com/python-poetry/poetry - rev: 1.8.0 - hooks: - - id: poetry-check - args: ["--directory=src"] - - id: poetry-lock - args: ["--no-update", "--directory=src"] - - - repo: https://github.com/hadolint/hadolint - rev: v2.13.0-beta - hooks: - - id: hadolint - args: ["--ignore=DL3013", "Dockerfile"] - - - repo: local - hooks: - - id: pylint - name: pylint - entry: bash -c 'poetry run pylint --disable=W,C,R,E -j 0 -rn -sn src/' - language: system - files: '.*\.py' - - - id: trufflehog - name: TruffleHog - description: Detect secrets in your data. - entry: bash -c 'trufflehog --no-update git file://. --only-verified --fail' - # For running trufflehog in docker, use the following entry instead: - # entry: bash -c 'docker run -v "$(pwd):/workdir" -i --rm trufflesecurity/trufflehog:latest git file:///workdir --only-verified --fail' - language: system - stages: ["commit", "push"] - - - id: bandit - name: bandit - description: "Bandit is a tool for finding common security issues in Python code" - entry: bash -c 'poetry run bandit -q -lll -x '*_test.py,./contrib/,./.venv/' -r .' - language: system - files: '.*\.py' - - - id: safety - name: safety - description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities" - entry: bash -c 'poetry run safety check --ignore 70612,66963,74429' - language: system - - - id: vulture - name: vulture - description: "Vulture finds unused code in Python programs." - entry: bash -c 'poetry run vulture --exclude "contrib,.venv,tests,conftest.py" --min-confidence 100 .' - language: system - files: '.*\.py' diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index d886e6f8be..abc9d64413 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -8,16 +8,21 @@ All notable changes to the **Prowler API** are documented in this file. - 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) - `GET /compliance-overviews` endpoints to retrieve compliance metadata and specific requirements statuses [(#7877)](https://github.com/prowler-cloud/prowler/pull/7877) +- Lighthouse configuration support [(#7848)](https://github.com/prowler-cloud/prowler/pull/7848) ### Changed - Reworked `GET /compliance-overviews` to return proper requirement metrics [(#7877)](https://github.com/prowler-cloud/prowler/pull/7877) +### Fixed +- Scheduled scans are no longer deleted when their daily schedule run is disabled [(#8082)](https://github.com/prowler-cloud/prowler/pull/8082) + --- ## [v1.8.5] (Prowler v5.7.5) ### Fixed - Normalize provider UID to ensure safe and unique export directory paths [(#8007)](https://github.com/prowler-cloud/prowler/pull/8007). +- Blank resource types in `/metadata` endpoints [(#8027)](https://github.com/prowler-cloud/prowler/pull/8027) --- diff --git a/api/poetry.lock b/api/poetry.lock index c961c7f73f..452590b8c4 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1448,6 +1448,18 @@ files = [ graph = ["objgraph (>=1.7.2)"] profile = ["gprof2dot (>=2022.7.29)"] +[[package]] +name = "distro" +version = "1.9.0" +description = "Distro - an OS platform information API" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, + {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, +] + [[package]] name = "dj-rest-auth" version = "7.0.1" @@ -2471,6 +2483,93 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] +[[package]] +name = "jiter" +version = "0.10.0" +description = "Fast iterable JSON parser." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jiter-0.10.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:cd2fb72b02478f06a900a5782de2ef47e0396b3e1f7d5aba30daeb1fce66f303"}, + {file = "jiter-0.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32bb468e3af278f095d3fa5b90314728a6916d89ba3d0ffb726dd9bf7367285e"}, + {file = "jiter-0.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa8b3e0068c26ddedc7abc6fac37da2d0af16b921e288a5a613f4b86f050354f"}, + {file = "jiter-0.10.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:286299b74cc49e25cd42eea19b72aa82c515d2f2ee12d11392c56d8701f52224"}, + {file = "jiter-0.10.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ed5649ceeaeffc28d87fb012d25a4cd356dcd53eff5acff1f0466b831dda2a7"}, + {file = "jiter-0.10.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2ab0051160cb758a70716448908ef14ad476c3774bd03ddce075f3c1f90a3d6"}, + {file = "jiter-0.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03997d2f37f6b67d2f5c475da4412be584e1cec273c1cfc03d642c46db43f8cf"}, + {file = "jiter-0.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c404a99352d839fed80d6afd6c1d66071f3bacaaa5c4268983fc10f769112e90"}, + {file = "jiter-0.10.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:66e989410b6666d3ddb27a74c7e50d0829704ede652fd4c858e91f8d64b403d0"}, + {file = "jiter-0.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b532d3af9ef4f6374609a3bcb5e05a1951d3bf6190dc6b176fdb277c9bbf15ee"}, + {file = "jiter-0.10.0-cp310-cp310-win32.whl", hash = "sha256:da9be20b333970e28b72edc4dff63d4fec3398e05770fb3205f7fb460eb48dd4"}, + {file = "jiter-0.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:f59e533afed0c5b0ac3eba20d2548c4a550336d8282ee69eb07b37ea526ee4e5"}, + {file = "jiter-0.10.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3bebe0c558e19902c96e99217e0b8e8b17d570906e72ed8a87170bc290b1e978"}, + {file = "jiter-0.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:558cc7e44fd8e507a236bee6a02fa17199ba752874400a0ca6cd6e2196cdb7dc"}, + {file = "jiter-0.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d613e4b379a07d7c8453c5712ce7014e86c6ac93d990a0b8e7377e18505e98d"}, + {file = "jiter-0.10.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f62cf8ba0618eda841b9bf61797f21c5ebd15a7a1e19daab76e4e4b498d515b2"}, + {file = "jiter-0.10.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:919d139cdfa8ae8945112398511cb7fca58a77382617d279556b344867a37e61"}, + {file = "jiter-0.10.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13ddbc6ae311175a3b03bd8994881bc4635c923754932918e18da841632349db"}, + {file = "jiter-0.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c440ea003ad10927a30521a9062ce10b5479592e8a70da27f21eeb457b4a9c5"}, + {file = "jiter-0.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc347c87944983481e138dea467c0551080c86b9d21de6ea9306efb12ca8f606"}, + {file = "jiter-0.10.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:13252b58c1f4d8c5b63ab103c03d909e8e1e7842d302473f482915d95fefd605"}, + {file = "jiter-0.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7d1bbf3c465de4a24ab12fb7766a0003f6f9bce48b8b6a886158c4d569452dc5"}, + {file = "jiter-0.10.0-cp311-cp311-win32.whl", hash = "sha256:db16e4848b7e826edca4ccdd5b145939758dadf0dc06e7007ad0e9cfb5928ae7"}, + {file = "jiter-0.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c9c1d5f10e18909e993f9641f12fe1c77b3e9b533ee94ffa970acc14ded3812"}, + {file = "jiter-0.10.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1e274728e4a5345a6dde2d343c8da018b9d4bd4350f5a472fa91f66fda44911b"}, + {file = "jiter-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7202ae396446c988cb2a5feb33a543ab2165b786ac97f53b59aafb803fef0744"}, + {file = "jiter-0.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23ba7722d6748b6920ed02a8f1726fb4b33e0fd2f3f621816a8b486c66410ab2"}, + {file = "jiter-0.10.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:371eab43c0a288537d30e1f0b193bc4eca90439fc08a022dd83e5e07500ed026"}, + {file = "jiter-0.10.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c675736059020365cebc845a820214765162728b51ab1e03a1b7b3abb70f74c"}, + {file = "jiter-0.10.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0c5867d40ab716e4684858e4887489685968a47e3ba222e44cde6e4a2154f959"}, + {file = "jiter-0.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395bb9a26111b60141757d874d27fdea01b17e8fac958b91c20128ba8f4acc8a"}, + {file = "jiter-0.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6842184aed5cdb07e0c7e20e5bdcfafe33515ee1741a6835353bb45fe5d1bd95"}, + {file = "jiter-0.10.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:62755d1bcea9876770d4df713d82606c8c1a3dca88ff39046b85a048566d56ea"}, + {file = "jiter-0.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:533efbce2cacec78d5ba73a41756beff8431dfa1694b6346ce7af3a12c42202b"}, + {file = "jiter-0.10.0-cp312-cp312-win32.whl", hash = "sha256:8be921f0cadd245e981b964dfbcd6fd4bc4e254cdc069490416dd7a2632ecc01"}, + {file = "jiter-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7c7d785ae9dda68c2678532a5a1581347e9c15362ae9f6e68f3fdbfb64f2e49"}, + {file = "jiter-0.10.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0588107ec8e11b6f5ef0e0d656fb2803ac6cf94a96b2b9fc675c0e3ab5e8644"}, + {file = "jiter-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cafc4628b616dc32530c20ee53d71589816cf385dd9449633e910d596b1f5c8a"}, + {file = "jiter-0.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:520ef6d981172693786a49ff5b09eda72a42e539f14788124a07530f785c3ad6"}, + {file = "jiter-0.10.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:554dedfd05937f8fc45d17ebdf298fe7e0c77458232bcb73d9fbbf4c6455f5b3"}, + {file = "jiter-0.10.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bc299da7789deacf95f64052d97f75c16d4fc8c4c214a22bf8d859a4288a1c2"}, + {file = "jiter-0.10.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5161e201172de298a8a1baad95eb85db4fb90e902353b1f6a41d64ea64644e25"}, + {file = "jiter-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e2227db6ba93cb3e2bf67c87e594adde0609f146344e8207e8730364db27041"}, + {file = "jiter-0.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:15acb267ea5e2c64515574b06a8bf393fbfee6a50eb1673614aa45f4613c0cca"}, + {file = "jiter-0.10.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:901b92f2e2947dc6dfcb52fd624453862e16665ea909a08398dde19c0731b7f4"}, + {file = "jiter-0.10.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d0cb9a125d5a3ec971a094a845eadde2db0de85b33c9f13eb94a0c63d463879e"}, + {file = "jiter-0.10.0-cp313-cp313-win32.whl", hash = "sha256:48a403277ad1ee208fb930bdf91745e4d2d6e47253eedc96e2559d1e6527006d"}, + {file = "jiter-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:75f9eb72ecb640619c29bf714e78c9c46c9c4eaafd644bf78577ede459f330d4"}, + {file = "jiter-0.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:28ed2a4c05a1f32ef0e1d24c2611330219fed727dae01789f4a335617634b1ca"}, + {file = "jiter-0.10.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14a4c418b1ec86a195f1ca69da8b23e8926c752b685af665ce30777233dfe070"}, + {file = "jiter-0.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d7bfed2fe1fe0e4dda6ef682cee888ba444b21e7a6553e03252e4feb6cf0adca"}, + {file = "jiter-0.10.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5e9251a5e83fab8d87799d3e1a46cb4b7f2919b895c6f4483629ed2446f66522"}, + {file = "jiter-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:023aa0204126fe5b87ccbcd75c8a0d0261b9abdbbf46d55e7ae9f8e22424eeb8"}, + {file = "jiter-0.10.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c189c4f1779c05f75fc17c0c1267594ed918996a231593a21a5ca5438445216"}, + {file = "jiter-0.10.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15720084d90d1098ca0229352607cd68256c76991f6b374af96f36920eae13c4"}, + {file = "jiter-0.10.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4f2fb68e5f1cfee30e2b2a09549a00683e0fde4c6a2ab88c94072fc33cb7426"}, + {file = "jiter-0.10.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce541693355fc6da424c08b7edf39a2895f58d6ea17d92cc2b168d20907dee12"}, + {file = "jiter-0.10.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31c50c40272e189d50006ad5c73883caabb73d4e9748a688b216e85a9a9ca3b9"}, + {file = "jiter-0.10.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fa3402a2ff9815960e0372a47b75c76979d74402448509ccd49a275fa983ef8a"}, + {file = "jiter-0.10.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:1956f934dca32d7bb647ea21d06d93ca40868b505c228556d3373cbd255ce853"}, + {file = "jiter-0.10.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:fcedb049bdfc555e261d6f65a6abe1d5ad68825b7202ccb9692636c70fcced86"}, + {file = "jiter-0.10.0-cp314-cp314-win32.whl", hash = "sha256:ac509f7eccca54b2a29daeb516fb95b6f0bd0d0d8084efaf8ed5dfc7b9f0b357"}, + {file = "jiter-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ed975b83a2b8639356151cef5c0d597c68376fc4922b45d0eb384ac058cfa00"}, + {file = "jiter-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa96f2abba33dc77f79b4cf791840230375f9534e5fac927ccceb58c5e604a5"}, + {file = "jiter-0.10.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bd6292a43c0fc09ce7c154ec0fa646a536b877d1e8f2f96c19707f65355b5a4d"}, + {file = "jiter-0.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:39de429dcaeb6808d75ffe9effefe96a4903c6a4b376b2f6d08d77c1aaee2f18"}, + {file = "jiter-0.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52ce124f13a7a616fad3bb723f2bfb537d78239d1f7f219566dc52b6f2a9e48d"}, + {file = "jiter-0.10.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:166f3606f11920f9a1746b2eea84fa2c0a5d50fd313c38bdea4edc072000b0af"}, + {file = "jiter-0.10.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:28dcecbb4ba402916034fc14eba7709f250c4d24b0c43fc94d187ee0580af181"}, + {file = "jiter-0.10.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86c5aa6910f9bebcc7bc4f8bc461aff68504388b43bfe5e5c0bd21efa33b52f4"}, + {file = "jiter-0.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ceeb52d242b315d7f1f74b441b6a167f78cea801ad7c11c36da77ff2d42e8a28"}, + {file = "jiter-0.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ff76d8887c8c8ee1e772274fcf8cc1071c2c58590d13e33bd12d02dc9a560397"}, + {file = "jiter-0.10.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a9be4d0fa2b79f7222a88aa488bd89e2ae0a0a5b189462a12def6ece2faa45f1"}, + {file = "jiter-0.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9ab7fd8738094139b6c1ab1822d6f2000ebe41515c537235fd45dabe13ec9324"}, + {file = "jiter-0.10.0-cp39-cp39-win32.whl", hash = "sha256:5f51e048540dd27f204ff4a87f5d79294ea0aa3aa552aca34934588cf27023cf"}, + {file = "jiter-0.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:1b28302349dc65703a9e4ead16f163b1c339efffbe1049c30a44b001a2a4fff9"}, + {file = "jiter-0.10.0.tar.gz", hash = "sha256:07a7142c38aacc85194391108dc91b5b57093c978a9932bd86a36862759d9500"}, +] + [[package]] name = "jmespath" version = "1.0.1" @@ -3371,6 +3470,33 @@ rsa = ["cryptography (>=3.0.0)"] signals = ["blinker (>=1.4.0)"] signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] +[[package]] +name = "openai" +version = "1.82.0" +description = "The official Python library for the openai API" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "openai-1.82.0-py3-none-any.whl", hash = "sha256:8c40647fea1816516cb3de5189775b30b5f4812777e40b8768f361f232b61b30"}, + {file = "openai-1.82.0.tar.gz", hash = "sha256:b0a009b9a58662d598d07e91e4219ab4b1e3d8ba2db3f173896a92b9b874d1a7"}, +] + +[package.dependencies] +anyio = ">=3.5.0,<5" +distro = ">=1.7.0,<2" +httpx = ">=0.23.0,<1" +jiter = ">=0.4.0,<1" +pydantic = ">=1.9.0,<3" +sniffio = "*" +tqdm = ">4" +typing-extensions = ">=4.11,<5" + +[package.extras] +datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"] +realtime = ["websockets (>=13,<16)"] +voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"] + [[package]] name = "opentelemetry-api" version = "1.32.1" @@ -4547,19 +4673,19 @@ typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} [[package]] name = "requests" -version = "2.32.3" +version = "2.32.4" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, ] [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" +charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<3" @@ -4808,6 +4934,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"}, @@ -4816,6 +4943,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"}, @@ -4824,6 +4952,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"}, @@ -4832,6 +4961,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"}, @@ -4840,6 +4970,7 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"}, {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"}, @@ -5221,7 +5352,7 @@ version = "4.67.1" description = "Fast, Extensible Progress Meter" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, @@ -5690,4 +5821,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.11,<3.13" -content-hash = "e25a4a8a62a8bad15ecb770bd3a6038e23ae545f55944b067816f59b3a96d5ca" +content-hash = "0750d4d8d4c0b020c87a5c6e3c459f1f5f445e6f1395f7e492adea9a901e2056" diff --git a/api/pyproject.toml b/api/pyproject.toml index fb58826690..30b1229280 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -27,7 +27,8 @@ dependencies = [ "psycopg2-binary==2.9.9", "pytest-celery[redis] (>=1.0.1,<2.0.0)", "sentry-sdk[django] (>=2.20.0,<3.0.0)", - "uuid6==2024.7.10" + "uuid6==2024.7.10", + "openai (>=1.82.0,<2.0.0)" ] description = "Prowler's API (Django/DRF)" license = "Apache-2.0" diff --git a/api/src/backend/api/adapters.py b/api/src/backend/api/adapters.py index 3cee70761f..15f690ca54 100644 --- a/api/src/backend/api/adapters.py +++ b/api/src/backend/api/adapters.py @@ -43,10 +43,20 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): 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.first_name = ( + extra.get("firstName", [""])[0] if extra.get("firstName") else "" + ) + user.last_name = ( + extra.get("lastName", [""])[0] if extra.get("lastName") else "" + ) + user.company_name = ( + extra.get("organization", [""])[0] + if extra.get("organization") + else "" + ) user.name = f"{user.first_name} {user.last_name}".strip() + if user.name == "": + user.name = "N/A" user.save(using=MainRouter.admin_db) email_domain = user.email.split("@")[-1] @@ -57,7 +67,11 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter): ) with rls_transaction(str(tenant.id)): - role_name = extra.get("userType", ["saml_default_role"])[0].strip() + role_name = ( + extra.get("userType", ["saml_default_role"])[0].strip() + if extra.get("userType") + else "saml_default_role" + ) try: role = Role.objects.using(MainRouter.admin_db).get( diff --git a/api/src/backend/api/migrations/0031_lighthouseconfiguration.py b/api/src/backend/api/migrations/0031_lighthouseconfiguration.py new file mode 100644 index 0000000000..9393bbf32f --- /dev/null +++ b/api/src/backend/api/migrations/0031_lighthouseconfiguration.py @@ -0,0 +1,107 @@ +# Generated by Django 5.1.10 on 2025-06-12 12:45 + +import uuid + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + +import api.rls + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0030_samlconfigurations"), + ] + + operations = [ + migrations.CreateModel( + name="LighthouseConfiguration", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("inserted_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "name", + models.CharField( + help_text="Name of the configuration", + max_length=100, + validators=[django.core.validators.MinLengthValidator(3)], + ), + ), + ( + "api_key", + models.BinaryField( + help_text="Encrypted API key for the LLM service" + ), + ), + ( + "model", + models.CharField( + choices=[ + ("gpt-4o-2024-11-20", "GPT-4o v2024-11-20"), + ("gpt-4o-2024-08-06", "GPT-4o v2024-08-06"), + ("gpt-4o-2024-05-13", "GPT-4o v2024-05-13"), + ("gpt-4o", "GPT-4o Default"), + ("gpt-4o-mini-2024-07-18", "GPT-4o Mini v2024-07-18"), + ("gpt-4o-mini", "GPT-4o Mini Default"), + ], + default="gpt-4o-2024-08-06", + help_text="Must be one of the supported model names", + max_length=50, + ), + ), + ( + "temperature", + models.FloatField(default=0, help_text="Must be between 0 and 1"), + ), + ( + "max_tokens", + models.IntegerField( + default=4000, help_text="Must be between 500 and 5000" + ), + ), + ( + "business_context", + models.TextField( + blank=True, + default="", + help_text="Additional business context for this AI model configuration", + ), + ), + ("is_active", models.BooleanField(default=True)), + ( + "tenant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="api.tenant" + ), + ), + ], + options={ + "db_table": "lighthouse_configurations", + "abstract": False, + "constraints": [ + models.UniqueConstraint( + fields=("tenant_id",), + name="unique_lighthouse_config_per_tenant", + ), + ], + }, + ), + migrations.AddConstraint( + model_name="lighthouseconfiguration", + constraint=api.rls.RowLevelSecurityConstraint( + "tenant_id", + name="rls_on_lighthouseconfiguration", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + ), + ] diff --git a/api/src/backend/api/migrations/0032_scan_disable_on_cascade_periodic_tasks.py b/api/src/backend/api/migrations/0032_scan_disable_on_cascade_periodic_tasks.py new file mode 100644 index 0000000000..07f3523ef2 --- /dev/null +++ b/api/src/backend/api/migrations/0032_scan_disable_on_cascade_periodic_tasks.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.10 on 2025-06-23 10:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0031_lighthouseconfiguration"), + ("django_celery_beat", "0019_alter_periodictasks_options"), + ] + + operations = [ + migrations.AlterField( + model_name="scan", + name="scheduler_task", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="django_celery_beat.periodictask", + ), + ), + ] diff --git a/api/src/backend/api/models.py b/api/src/backend/api/models.py index 2d006ff223..3f8e1264c9 100644 --- a/api/src/backend/api/models.py +++ b/api/src/backend/api/models.py @@ -1,11 +1,13 @@ import json +import logging import re import xml.etree.ElementTree as ET from uuid import UUID, uuid4 from allauth.socialaccount.models import SocialApp +from config.custom_logging import BackendLogger from config.settings.social_login import SOCIALACCOUNT_PROVIDERS -from cryptography.fernet import Fernet +from cryptography.fernet import Fernet, InvalidToken from django.conf import settings from django.contrib.auth.models import AbstractBaseUser from django.contrib.postgres.fields import ArrayField @@ -55,6 +57,8 @@ fernet = Fernet(settings.SECRETS_ENCRYPTION_KEY.encode()) # Convert Prowler Severity enum to Django TextChoices SeverityChoices = enum_to_choices(Severity) +logger = logging.getLogger(BackendLogger.API) + class StatusChoices(models.TextChoices): """ @@ -433,7 +437,7 @@ class Scan(RowLevelSecurityProtectedModel): completed_at = models.DateTimeField(null=True, blank=True) next_scan_at = models.DateTimeField(null=True, blank=True) scheduler_task = models.ForeignKey( - PeriodicTask, on_delete=models.CASCADE, null=True, blank=True + PeriodicTask, on_delete=models.SET_NULL, null=True, blank=True ) output_location = models.CharField(blank=True, null=True, max_length=200) @@ -1628,3 +1632,130 @@ class ResourceScanSummary(RowLevelSecurityProtectedModel): statements=["SELECT", "INSERT", "UPDATE", "DELETE"], ), ] + + +class LighthouseConfiguration(RowLevelSecurityProtectedModel): + """ + Stores configuration and API keys for LLM services. + """ + + class ModelChoices(models.TextChoices): + GPT_4O_2024_11_20 = "gpt-4o-2024-11-20", _("GPT-4o v2024-11-20") + GPT_4O_2024_08_06 = "gpt-4o-2024-08-06", _("GPT-4o v2024-08-06") + GPT_4O_2024_05_13 = "gpt-4o-2024-05-13", _("GPT-4o v2024-05-13") + GPT_4O = "gpt-4o", _("GPT-4o Default") + GPT_4O_MINI_2024_07_18 = "gpt-4o-mini-2024-07-18", _("GPT-4o Mini v2024-07-18") + GPT_4O_MINI = "gpt-4o-mini", _("GPT-4o Mini Default") + + id = models.UUIDField(primary_key=True, default=uuid4, editable=False) + inserted_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) + + name = models.CharField( + max_length=100, + validators=[MinLengthValidator(3)], + blank=False, + null=False, + help_text="Name of the configuration", + ) + api_key = models.BinaryField( + blank=False, null=False, help_text="Encrypted API key for the LLM service" + ) + model = models.CharField( + max_length=50, + choices=ModelChoices.choices, + blank=False, + null=False, + default=ModelChoices.GPT_4O_2024_08_06, + help_text="Must be one of the supported model names", + ) + temperature = models.FloatField(default=0, help_text="Must be between 0 and 1") + max_tokens = models.IntegerField( + default=4000, help_text="Must be between 500 and 5000" + ) + business_context = models.TextField( + blank=True, + null=False, + default="", + help_text="Additional business context for this AI model configuration", + ) + is_active = models.BooleanField(default=True) + + def __str__(self): + return self.name + + def clean(self): + super().clean() + + # Validate temperature + if not 0 <= self.temperature <= 1: + raise ModelValidationError( + detail="Temperature must be between 0 and 1", + code="invalid_temperature", + pointer="/data/attributes/temperature", + ) + + # Validate max_tokens + if not 500 <= self.max_tokens <= 5000: + raise ModelValidationError( + detail="Max tokens must be between 500 and 5000", + code="invalid_max_tokens", + pointer="/data/attributes/max_tokens", + ) + + @property + def api_key_decoded(self): + """Return the decrypted API key, or None if unavailable or invalid.""" + if not self.api_key: + return None + + try: + decrypted_key = fernet.decrypt(bytes(self.api_key)) + return decrypted_key.decode() + + except InvalidToken: + logger.warning("Invalid token while decrypting API key.") + except Exception as e: + logger.exception("Unexpected error while decrypting API key: %s", e) + + @api_key_decoded.setter + def api_key_decoded(self, value): + """Store the encrypted API key.""" + if not value: + raise ModelValidationError( + detail="API key is required", + code="invalid_api_key", + pointer="/data/attributes/api_key", + ) + + # Validate OpenAI API key format + openai_key_pattern = r"^sk-[\w-]+T3BlbkFJ[\w-]+$" + if not re.match(openai_key_pattern, value): + raise ModelValidationError( + detail="Invalid OpenAI API key format.", + code="invalid_api_key", + pointer="/data/attributes/api_key", + ) + self.api_key = fernet.encrypt(value.encode()) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + class Meta(RowLevelSecurityProtectedModel.Meta): + db_table = "lighthouse_configurations" + + constraints = [ + RowLevelSecurityConstraint( + field="tenant_id", + name="rls_on_%(class)s", + statements=["SELECT", "INSERT", "UPDATE", "DELETE"], + ), + # Add unique constraint for name within a tenant + models.UniqueConstraint( + fields=["tenant_id"], name="unique_lighthouse_config_per_tenant" + ), + ] + + class JSONAPIMeta: + resource_name = "lighthouse-configurations" diff --git a/api/src/backend/api/specs/v1.yaml b/api/src/backend/api/specs/v1.yaml index 7ea2f063c9..9a56a91f65 100644 --- a/api/src/backend/api/specs/v1.yaml +++ b/api/src/backend/api/specs/v1.yaml @@ -2877,6 +2877,180 @@ paths: schema: $ref: '#/components/schemas/OpenApiResponseResponse' description: '' + /api/v1/lighthouse-configurations: + get: + operationId: lighthouse_configurations_list + description: Retrieve a list of all Lighthouse configurations. + summary: List all Lighthouse configurations + parameters: + - in: query + name: fields[lighthouse-configurations] + schema: + type: array + items: + type: string + enum: + - name + - api_key + - model + - temperature + - max_tokens + - business_context + - is_active + - inserted_at + - updated_at + - url + 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: + - name + - -name + - inserted_at + - -inserted_at + - updated_at + - -updated_at + - is_active + - -is_active + explode: false + tags: + - Lighthouse + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PaginatedLighthouseConfigList' + description: '' + post: + operationId: lighthouse_configurations_create + description: Create a new Lighthouse configuration with the specified details. + summary: Create a new Lighthouse configuration + tags: + - Lighthouse + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/LighthouseConfigCreateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/LighthouseConfigCreateRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/LighthouseConfigCreateRequest' + required: true + security: + - jwtAuth: [] + responses: + '201': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/LighthouseConfigCreateResponse' + description: '' + /api/v1/lighthouse-configurations/{id}: + patch: + operationId: lighthouse_configurations_partial_update + description: Update certain fields of an existing Lighthouse configuration. + summary: Partially update a Lighthouse configuration + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Lighthouse + requestBody: + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/PatchedLighthouseConfigUpdateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedLighthouseConfigUpdateRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedLighthouseConfigUpdateRequest' + required: true + security: + - jwtAuth: [] + responses: + '200': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/LighthouseConfigUpdateResponse' + description: '' + delete: + operationId: lighthouse_configurations_destroy + description: Remove a Lighthouse configuration by its ID. + summary: Delete a Lighthouse configuration + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Lighthouse + security: + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/lighthouse-configurations/{id}/connection: + post: + operationId: lighthouse_configurations_connection_create + description: Verify the connection to the OpenAI API for a specific Lighthouse + configuration. + summary: Check the connection to the OpenAI API + parameters: + - in: path + name: id + schema: + type: string + required: true + tags: + - Lighthouse + security: + - jwtAuth: [] + responses: + '202': + content: + application/vnd.api+json: + schema: + $ref: '#/components/schemas/OpenApiResponseResponse' + description: '' /api/v1/overviews/findings: get: operationId: overviews_findings_retrieve @@ -8464,6 +8638,290 @@ components: $ref: '#/components/schemas/InvitationUpdate' required: - data + LighthouseConfig: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/Type4bfEnum' + 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: + name: + type: string + description: Name of the configuration + maxLength: 100 + minLength: 3 + api_key: + type: string + model: + enum: + - gpt-4o-2024-11-20 + - gpt-4o-2024-08-06 + - gpt-4o-2024-05-13 + - gpt-4o + - gpt-4o-mini-2024-07-18 + - gpt-4o-mini + type: string + description: |- + Must be one of the supported model names + + * `gpt-4o-2024-11-20` - GPT-4o v2024-11-20 + * `gpt-4o-2024-08-06` - GPT-4o v2024-08-06 + * `gpt-4o-2024-05-13` - GPT-4o v2024-05-13 + * `gpt-4o` - GPT-4o Default + * `gpt-4o-mini-2024-07-18` - GPT-4o Mini v2024-07-18 + * `gpt-4o-mini` - GPT-4o Mini Default + temperature: + type: number + format: double + description: Must be between 0 and 1 + max_tokens: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: Must be between 500 and 5000 + business_context: + type: string + description: Additional business context for this AI model configuration + is_active: + type: boolean + readOnly: true + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - name + LighthouseConfigCreate: + type: object + required: + - type + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/Type4bfEnum' + description: The [type](https://jsonapi.org/format/#document-resource-object-identification) + member is used to describe resource objects that share common attributes + and relationships. + attributes: + type: object + properties: + name: + type: string + description: Name of the configuration + maxLength: 100 + minLength: 3 + api_key: + type: string + writeOnly: true + model: + enum: + - gpt-4o-2024-11-20 + - gpt-4o-2024-08-06 + - gpt-4o-2024-05-13 + - gpt-4o + - gpt-4o-mini-2024-07-18 + - gpt-4o-mini + type: string + description: |- + Must be one of the supported model names + + * `gpt-4o-2024-11-20` - GPT-4o v2024-11-20 + * `gpt-4o-2024-08-06` - GPT-4o v2024-08-06 + * `gpt-4o-2024-05-13` - GPT-4o v2024-05-13 + * `gpt-4o` - GPT-4o Default + * `gpt-4o-mini-2024-07-18` - GPT-4o Mini v2024-07-18 + * `gpt-4o-mini` - GPT-4o Mini Default + temperature: + type: number + format: double + description: Must be between 0 and 1 + max_tokens: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: Must be between 500 and 5000 + business_context: + type: string + description: Additional business context for this AI model configuration + is_active: + type: boolean + readOnly: true + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - name + - api_key + LighthouseConfigCreateRequest: + 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: + - lighthouse-configurations + attributes: + type: object + properties: + name: + type: string + minLength: 3 + description: Name of the configuration + maxLength: 100 + api_key: + type: string + writeOnly: true + minLength: 1 + model: + enum: + - gpt-4o-2024-11-20 + - gpt-4o-2024-08-06 + - gpt-4o-2024-05-13 + - gpt-4o + - gpt-4o-mini-2024-07-18 + - gpt-4o-mini + type: string + description: |- + Must be one of the supported model names + + * `gpt-4o-2024-11-20` - GPT-4o v2024-11-20 + * `gpt-4o-2024-08-06` - GPT-4o v2024-08-06 + * `gpt-4o-2024-05-13` - GPT-4o v2024-05-13 + * `gpt-4o` - GPT-4o Default + * `gpt-4o-mini-2024-07-18` - GPT-4o Mini v2024-07-18 + * `gpt-4o-mini` - GPT-4o Mini Default + temperature: + type: number + format: double + description: Must be between 0 and 1 + max_tokens: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: Must be between 500 and 5000 + business_context: + type: string + description: Additional business context for this AI model configuration + is_active: + type: boolean + readOnly: true + inserted_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - name + - api_key + required: + - data + LighthouseConfigCreateResponse: + type: object + properties: + data: + $ref: '#/components/schemas/LighthouseConfigCreate' + required: + - data + LighthouseConfigUpdate: + type: object + required: + - type + - id + additionalProperties: false + properties: + type: + allOf: + - $ref: '#/components/schemas/Type4bfEnum' + 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: + name: + type: string + description: Name of the configuration + maxLength: 100 + minLength: 3 + api_key: + type: string + writeOnly: true + model: + enum: + - gpt-4o-2024-11-20 + - gpt-4o-2024-08-06 + - gpt-4o-2024-05-13 + - gpt-4o + - gpt-4o-mini-2024-07-18 + - gpt-4o-mini + type: string + description: |- + Must be one of the supported model names + + * `gpt-4o-2024-11-20` - GPT-4o v2024-11-20 + * `gpt-4o-2024-08-06` - GPT-4o v2024-08-06 + * `gpt-4o-2024-05-13` - GPT-4o v2024-05-13 + * `gpt-4o` - GPT-4o Default + * `gpt-4o-mini-2024-07-18` - GPT-4o Mini v2024-07-18 + * `gpt-4o-mini` - GPT-4o Mini Default + temperature: + type: number + format: double + description: Must be between 0 and 1 + max_tokens: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: Must be between 500 and 5000 + business_context: + type: string + description: Additional business context for this AI model configuration + is_active: + type: boolean + readOnly: true + LighthouseConfigUpdateResponse: + type: object + properties: + data: + $ref: '#/components/schemas/LighthouseConfigUpdate' + required: + - data Membership: type: object required: @@ -8830,6 +9288,15 @@ components: $ref: '#/components/schemas/Invitation' required: - data + PaginatedLighthouseConfigList: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/LighthouseConfig' + required: + - data PaginatedMembershipList: type: object properties: @@ -9153,6 +9620,73 @@ components: title: roles required: - data + PatchedLighthouseConfigUpdateRequest: + 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: + - lighthouse-configurations + id: + type: string + format: uuid + attributes: + type: object + properties: + name: + type: string + minLength: 3 + description: Name of the configuration + maxLength: 100 + api_key: + type: string + writeOnly: true + minLength: 1 + model: + enum: + - gpt-4o-2024-11-20 + - gpt-4o-2024-08-06 + - gpt-4o-2024-05-13 + - gpt-4o + - gpt-4o-mini-2024-07-18 + - gpt-4o-mini + type: string + description: |- + Must be one of the supported model names + + * `gpt-4o-2024-11-20` - GPT-4o v2024-11-20 + * `gpt-4o-2024-08-06` - GPT-4o v2024-08-06 + * `gpt-4o-2024-05-13` - GPT-4o v2024-05-13 + * `gpt-4o` - GPT-4o Default + * `gpt-4o-mini-2024-07-18` - GPT-4o Mini v2024-07-18 + * `gpt-4o-mini` - GPT-4o Mini Default + temperature: + type: number + format: double + description: Must be between 0 and 1 + max_tokens: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: Must be between 500 and 5000 + business_context: + type: string + description: Additional business context for this AI model configuration + is_active: + type: boolean + readOnly: true + required: + - data PatchedProviderGroupMembershipRequest: type: object properties: @@ -12649,6 +13183,10 @@ components: type: string enum: - provider-groups + Type4bfEnum: + type: string + enum: + - lighthouse-configurations Type6bbEnum: type: string enum: @@ -12970,3 +13508,7 @@ tags: description: Endpoints for managing third-party integrations, including registration, configuration, retrieval, and deletion of integrations such as S3, JIRA, or other services. +- name: Lighthouse + description: Endpoints for managing Lighthouse configurations, including creation, + retrieval, updating, and deletion of configurations such as OpenAI keys, models, + and business context. diff --git a/api/src/backend/api/tests/test_adapters.py b/api/src/backend/api/tests/test_adapters.py index b5cf1564d7..05b4195121 100644 --- a/api/src/backend/api/tests/test_adapters.py +++ b/api/src/backend/api/tests/test_adapters.py @@ -63,6 +63,12 @@ class TestProwlerSocialAccountAdapter: adapter = ProwlerSocialAccountAdapter() request = rf.get("/") saml_sociallogin.user.email = saml_setup["email"] + saml_sociallogin.account.extra_data = { + "firstName": [], + "lastName": [], + "organization": [], + "userType": [], + } tenant = Tenant.objects.using(MainRouter.admin_db).get( id=saml_setup["tenant_id"] @@ -74,6 +80,8 @@ class TestProwlerSocialAccountAdapter: user = adapter.save_user(request, saml_sociallogin) + assert user.name == "N/A" + assert user.company_name == "" assert user.email == saml_setup["email"] assert ( Membership.objects.using(MainRouter.admin_db) diff --git a/api/src/backend/api/tests/test_sentry.py b/api/src/backend/api/tests/test_sentry.py new file mode 100644 index 0000000000..cf71593469 --- /dev/null +++ b/api/src/backend/api/tests/test_sentry.py @@ -0,0 +1,80 @@ +import logging +from unittest.mock import MagicMock + +from config.settings.sentry import before_send + + +def test_before_send_ignores_log_with_ignored_exception(): + """Test that before_send ignores logs containing ignored exceptions.""" + log_record = MagicMock() + log_record.msg = "Provider kubernetes is not connected" + log_record.levelno = logging.ERROR # 40 + + hint = {"log_record": log_record} + + event = MagicMock() + + result = before_send(event, hint) + + # Assert that the event was dropped (None returned) + assert result is None + + +def test_before_send_ignores_exception_with_ignored_exception(): + """Test that before_send ignores exceptions containing ignored exceptions.""" + exc_info = (Exception, Exception("Provider kubernetes is not connected"), None) + + hint = {"exc_info": exc_info} + + event = MagicMock() + + result = before_send(event, hint) + + # Assert that the event was dropped (None returned) + assert result is None + + +def test_before_send_passes_through_non_ignored_log(): + """Test that before_send passes through logs that don't contain ignored exceptions.""" + log_record = MagicMock() + log_record.msg = "Some other error message" + log_record.levelno = logging.ERROR # 40 + + hint = {"log_record": log_record} + + event = MagicMock() + + result = before_send(event, hint) + + # Assert that the event was passed through + assert result == event + + +def test_before_send_passes_through_non_ignored_exception(): + """Test that before_send passes through exceptions that don't contain ignored exceptions.""" + exc_info = (Exception, Exception("Some other error message"), None) + + hint = {"exc_info": exc_info} + + event = MagicMock() + + result = before_send(event, hint) + + # Assert that the event was passed through + assert result == event + + +def test_before_send_handles_warning_level(): + """Test that before_send handles warning level logs.""" + log_record = MagicMock() + log_record.msg = "Provider kubernetes is not connected" + log_record.levelno = logging.WARNING # 30 + + hint = {"log_record": log_record} + + event = MagicMock() + + result = before_send(event, hint) + + # Assert that the event was dropped (None returned) + assert result is None diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 451775bd53..8771e6a1ee 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -5820,3 +5820,335 @@ class TestTenantFinishACSView: user.name = original_name user.company_name = original_company user.save() + + +@pytest.mark.django_db +class TestLighthouseConfigViewSet: + @pytest.fixture + def valid_config_payload(self): + return { + "data": { + "type": "lighthouse-configurations", + "attributes": { + "name": "OpenAI", + "api_key": "sk-test1234567890T3BlbkFJtest1234567890", + "model": "gpt-4o", + "temperature": 0.7, + "max_tokens": 4000, + "business_context": "Test business context", + "is_active": True, + }, + } + } + + @pytest.fixture + def invalid_config_payload(self): + return { + "data": { + "type": "lighthouse-configurations", + "attributes": { + "name": "T", # Too short + "api_key": "invalid-key", # Invalid format + "model": "invalid-model", + "temperature": 2.0, # Invalid range + "max_tokens": -1, # Invalid value + }, + } + } + + def test_lighthouse_config_list(self, authenticated_client): + response = authenticated_client.get(reverse("lighthouseconfiguration-list")) + assert response.status_code == status.HTTP_200_OK + assert response.json()["data"] == [] + + def test_lighthouse_config_create(self, authenticated_client, valid_config_payload): + response = authenticated_client.post( + reverse("lighthouseconfiguration-list"), + data=valid_config_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert response.status_code == status.HTTP_201_CREATED + data = response.json()["data"] + assert ( + data["attributes"]["name"] + == valid_config_payload["data"]["attributes"]["name"] + ) + assert ( + data["attributes"]["model"] + == valid_config_payload["data"]["attributes"]["model"] + ) + assert ( + data["attributes"]["temperature"] + == valid_config_payload["data"]["attributes"]["temperature"] + ) + assert ( + data["attributes"]["max_tokens"] + == valid_config_payload["data"]["attributes"]["max_tokens"] + ) + assert ( + data["attributes"]["business_context"] + == valid_config_payload["data"]["attributes"]["business_context"] + ) + assert ( + data["attributes"]["is_active"] + == valid_config_payload["data"]["attributes"]["is_active"] + ) + # Check that API key is masked with asterisks only + masked_api_key = data["attributes"]["api_key"] + assert all( + c == "*" for c in masked_api_key + ), "API key should contain only asterisks" + + @pytest.mark.parametrize( + "field_name, invalid_value", + [ + ("name", "T"), # Too short + ("api_key", "invalid-key"), # Invalid format + ("model", "invalid-model"), # Invalid model + ("temperature", 2.0), # Out of range + ("max_tokens", -1), # Invalid value + ], + ) + def test_lighthouse_config_create_invalid_fields( + self, authenticated_client, valid_config_payload, field_name, invalid_value + ): + """Test that validation fails for various invalid field values""" + payload = valid_config_payload.copy() + payload["data"]["attributes"][field_name] = invalid_value + + response = authenticated_client.post( + reverse("lighthouseconfiguration-list"), + data=payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + errors = response.json()["errors"] + + # All field validation errors now follow the same pattern + assert any(field_name in error["source"]["pointer"] for error in errors) + + def test_lighthouse_config_create_missing_required_fields( + self, authenticated_client + ): + """Test that validation fails when required fields are missing""" + payload = {"data": {"type": "lighthouse-configurations", "attributes": {}}} + + response = authenticated_client.post( + reverse("lighthouseconfiguration-list"), + data=payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + errors = response.json()["errors"] + # Check for required fields + required_fields = ["name", "api_key"] + for field in required_fields: + assert any(field in error["source"]["pointer"] for error in errors) + + def test_lighthouse_config_create_duplicate( + self, authenticated_client, valid_config_payload + ): + # Create first config + response = authenticated_client.post( + reverse("lighthouseconfiguration-list"), + data=valid_config_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert response.status_code == status.HTTP_201_CREATED + + # Try to create second config for same tenant + response = authenticated_client.post( + reverse("lighthouseconfiguration-list"), + data=valid_config_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert ( + "Lighthouse configuration already exists for this tenant" + in response.json()["errors"][0]["detail"] + ) + + def test_lighthouse_config_update( + self, authenticated_client, lighthouse_config_fixture + ): + update_payload = { + "data": { + "type": "lighthouse-configurations", + "id": str(lighthouse_config_fixture.id), + "attributes": { + "name": "Updated Config", + "model": "gpt-4o-mini", + "temperature": 0.5, + }, + } + } + response = authenticated_client.patch( + reverse( + "lighthouseconfiguration-detail", + kwargs={"pk": lighthouse_config_fixture.id}, + ), + data=update_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert data["attributes"]["name"] == "Updated Config" + assert data["attributes"]["model"] == "gpt-4o-mini" + assert data["attributes"]["temperature"] == 0.5 + + @pytest.mark.parametrize( + "field_name, invalid_value", + [ + ("model", "invalid-model"), # Invalid model name + ("temperature", 2.5), # Temperature too high + ("temperature", -0.5), # Temperature too low + ("max_tokens", -1), # Negative max tokens + ("max_tokens", 100000), # Max tokens too high + ("name", "T"), # Name too short + ("api_key", "invalid-key"), # Invalid API key format + ], + ) + def test_lighthouse_config_update_invalid( + self, authenticated_client, lighthouse_config_fixture, field_name, invalid_value + ): + update_payload = { + "data": { + "type": "lighthouse-configurations", + "id": str(lighthouse_config_fixture.id), + "attributes": { + field_name: invalid_value, + }, + } + } + response = authenticated_client.patch( + reverse( + "lighthouseconfiguration-detail", + kwargs={"pk": lighthouse_config_fixture.id}, + ), + data=update_payload, + content_type=API_JSON_CONTENT_TYPE, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + errors = response.json()["errors"] + assert any(field_name in error["source"]["pointer"] for error in errors) + + def test_lighthouse_config_delete( + self, authenticated_client, lighthouse_config_fixture + ): + config_id = lighthouse_config_fixture.id + response = authenticated_client.delete( + reverse("lighthouseconfiguration-detail", kwargs={"pk": config_id}) + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Verify deletion by checking list endpoint returns no items + response = authenticated_client.get(reverse("lighthouseconfiguration-list")) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == 0 + + def test_lighthouse_config_list_masked_api_key_default( + self, authenticated_client, lighthouse_config_fixture + ): + """Test that list view returns all fields with masked API key by default""" + response = authenticated_client.get(reverse("lighthouseconfiguration-list")) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + config = data[0]["attributes"] + + # All fields should be present + assert "name" in config + assert "model" in config + assert "temperature" in config + assert "max_tokens" in config + assert "business_context" in config + assert "api_key" in config + + # API key should be masked (asterisks) + api_key = config["api_key"] + assert api_key.startswith("*") + assert all(c == "*" for c in api_key) + + def test_lighthouse_config_unmasked_api_key_single_field( + self, authenticated_client, lighthouse_config_fixture, valid_config_payload + ): + """Test that specifying api_key in fields param returns all fields with unmasked API key""" + expected_api_key = valid_config_payload["data"]["attributes"]["api_key"] + response = authenticated_client.get( + reverse("lighthouseconfiguration-list") + + "?fields[lighthouse-config]=api_key" + ) + assert response.status_code == status.HTTP_200_OK + data = response.json()["data"] + assert len(data) == 1 + config = data[0]["attributes"] + + # All fields should still be present + assert "name" in config + assert "model" in config + assert "temperature" in config + assert "max_tokens" in config + assert "business_context" in config + assert "api_key" in config + + # API key should be unmasked + assert config["api_key"] == expected_api_key + + @pytest.mark.parametrize( + "sort_field, expected_count", + [ + ("name", 1), # Test sorting by name + ("-inserted_at", 1), # Test sorting by inserted_at + ], + ) + def test_lighthouse_config_sorting( + self, + authenticated_client, + lighthouse_config_fixture, + sort_field, + expected_count, + ): + """Test sorting lighthouse configurations by various fields""" + response = authenticated_client.get( + reverse("lighthouseconfiguration-list") + f"?sort={sort_field}" + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["data"]) == expected_count + + @patch("api.v1.views.Task.objects.get") + @patch("api.v1.views.check_lighthouse_connection_task.delay") + def test_lighthouse_config_connection( + self, + mock_lighthouse_connection, + mock_task_get, + authenticated_client, + lighthouse_config_fixture, + tasks_fixture, + ): + prowler_task = tasks_fixture[0] + task_mock = Mock() + task_mock.id = prowler_task.id + task_mock.status = "PENDING" + mock_lighthouse_connection.return_value = task_mock + mock_task_get.return_value = prowler_task + + config_id = lighthouse_config_fixture.id + assert lighthouse_config_fixture.is_active is True + + response = authenticated_client.post( + reverse("lighthouseconfiguration-connection", kwargs={"pk": config_id}) + ) + assert response.status_code == status.HTTP_202_ACCEPTED + mock_lighthouse_connection.assert_called_once_with( + lighthouse_config_id=str(config_id), tenant_id=ANY + ) + assert "Content-Location" in response.headers + assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}" + + def test_lighthouse_config_connection_invalid_config( + self, authenticated_client, lighthouse_config_fixture + ): + response = authenticated_client.post( + reverse("lighthouseconfiguration-connection", kwargs={"pk": "random_id"}) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/api/src/backend/api/v1/serializers.py b/api/src/backend/api/v1/serializers.py index ed20903df3..73d9cea5b8 100644 --- a/api/src/backend/api/v1/serializers.py +++ b/api/src/backend/api/v1/serializers.py @@ -19,6 +19,7 @@ from api.models import ( IntegrationProviderRelationship, Invitation, InvitationRoleRelationship, + LighthouseConfiguration, Membership, Provider, ProviderGroup, @@ -2084,3 +2085,134 @@ class SAMLConfigurationSerializer(RLSSerializer): model = SAMLConfiguration fields = ["id", "email_domain", "metadata_xml", "created_at", "updated_at"] read_only_fields = ["id", "created_at", "updated_at"] + + +class LighthouseConfigSerializer(RLSSerializer): + """ + Serializer for the LighthouseConfig model. + """ + + api_key = serializers.CharField(required=False) + + class Meta: + model = LighthouseConfiguration + fields = [ + "id", + "name", + "api_key", + "model", + "temperature", + "max_tokens", + "business_context", + "is_active", + "inserted_at", + "updated_at", + "url", + ] + extra_kwargs = { + "id": {"read_only": True}, + "is_active": {"read_only": True}, + "inserted_at": {"read_only": True}, + "updated_at": {"read_only": True}, + } + + def to_representation(self, instance): + data = super().to_representation(instance) + # Check if api_key is specifically requested in fields param + fields_param = self.context.get("request", None) and self.context[ + "request" + ].query_params.get("fields[lighthouse-config]", "") + if fields_param == "api_key": + # Return decrypted key if specifically requested + data["api_key"] = instance.api_key_decoded if instance.api_key else None + else: + # Return masked key for general requests + data["api_key"] = "*" * len(instance.api_key) if instance.api_key else None + return data + + +class LighthouseConfigCreateSerializer(RLSSerializer, BaseWriteSerializer): + """Serializer for creating new Lighthouse configurations.""" + + api_key = serializers.CharField(write_only=True, required=True) + + class Meta: + model = LighthouseConfiguration + fields = [ + "id", + "name", + "api_key", + "model", + "temperature", + "max_tokens", + "business_context", + "is_active", + "inserted_at", + "updated_at", + ] + extra_kwargs = { + "id": {"read_only": True}, + "is_active": {"read_only": True}, + "inserted_at": {"read_only": True}, + "updated_at": {"read_only": True}, + } + + def validate(self, attrs): + tenant_id = self.context.get("request").tenant_id + if LighthouseConfiguration.objects.filter(tenant_id=tenant_id).exists(): + raise serializers.ValidationError( + { + "tenant_id": "Lighthouse configuration already exists for this tenant." + } + ) + return super().validate(attrs) + + def create(self, validated_data): + api_key = validated_data.pop("api_key") + instance = super().create(validated_data) + instance.api_key_decoded = api_key + instance.save() + return instance + + def to_representation(self, instance): + data = super().to_representation(instance) + # Always mask the API key in the response + data["api_key"] = "*" * len(instance.api_key) if instance.api_key else None + return data + + +class LighthouseConfigUpdateSerializer(BaseWriteSerializer): + """ + Serializer for updating LighthouseConfig instances. + """ + + api_key = serializers.CharField(write_only=True, required=False) + + class Meta: + model = LighthouseConfiguration + fields = [ + "id", + "name", + "api_key", + "model", + "temperature", + "max_tokens", + "business_context", + "is_active", + ] + extra_kwargs = { + "id": {"read_only": True}, + "is_active": {"read_only": True}, + "name": {"required": False}, + "model": {"required": False}, + "temperature": {"required": False}, + "max_tokens": {"required": False}, + } + + def update(self, instance, validated_data): + api_key = validated_data.pop("api_key", None) + instance = super().update(instance, validated_data) + if api_key: + instance.api_key_decoded = api_key + instance.save() + return instance diff --git a/api/src/backend/api/v1/urls.py b/api/src/backend/api/v1/urls.py index 967b40ff87..c96a6f56d0 100644 --- a/api/src/backend/api/v1/urls.py +++ b/api/src/backend/api/v1/urls.py @@ -13,6 +13,7 @@ from api.v1.views import ( IntegrationViewSet, InvitationAcceptViewSet, InvitationViewSet, + LighthouseConfigViewSet, MembershipViewSet, OverviewViewSet, ProviderGroupProvidersRelationshipView, @@ -53,6 +54,11 @@ router.register(r"overviews", OverviewViewSet, basename="overview") router.register(r"schedules", ScheduleViewSet, basename="schedule") router.register(r"integrations", IntegrationViewSet, basename="integration") router.register(r"saml-config", SAMLConfigurationViewSet, basename="saml-config") +router.register( + r"lighthouse-configurations", + LighthouseConfigViewSet, + basename="lighthouseconfiguration", +) tenants_router = routers.NestedSimpleRouter(router, r"tenants", lookup="tenant") tenants_router.register( diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index a938b45733..7d6cf1bfa0 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -54,6 +54,7 @@ from tasks.beat import schedule_provider_scan from tasks.jobs.export import get_s3_client from tasks.tasks import ( backfill_scan_resource_summaries_task, + check_lighthouse_connection_task, check_provider_connection_task, delete_provider_task, delete_tenant_task, @@ -93,6 +94,7 @@ from api.models import ( Finding, Integration, Invitation, + LighthouseConfiguration, Membership, Provider, ProviderGroup, @@ -138,6 +140,9 @@ from api.v1.serializers import ( InvitationCreateSerializer, InvitationSerializer, InvitationUpdateSerializer, + LighthouseConfigCreateSerializer, + LighthouseConfigSerializer, + LighthouseConfigUpdateSerializer, MembershipSerializer, OverviewFindingSerializer, OverviewProviderSerializer, @@ -335,6 +340,11 @@ class SchemaView(SpectacularAPIView): "description": "Endpoints for managing third-party integrations, including registration, configuration," " retrieval, and deletion of integrations such as S3, JIRA, or other services.", }, + { + "name": "Lighthouse", + "description": "Endpoints for managing Lighthouse configurations, including creation, retrieval, " + "updating, and deletion of configurations such as OpenAI keys, models, and business context.", + }, ] return super().get(request, *args, **kwargs) @@ -499,10 +509,16 @@ class TenantFinishACSView(FinishACSView): 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.first_name = ( + extra.get("firstName", [""])[0] if extra.get("firstName") else "" + ) + user.last_name = extra.get("lastName", [""])[0] if extra.get("lastName") else "" + user.company_name = ( + extra.get("organization", [""])[0] if extra.get("organization") else "" + ) user.name = f"{user.first_name} {user.last_name}".strip() + if user.name == "": + user.name = "N/A" user.save() email_domain = user.email.split("@")[-1] @@ -511,7 +527,11 @@ class TenantFinishACSView(FinishACSView): .get(email_domain=email_domain) .tenant ) - role_name = extra.get("userType", ["saml_default_role"])[0].strip() + role_name = ( + extra.get("userType", ["saml_default_role"])[0].strip() + if extra.get("userType") + else "saml_default_role" + ) try: role = Role.objects.using(MainRouter.admin_db).get( name=role_name, tenant=tenant @@ -2080,6 +2100,8 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): ) resource_types = list( queryset.values_list("resource_type", flat=True) + .exclude(resource_type__isnull=True) + .exclude(resource_type__exact="") .distinct() .order_by("resource_type") ) @@ -2181,6 +2203,8 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): ) resource_types = list( queryset.values_list("resource_type", flat=True) + .exclude(resource_type__isnull=True) + .exclude(resource_type__exact="") .distinct() .order_by("resource_type") ) @@ -3391,3 +3415,80 @@ class IntegrationViewSet(BaseRLSViewSet): context = super().get_serializer_context() context["allowed_providers"] = self.allowed_providers return context + + +@extend_schema_view( + list=extend_schema( + tags=["Lighthouse"], + summary="List all Lighthouse configurations", + description="Retrieve a list of all Lighthouse configurations.", + ), + create=extend_schema( + tags=["Lighthouse"], + summary="Create a new Lighthouse configuration", + description="Create a new Lighthouse configuration with the specified details.", + ), + partial_update=extend_schema( + tags=["Lighthouse"], + summary="Partially update a Lighthouse configuration", + description="Update certain fields of an existing Lighthouse configuration.", + ), + destroy=extend_schema( + tags=["Lighthouse"], + summary="Delete a Lighthouse configuration", + description="Remove a Lighthouse configuration by its ID.", + ), + connection=extend_schema( + tags=["Lighthouse"], + summary="Check the connection to the OpenAI API", + description="Verify the connection to the OpenAI API for a specific Lighthouse configuration.", + request=None, + responses={202: OpenApiResponse(response=TaskSerializer)}, + ), +) +class LighthouseConfigViewSet(BaseRLSViewSet): + """ + API endpoint for managing Lighthouse configuration. + """ + + serializer_class = LighthouseConfigSerializer + ordering_fields = ["name", "inserted_at", "updated_at", "is_active"] + ordering = ["-inserted_at"] + + def get_queryset(self): + return LighthouseConfiguration.objects.filter(tenant_id=self.request.tenant_id) + + def get_serializer_class(self): + if self.action == "create": + return LighthouseConfigCreateSerializer + elif self.action == "partial_update": + return LighthouseConfigUpdateSerializer + elif self.action == "connection": + return TaskSerializer + return super().get_serializer_class() + + @extend_schema(exclude=True) + def retrieve(self, request, *args, **kwargs): + raise MethodNotAllowed(method="GET") + + @action(detail=True, methods=["post"], url_name="connection") + def connection(self, request, pk=None): + """ + Check the connection to the OpenAI API asynchronously. + """ + instance = self.get_object() + with transaction.atomic(): + task = check_lighthouse_connection_task.delay( + lighthouse_config_id=str(instance.id), tenant_id=self.request.tenant_id + ) + prowler_task = Task.objects.get(id=task.id) + serializer = TaskSerializer(prowler_task) + return Response( + data=serializer.data, + status=status.HTTP_202_ACCEPTED, + headers={ + "Content-Location": reverse( + "task-detail", kwargs={"pk": prowler_task.id} + ) + }, + ) diff --git a/api/src/backend/config/settings/sentry.py b/api/src/backend/config/settings/sentry.py index 87d08bd6ee..648324707f 100644 --- a/api/src/backend/config/settings/sentry.py +++ b/api/src/backend/config/settings/sentry.py @@ -79,9 +79,16 @@ def before_send(event, hint): log_msg = hint["log_record"].msg log_lvl = hint["log_record"].levelno - # Handle Error events and discard the rest - if log_lvl == 40 and any(ignored in log_msg for ignored in IGNORED_EXCEPTIONS): - return + # Handle Error and Critical events and discard the rest + if log_lvl <= 40 and any(ignored in log_msg for ignored in IGNORED_EXCEPTIONS): + return None # Explicitly return None to drop the event + + # Ignore exceptions with the ignored_exceptions + if "exc_info" in hint and hint["exc_info"]: + exc_value = str(hint["exc_info"][1]) + if any(ignored in exc_value for ignored in IGNORED_EXCEPTIONS): + return None # Explicitly return None to drop the event + return event diff --git a/api/src/backend/conftest.py b/api/src/backend/conftest.py index b8013d4252..c601e07b80 100644 --- a/api/src/backend/conftest.py +++ b/api/src/backend/conftest.py @@ -21,6 +21,7 @@ from api.models import ( Integration, IntegrationProviderRelationship, Invitation, + LighthouseConfiguration, Membership, Provider, ProviderGroup, @@ -1059,6 +1060,20 @@ def backfill_scan_metadata_fixture(scans_fixture, findings_fixture): backfill_resource_scan_summaries(tenant_id=tenant_id, scan_id=scan_id) +@pytest.fixture +def lighthouse_config_fixture(authenticated_client, tenants_fixture): + return LighthouseConfiguration.objects.create( + tenant_id=tenants_fixture[0].id, + name="OpenAI", + api_key_decoded="sk-test1234567890T3BlbkFJtest1234567890", + model="gpt-4o", + temperature=0, + max_tokens=4000, + business_context="Test business context", + is_active=True, + ) + + @pytest.fixture(scope="function") def latest_scan_finding(authenticated_client, providers_fixture, resources_fixture): provider = providers_fixture[0] diff --git a/api/src/backend/tasks/jobs/connection.py b/api/src/backend/tasks/jobs/connection.py index 1583f8a75f..43cb816293 100644 --- a/api/src/backend/tasks/jobs/connection.py +++ b/api/src/backend/tasks/jobs/connection.py @@ -1,8 +1,9 @@ from datetime import datetime, timezone +import openai from celery.utils.log import get_task_logger -from api.models import Provider +from api.models import LighthouseConfiguration, Provider from api.utils import prowler_provider_connection_test logger = get_task_logger(__name__) @@ -39,3 +40,46 @@ def check_provider_connection(provider_id: str): connection_error = f"{connection_result.error}" if connection_result.error else None return {"connected": connection_result.is_connected, "error": connection_error} + + +def check_lighthouse_connection(lighthouse_config_id: str): + """ + Business logic to check the connection status of a Lighthouse configuration. + + Args: + lighthouse_config_id (str): The primary key of the LighthouseConfiguration instance to check. + + Returns: + dict: A dictionary containing: + - 'connected' (bool): Indicates whether the connection is successful. + - 'error' (str or None): The error message if the connection failed, otherwise `None`. + - 'available_models' (list): List of available models if connection is successful. + + Raises: + Model.DoesNotExist: If the lighthouse configuration does not exist. + """ + lighthouse_config = LighthouseConfiguration.objects.get(pk=lighthouse_config_id) + + if not lighthouse_config.api_key_decoded: + lighthouse_config.is_active = False + lighthouse_config.save() + return { + "connected": False, + "error": "API key is invalid or missing.", + "available_models": [], + } + + try: + client = openai.OpenAI(api_key=lighthouse_config.api_key_decoded) + models = client.models.list() + lighthouse_config.is_active = True + lighthouse_config.save() + return { + "connected": True, + "error": None, + "available_models": [model.id for model in models.data], + } + except Exception as e: + lighthouse_config.is_active = False + lighthouse_config.save() + return {"connected": False, "error": str(e), "available_models": []} diff --git a/api/src/backend/tasks/tasks.py b/api/src/backend/tasks/tasks.py index 4f30b5fc68..998e6773be 100644 --- a/api/src/backend/tasks/tasks.py +++ b/api/src/backend/tasks/tasks.py @@ -8,7 +8,7 @@ from config.celery import RLSTask from config.django.base import DJANGO_FINDINGS_BATCH_SIZE, DJANGO_TMP_OUTPUT_DIRECTORY from django_celery_beat.models import PeriodicTask from tasks.jobs.backfill import backfill_resource_scan_summaries -from tasks.jobs.connection import check_provider_connection +from tasks.jobs.connection import check_lighthouse_connection, check_provider_connection from tasks.jobs.deletion import delete_provider, delete_tenant from tasks.jobs.export import ( COMPLIANCE_CLASS_MAP, @@ -395,3 +395,22 @@ def create_compliance_requirements_task(tenant_id: str, scan_id: str): 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) + + +@shared_task(base=RLSTask, name="lighthouse-connection-check") +@set_tenant +def check_lighthouse_connection_task(lighthouse_config_id: str, tenant_id: str = None): + """ + Task to check the connection status of a Lighthouse configuration. + + Args: + lighthouse_config_id (str): The primary key of the LighthouseConfiguration instance to check. + tenant_id (str): The tenant ID for the task. + + Returns: + dict: A dictionary containing: + - 'connected' (bool): Indicates whether the connection is successful. + - 'error' (str or None): The error message if the connection failed, otherwise `None`. + - 'available_models' (list): List of available models if connection is successful. + """ + return check_lighthouse_connection(lighthouse_config_id=lighthouse_config_id) diff --git a/api/src/backend/tasks/tests/test_beat.py b/api/src/backend/tasks/tests/test_beat.py index 6e5c6fdf91..01ce0249e6 100644 --- a/api/src/backend/tasks/tests/test_beat.py +++ b/api/src/backend/tasks/tests/test_beat.py @@ -55,3 +55,22 @@ class TestScheduleProviderScan: assert "There is already a scheduled scan for this provider." in str( exc_info.value ) + + def test_remove_periodic_task(self, providers_fixture): + provider_instance = providers_fixture[0] + + assert Scan.objects.count() == 0 + with patch("tasks.tasks.perform_scheduled_scan_task.apply_async"): + schedule_provider_scan(provider_instance) + + assert Scan.objects.count() == 1 + scan = Scan.objects.first() + periodic_task = scan.scheduler_task + assert periodic_task is not None + + periodic_task.delete() + + scan.refresh_from_db() + # Assert the scan still exists but its scheduler_task is set to None + # Otherwise, Scan.DoesNotExist would be raised + assert Scan.objects.get(id=scan.id).scheduler_task is None diff --git a/api/src/backend/tasks/tests/test_connection.py b/api/src/backend/tasks/tests/test_connection.py index 75ba6dc2eb..30c8b147bc 100644 --- a/api/src/backend/tasks/tests/test_connection.py +++ b/api/src/backend/tasks/tests/test_connection.py @@ -1,10 +1,10 @@ from datetime import datetime, timezone -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch import pytest +from tasks.jobs.connection import check_lighthouse_connection, check_provider_connection -from api.models import Provider -from tasks.jobs.connection import check_provider_connection +from api.models import LighthouseConfiguration, Provider @pytest.mark.parametrize( @@ -70,3 +70,60 @@ def test_check_provider_connection_exception( mock_provider_instance.save.assert_called_once() assert mock_provider_instance.connected is False + + +@pytest.mark.parametrize( + "lighthouse_data", + [ + { + "name": "OpenAI", + "api_key_decoded": "sk-test1234567890T3BlbkFJtest1234567890", + "model": "gpt-4o", + "temperature": 0, + "max_tokens": 4000, + "business_context": "Test business context", + "is_active": True, + }, + ], +) +@patch("tasks.jobs.connection.openai.OpenAI") +@pytest.mark.django_db +def test_check_lighthouse_connection( + mock_openai_client, tenants_fixture, lighthouse_data +): + lighthouse_config = LighthouseConfiguration.objects.create( + **lighthouse_data, tenant_id=tenants_fixture[0].id + ) + + mock_models = MagicMock() + mock_models.data = [MagicMock(id="gpt-4o"), MagicMock(id="gpt-4o-mini")] + mock_openai_client.return_value.models.list.return_value = mock_models + + result = check_lighthouse_connection( + lighthouse_config_id=str(lighthouse_config.id), + ) + lighthouse_config.refresh_from_db() + + mock_openai_client.assert_called_once_with( + api_key=lighthouse_data["api_key_decoded"] + ) + assert lighthouse_config.is_active is True + assert result["connected"] is True + assert result["error"] is None + assert result["available_models"] == ["gpt-4o", "gpt-4o-mini"] + + +@patch("tasks.jobs.connection.LighthouseConfiguration.objects.get") +@pytest.mark.django_db +def test_check_lighthouse_connection_missing_api_key(mock_lighthouse_get): + mock_lighthouse_instance = MagicMock() + mock_lighthouse_instance.api_key_decoded = None + mock_lighthouse_get.return_value = mock_lighthouse_instance + + result = check_lighthouse_connection("lighthouse_config_id") + + assert result["connected"] is False + assert result["error"] == "API key is invalid or missing." + assert result["available_models"] == [] + assert mock_lighthouse_instance.is_active is False + mock_lighthouse_instance.save.assert_called_once() diff --git a/api/tests/performance/scenarios/compliance.py b/api/tests/performance/scenarios/compliance.py new file mode 100644 index 0000000000..ae4e030a59 --- /dev/null +++ b/api/tests/performance/scenarios/compliance.py @@ -0,0 +1,128 @@ +import random +from collections import defaultdict + +import requests +from locust import events, task +from utils.helpers import APIUserBase, get_api_token, get_auth_headers + +GLOBAL = { + "token": None, + "available_scans_info": {}, +} +SUPPORTED_COMPLIANCE_IDS = { + "aws": ["ens_rd2022", "cis_2.0", "prowler_threatscore", "soc2"], + "gcp": ["ens_rd2022", "cis_2.0", "prowler_threatscore", "soc2"], + "azure": ["ens_rd2022", "cis_2.0", "prowler_threatscore", "soc2"], + "m365": ["cis_4.0", "iso27001_2022", "prowler_threatscore"], +} + + +def _get_random_scan() -> tuple: + provider_type = random.choice(list(GLOBAL["available_scans_info"].keys())) + scan_info = random.choice(GLOBAL["available_scans_info"][provider_type]) + return provider_type, scan_info + + +def _get_random_compliance_id(provider: str) -> str: + return f"{random.choice(SUPPORTED_COMPLIANCE_IDS[provider])}_{provider}" + + +def _get_compliance_available_scans_by_provider_type(host: str, token: str) -> dict: + excluded_providers = ["kubernetes"] + + response_dict = defaultdict(list) + provider_response = requests.get( + f"{host}/providers?fields[providers]=id,provider&filter[connected]=true", + headers=get_auth_headers(token), + ) + for provider in provider_response.json()["data"]: + provider_id = provider["id"] + provider_type = provider["attributes"]["provider"] + if provider_type in excluded_providers: + continue + + scan_response = requests.get( + f"{host}/scans?fields[scans]=id&filter[provider]={provider_id}&filter[state]=completed", + headers=get_auth_headers(token), + ) + scan_data = scan_response.json()["data"] + if not scan_data: + continue + scan_id = scan_data[0]["id"] + response_dict[provider_type].append(scan_id) + return response_dict + + +def _get_compliance_regions_from_scan(host: str, token: str, scan_id: str) -> list: + response = requests.get( + f"{host}/compliance-overviews/metadata?filter[scan_id]={scan_id}", + headers=get_auth_headers(token), + ) + assert response.status_code == 200, f"Failed to get scan: {response.text}" + return response.json()["data"]["attributes"]["regions"] + + +@events.test_start.add_listener +def on_test_start(environment, **kwargs): + GLOBAL["token"] = get_api_token(environment.host) + scans_by_provider = _get_compliance_available_scans_by_provider_type( + environment.host, GLOBAL["token"] + ) + scan_info = defaultdict(list) + for provider, scans in scans_by_provider.items(): + for scan in scans: + scan_info[provider].append( + { + "scan_id": scan, + "regions": _get_compliance_regions_from_scan( + environment.host, GLOBAL["token"], scan + ), + } + ) + GLOBAL["available_scans_info"] = scan_info + + +class APIUser(APIUserBase): + def on_start(self): + self.token = GLOBAL["token"] + + @task(3) + def compliance_overviews_default(self): + provider_type, scan_info = _get_random_scan() + name = f"/compliance-overviews ({provider_type})" + endpoint = f"/compliance-overviews?" f"filter[scan_id]={scan_info['scan_id']}" + self.client.get(endpoint, headers=get_auth_headers(self.token), name=name) + + @task(2) + def compliance_overviews_region(self): + provider_type, scan_info = _get_random_scan() + name = f"/compliance-overviews?filter[region] ({provider_type})" + endpoint = ( + f"/compliance-overviews" + f"?filter[scan_id]={scan_info['scan_id']}" + f"&filter[region]={random.choice(scan_info['regions'])}" + ) + self.client.get(endpoint, headers=get_auth_headers(self.token), name=name) + + @task(2) + def compliance_overviews_requirements(self): + provider_type, scan_info = _get_random_scan() + compliance_id = _get_random_compliance_id(provider_type) + name = f"/compliance-overviews/requirements ({compliance_id})" + endpoint = ( + f"/compliance-overviews/requirements" + f"?filter[scan_id]={scan_info['scan_id']}" + f"&filter[compliance_id]={compliance_id}" + ) + self.client.get(endpoint, headers=get_auth_headers(self.token), name=name) + + @task + def compliance_overviews_attributes(self): + provider_type, _ = _get_random_scan() + compliance_id = _get_random_compliance_id(provider_type) + name = f"/compliance-overviews/attributes ({compliance_id})" + endpoint = ( + f"/compliance-overviews/attributes" + f"?filter[compliance_id]={compliance_id}" + ) + self.client.get(endpoint, headers=get_auth_headers(self.token), name=name) diff --git a/dashboard/compliance/iso27001_2022_m365.py b/dashboard/compliance/iso27001_2022_m365.py new file mode 100644 index 0000000000..0d15771106 --- /dev/null +++ b/dashboard/compliance/iso27001_2022_m365.py @@ -0,0 +1,23 @@ +import warnings + +from dashboard.common_methods import get_section_container_iso + +warnings.filterwarnings("ignore") + + +def get_table(data): + aux = data[ + [ + "REQUIREMENTS_ATTRIBUTES_CATEGORY", + "REQUIREMENTS_ATTRIBUTES_OBJETIVE_ID", + "REQUIREMENTS_ATTRIBUTES_OBJETIVE_NAME", + "CHECKID", + "STATUS", + "REGION", + "ACCOUNTID", + "RESOURCEID", + ] + ] + return get_section_container_iso( + aux, "REQUIREMENTS_ATTRIBUTES_CATEGORY", "REQUIREMENTS_ATTRIBUTES_OBJETIVE_ID" + ) diff --git a/dashboard/lib/cards.py b/dashboard/lib/cards.py index 0df0ff5794..ba3e73be11 100644 --- a/dashboard/lib/cards.py +++ b/dashboard/lib/cards.py @@ -4,7 +4,10 @@ from dash import html def create_provider_card( - provider: str, provider_logo: str, account_type: str, filtered_data + provider: str, + provider_logo: str, + account_type: str, + filtered_data, ) -> List[html.Div]: """ Card to display the provider's name and icon. diff --git a/dashboard/lib/dropdowns.py b/dashboard/lib/dropdowns.py index 2aaab1ab31..3e92759042 100644 --- a/dashboard/lib/dropdowns.py +++ b/dashboard/lib/dropdowns.py @@ -245,6 +245,31 @@ def create_service_dropdown(services: list) -> html.Div: ) +def create_provider_dropdown(providers: list) -> html.Div: + """ + Dropdown to select the provider. + Args: + providers (list): List of providers. + Returns: + html.Div: Dropdown to select the provider. + """ + return html.Div( + [ + html.Label( + "Provider:", className="text-prowler-stone-900 font-bold text-sm" + ), + dcc.Dropdown( + id="provider-filter", + options=[{"label": i, "value": i} for i in providers], + value=["All"], + clearable=False, + multi=True, + style={"color": "#000000"}, + ), + ], + ) + + def create_status_dropdown(status: list) -> html.Div: """ Dropdown to select the status. diff --git a/dashboard/lib/layouts.py b/dashboard/lib/layouts.py index 144036f183..e054731684 100644 --- a/dashboard/lib/layouts.py +++ b/dashboard/lib/layouts.py @@ -9,9 +9,11 @@ def create_layout_overview( download_button_xlsx: html.Button, severity_dropdown: html.Div, service_dropdown: html.Div, + provider_dropdown: html.Div, table_row_dropdown: html.Div, status_dropdown: html.Div, table_div_header: html.Div, + amount_providers: int, ) -> html.Div: """ Create the layout of the dashboard. @@ -47,9 +49,10 @@ def create_layout_overview( [ html.Div([severity_dropdown], className=""), html.Div([service_dropdown], className=""), + html.Div([provider_dropdown], className=""), html.Div([status_dropdown], className=""), ], - className="grid gap-x-4 mb-[30px] sm:grid-cols-2 lg:grid-cols-3", + className="grid gap-x-4 mb-[30px] sm:grid-cols-2 lg:grid-cols-4", ), html.Div( [ @@ -59,7 +62,7 @@ def create_layout_overview( html.Div(className="flex", id="k8s_card", n_clicks=0), html.Div(className="flex", id="m365_card", n_clicks=0), ], - className="grid gap-x-4 mb-[30px] sm:grid-cols-2 lg:grid-cols-5", + className=f"grid gap-x-4 mb-[30px] sm:grid-cols-2 lg:grid-cols-{amount_providers}", ), html.H4( "Count of Findings by severity", diff --git a/dashboard/pages/compliance.py b/dashboard/pages/compliance.py index 93c0eeb2c1..33a2b0c268 100644 --- a/dashboard/pages/compliance.py +++ b/dashboard/pages/compliance.py @@ -346,34 +346,27 @@ def display_data( if item == "nan" or item.__class__.__name__ != "str": region_filter_options.remove(item) + # Convert ASSESSMENTDATE to datetime data["ASSESSMENTDATE"] = pd.to_datetime(data["ASSESSMENTDATE"], errors="coerce") - data["ASSESSMENTDATE"] = data["ASSESSMENTDATE"].dt.strftime("%Y-%m-%d %H:%M:%S") + data["ASSESSMENTDAY"] = data["ASSESSMENTDATE"].dt.date - # Choosing the date that is the most recent - data_values = data["ASSESSMENTDATE"].unique() - data_values.sort() - data_values = data_values[::-1] - aux = [] + # Find the latest timestamp per account per day + latest_per_account_day = data.groupby(["ACCOUNTID", "ASSESSMENTDAY"])[ + "ASSESSMENTDATE" + ].transform("max") - data_values = [str(i) for i in data_values] - for value in data_values: - if value.split(" ")[0] not in [aux[i].split(" ")[0] for i in range(len(aux))]: - aux.append(value) - data_values = [str(i) for i in aux] + # Keep only rows with the latest timestamp for each account and day + data = data[data["ASSESSMENTDATE"] == latest_per_account_day] - data = data[data["ASSESSMENTDATE"].isin(data_values)] - data["ASSESSMENTDATE"] = data["ASSESSMENTDATE"].apply(lambda x: x.split(" ")[0]) + # Prepare the date filter options (unique days, as strings) + options_date = sorted(data["ASSESSMENTDAY"].astype(str).unique(), reverse=True) - options_date = data["ASSESSMENTDATE"].unique() - options_date.sort() - options_date = options_date[::-1] - - # Filter DATE + # Filter by selected date (as string) if date_filter_analytics in options_date: - data = data[data["ASSESSMENTDATE"] == date_filter_analytics] + data = data[data["ASSESSMENTDAY"].astype(str) == date_filter_analytics] else: date_filter_analytics = options_date[0] - data = data[data["ASSESSMENTDATE"] == date_filter_analytics] + data = data[data["ASSESSMENTDAY"].astype(str) == date_filter_analytics] if data.empty: fig = px.pie() diff --git a/dashboard/pages/overview.py b/dashboard/pages/overview.py index 85f07b816e..c51d33aa47 100644 --- a/dashboard/pages/overview.py +++ b/dashboard/pages/overview.py @@ -38,6 +38,7 @@ from dashboard.lib.cards import create_provider_card from dashboard.lib.dropdowns import ( create_account_dropdown, create_date_dropdown, + create_provider_dropdown, create_region_dropdown, create_service_dropdown, create_severity_dropdown, @@ -298,6 +299,13 @@ else: service_dropdown = create_service_dropdown(services) + # Provider Dropdown + providers = ["All"] + list(data["PROVIDER"].unique()) + providers = [ + x for x in providers if str(x) != "nan" and x.__class__.__name__ == "str" + ] + provider_dropdown = create_provider_dropdown(providers) + # Create the download button download_button_csv = html.Button( "Download this table as CSV", @@ -479,9 +487,11 @@ else: download_button_xlsx, severity_dropdown, service_dropdown, + provider_dropdown, table_row_dropdown, status_dropdown, table_div_header, + len(data["PROVIDER"].unique()), ) @@ -508,6 +518,8 @@ else: Output("severity-filter", "value"), Output("severity-filter", "options"), Output("service-filter", "value"), + Output("provider-filter", "value"), + Output("provider-filter", "options"), Output("service-filter", "options"), Output("table-rows", "value"), Output("table-rows", "options"), @@ -526,6 +538,7 @@ else: Input("download_link_xlsx", "n_clicks"), Input("severity-filter", "value"), Input("service-filter", "value"), + Input("provider-filter", "value"), Input("table-rows", "value"), Input("status-filter", "value"), Input("search-input", "value"), @@ -549,6 +562,7 @@ def filter_data( n_clicks_xlsx, severity_values, service_values, + provider_values, table_row_values, status_values, search_value, @@ -874,6 +888,25 @@ def filter_data( filtered_data["SERVICE_NAME"].isin(updated_service_values) ] + provider_filter_options = ["All"] + list(filtered_data["PROVIDER"].unique()) + + # Filter Provider + if provider_values == ["All"]: + updated_provider_values = filtered_data["PROVIDER"].unique() + elif "All" in provider_values and len(provider_values) > 1: + # Remove 'All' from the list + provider_values.remove("All") + updated_provider_values = provider_values + elif len(provider_values) == 0: + updated_provider_values = filtered_data["PROVIDER"].unique() + provider_values = ["All"] + else: + updated_provider_values = provider_values + + filtered_data = filtered_data[ + filtered_data["PROVIDER"].isin(updated_provider_values) + ] + # Filter Status if status_values == ["All"]: updated_status_values = filtered_data["STATUS"].unique() @@ -1094,25 +1127,17 @@ def filter_data( table_row_options = [] - # Take the values from the table_row_values + # Calculate table row options as percentages + percentages = [0.05, 0.10, 0.25, 0.50, 0.75, 1.0] + total_rows = len(filtered_data) + for pct in percentages: + value = max(1, int(total_rows * pct)) + label = f"{int(pct * 100)}%" + table_row_options.append({"label": label, "value": value}) + + # Default to 25% if not set if table_row_values is None or table_row_values == -1: - if len(filtered_data) < 25: - table_row_values = len(filtered_data) - else: - table_row_values = 25 - - if len(filtered_data) < 25: - table_row_values = len(filtered_data) - - if len(filtered_data) >= 25: - table_row_options.append(25) - if len(filtered_data) >= 50: - table_row_options.append(50) - if len(filtered_data) >= 75: - table_row_options.append(75) - if len(filtered_data) >= 100: - table_row_options.append(100) - table_row_options.append(len(filtered_data)) + table_row_values = table_row_options[0]["value"] # For the values that are nan or none, replace them with "" filtered_data = filtered_data.replace({np.nan: ""}) @@ -1347,21 +1372,36 @@ def filter_data( ] # Create Provider Cards - aws_card = create_provider_card( - "aws", aws_provider_logo, "Accounts", full_filtered_data - ) - azure_card = create_provider_card( - "azure", azure_provider_logo, "Subscriptions", full_filtered_data - ) - gcp_card = create_provider_card( - "gcp", gcp_provider_logo, "Projects", full_filtered_data - ) - k8s_card = create_provider_card( - "kubernetes", ks8_provider_logo, "Clusters", full_filtered_data - ) - m365_card = create_provider_card( - "m365", m365_provider_logo, "Accounts", full_filtered_data - ) + if "aws" in list(data["PROVIDER"].unique()): + aws_card = create_provider_card( + "aws", aws_provider_logo, "Accounts", full_filtered_data + ) + else: + aws_card = None + if "azure" in list(data["PROVIDER"].unique()): + azure_card = create_provider_card( + "azure", azure_provider_logo, "Subscriptions", full_filtered_data + ) + else: + azure_card = None + if "gcp" in list(data["PROVIDER"].unique()): + gcp_card = create_provider_card( + "gcp", gcp_provider_logo, "Projects", full_filtered_data + ) + else: + gcp_card = None + if "kubernetes" in list(data["PROVIDER"].unique()): + k8s_card = create_provider_card( + "kubernetes", ks8_provider_logo, "Clusters", full_filtered_data + ) + else: + k8s_card = None + if "m365" in list(data["PROVIDER"].unique()): + m365_card = create_provider_card( + "m365", m365_provider_logo, "Accounts", full_filtered_data + ) + else: + m365_card = None # Subscribe to Prowler Cloud card subscribe_card = [ @@ -1445,6 +1485,8 @@ def filter_data( severity_values, severity_filter_options, service_values, + provider_values, + provider_filter_options, service_filter_options, table_row_values, table_row_options, diff --git a/docs/developer-guide/aws-details.md b/docs/developer-guide/aws-details.md new file mode 100644 index 0000000000..819f04fa64 --- /dev/null +++ b/docs/developer-guide/aws-details.md @@ -0,0 +1,122 @@ +# AWS Provider + +In this page you can find all the details about [Amazon Web Services (AWS)](https://aws.amazon.com/) provider implementation in Prowler. + +By default, Prowler will audit just one account and organization settings per scan. To configure it, follow the [getting started](../index.md#aws) page. + +## AWS Provider Classes Architecture + +The AWS provider implementation follows the general [Provider structure](./provider.md). This section focuses on the AWS-specific implementation, highlighting how the generic provider concepts are realized for AWS in Prowler. For a full overview of the provider pattern, base classes, and extension guidelines, see [Provider documentation](./provider.md). In next subsection you can find a list of the main classes of the AWS provider. + +### `AwsProvider` (Main Class) + +- **Location:** [`prowler/providers/aws/aws_provider.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/aws/aws_provider.py) +- **Base Class:** Inherits from `Provider` (see [base class details](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/common/provider.py)). +- **Purpose:** Central orchestrator for AWS-specific logic, session management, credential validation, role assumption, region and organization discovery, and configuration. +- **Key AWS Responsibilities:** + - Initializes and manages AWS sessions (with or without role assumption, MFA, etc.). + - Validates credentials and sets up the AWS identity context. + - Loads and manages configuration, mutelist, and fixer settings. + - Discovers enabled AWS regions and organization metadata. + - Provides properties and methods for downstream AWS service classes to access session, identity, and configuration data. + +### Data Models + +- **Location:** [`prowler/providers/aws/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/aws/models.py) +- **Purpose:** Define structured data for AWS identity, session, credentials, organization info, and more. +- **Key AWS Models:** + - `AWSOrganizationsInfo`: Holds AWS Organizations metadata, to be used by the checks. + - `AWSCredentials`, `AWSAssumeRoleInfo`, `AWSAssumeRoleConfiguration`: Used for role assumption and session management. + - `AWSIdentityInfo`: Stores account, user, partition, and region context for the scan. + - `AWSSession`: Wraps the current and original [boto3](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) sessions and config. + +### `AWSService` (Service Base Class) + +- **Location:** [`prowler/providers/aws/lib/service/service.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/aws/lib/service/service.py) +- **Purpose:** Abstract base class that all AWS service-specific classes inherit from. This implements the generic service pattern (described in [service page](./services.md#service-base-class)) specifically for AWS. +- **Key AWS Responsibilities:** + - Receives an `AwsProvider` instance to access session, identity, and configuration. + - Manages clients for all services by regions. + - Provides `__threading_call__` method to make boto3 calls in parallel. By default, this calls are made by region, but it can be overridden with the first parameter of the method and use by resource. + - Exposes common audit context (`audited_account`, `audited_account_arn`, `audited_partition`, `audited_resources`) to subclasses. + +### Exception Handling + +- **Location:** [`prowler/providers/aws/exceptions/exceptions.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/aws/exceptions/exceptions.py) +- **Purpose:** Custom exception classes for AWS-specific error handling, such as credential and role errors. + +### Session and Utility Helpers + +- **Location:** [`prowler/providers/aws/lib/`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/aws/lib/) +- **Purpose:** Helpers for session setup, ARN parsing, mutelist management, and other cross-cutting concerns. + +## Specific Patterns in AWS Services + +The generic service pattern is described in [service page](./services.md#service-structure-and-initialisation). You can find all the right now implemented services in the following locations: + +- Directly in the code, in location [`prowler/providers/aws/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/aws/services) +- In the [Prowler Hub](https://hub.prowler.com/). For a more human-readable view. + +The best reference to understand how to implement a new service is following the [service implementation documentation](./services.md#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used accross all AWS services. + +### AWS Service Common Patterns + +- Services communicate with AWS using boto3, you can find the documentation with all the services [here](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/index.html). +- Every AWS service class inherits from `AWSService`, ensuring access to session, identity, configuration, and threading utilities. +- The constructor (`__init__`) always calls `super().__init__` with the service name and provider (e.g. `super().__init__(__class__.__name__, provider))`). Ensure that the service name in boto3 is the same that you use in the constructor. Usually is used the `__class__.__name__` to get the service name because it is the same as the class name. +- Resource containers **must** be initialized in the constructor. They should be dictionaries, with the key being the resource ARN or equivalent unique identifier and the value being the resource object. +- Resource discovery and attribute collection are parallelized using `self.__threading_call__`, typically by region or resource, for performance. The first parameter of the method is the iterator, if not provided, it will be the region; but if present indicate an array of the resources to be processed. +- Resource filtering is consistently enforced using `self.audit_resources` attribute and `is_resource_filtered` function, it is used to see if user has provided some resource that is not in the audit scope, so we can skip it in the service logic. Normally it is used befor storing the resource in the service container as follows: `if not self.audit_resources or (is_resource_filtered(resource["arn"], self.audit_resources)):`. +- All AWS resources are represented as Pydantic `BaseModel` classes, providing type safety and structured access to resource attributes. +- AWS API calls are wrapped in try/except blocks, with specific handling for `ClientError` and generic exceptions, always logging errors. +- If ARN is not present for some resource, it can be constructed using string interpolation, always including partition, service, region, account, and resource ID. +- Tags and additional attributes that cannot be retrieved from the default call, should be collected and stored for each resource using dedicated methods and threading using the resource object list as iterator. + +## Specific Patterns in AWS Checks + +The AWS checks pattern is described in [checks page](./checks.md). You can find all the right now implemented checks: + +- Directly in the code, within each service folder, each check has its own folder named after the name of the check. (e.g. [`prowler/providers/aws/services/s3/s3_bucket_acl_prohibited/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/aws/services/s3/s3_bucket_acl_prohibited)) +- In the [Prowler Hub](https://hub.prowler.com/). For a more human-readable view. + +The best reference to understand how to implement a new check is following the [check creation documentation](./checks.md#creating-a-check) and taking other similar checks as reference. + +### Check Report Class + +The `Check_Report_AWS` class models a single finding for an AWS resource in a check report. It is defined in [`prowler/lib/check/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/check/models.py) and inherits from the generic `Check_Report` base class. + +#### Purpose + +`Check_Report_AWS` extends the base report structure with AWS-specific fields, enabling detailed tracking of the resource, ARN, and region associated with each finding. + +#### Constructor and Attribute Population + +When you instantiate `Check_Report_AWS`, you must provide the check metadata and a resource object. The class will attempt to automatically populate its AWS-specific attributes from the resource, using the following logic (in order of precedence): + +- **`resource_id`**: + - Uses `resource.id` if present. + - Otherwise, uses `resource.name` if present. + - Defaults to an empty string if none are available. + +- **`resource_arn`**: + - Uses `resource.arn` if present. + - Defaults to an empty string if ARN is not present in the resource object. + +- **`region`**: + - Uses `resource.region` if present. + - Defaults to an empty string if region is not present in the resource object. + +If the resource object does not contain the required attributes, you must set them manually in the check logic. + +Other attributes are inherited from the `Check_Report` class, from that ones you **always** have to set the `status` and `status_extended` attributes in the check logic. + +#### Example Usage + +```python +report = Check_Report_AWS( + metadata=check_metadata, + resource=resource_object +) +report.status = "PASS" +report.status_extended = "Resource is compliant." +``` diff --git a/docs/developer-guide/azure-details.md b/docs/developer-guide/azure-details.md new file mode 100644 index 0000000000..9d21706acd --- /dev/null +++ b/docs/developer-guide/azure-details.md @@ -0,0 +1,121 @@ +# Azure Provider + +In this page you can find all the details about [Microsoft Azure](https://azure.microsoft.com/) provider implementation in Prowler. + +By default, Prowler will audit all the subscriptions that it is able to list in the Microsoft Entra tenant, and tenant Entra ID service. To configure it, follow the [getting started](../index.md#azure) page. + +## Azure Provider Classes Architecture + +The Azure provider implementation follows the general [Provider structure](./provider.md). This section focuses on the Azure-specific implementation, highlighting how the generic provider concepts are realized for Azure in Prowler. For a full overview of the provider pattern, base classes, and extension guidelines, see [Provider documentation](./provider.md). In next subsection you can find a list of the main classes of the Azure provider. + +### `AzureProvider` (Main Class) + +- **Location:** [`prowler/providers/azure/azure_provider.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/azure/azure_provider.py) +- **Base Class:** Inherits from `Provider` (see [base class details](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/common/provider.py)). +- **Purpose:** Central orchestrator for Azure-specific logic, session management, credential validation, and configuration. +- **Key Azure Responsibilities:** + - Initializes and manages Azure sessions (supports Service Principal, CLI, Browser, and Managed Identity authentication). + - Validates credentials and sets up the Azure identity context. + - Loads and manages configuration, mutelist, and fixer settings. + - Retrieves subscription(s) metadata. + - Provides properties and methods for downstream Azure service classes to access session, identity, and configuration data. + +### Data Models + +- **Location:** [`prowler/providers/azure/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/azure/models.py) +- **Purpose:** Define structured data for Azure identity, session, region configuration, and subscription info. +- **Key Azure Models:** + - `AzureIdentityInfo`: Holds Azure identity metadata, including tenant ID, domain, subscription names and IDs, and locations. + - `AzureRegionConfig`: Stores the specific region that will be audited. That can be: Global, US Government or China. + - `AzureSubscription`: Represents a subscription with ID, display name, and state. + +### `AzureService` (Service Base Class) + +- **Location:** [`prowler/providers/azure/lib/service/service.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/azure/lib/service/service.py) +- **Purpose:** Abstract base class that all Azure service-specific classes inherit from. This implements the generic service pattern (described in [service page](./services.md#service-base-class)) specifically for Azure. +- **Key Azure Responsibilities:** + - Receives an `AzureProvider` instance to access session, identity, and configuration. + - Manages clients for all services by subscription. + - Exposes common audit context (`subscriptions`, `locations`, `audit_config`, `fixer_config`) to subclasses. + +### Exception Handling + +- **Location:** [`prowler/providers/azure/exceptions/exceptions.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/azure/exceptions/exceptions.py) +- **Purpose:** Custom exception classes for Azure-specific error handling, such as credential, region, and session errors. + +### Session and Utility Helpers + +- **Location:** [`prowler/providers/azure/lib/`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/azure/lib/) +- **Purpose:** Helpers for argument parsing, region setup, mutelist management, and other cross-cutting concerns. + +## Specific Patterns in Azure Services + +The generic service pattern is described in [service page](./services.md#service-structure-and-initialisation). You can find all the currently implemented services in the following locations: + +- Directly in the code, in location [`prowler/providers/azure/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/azure/services) +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. + +The best reference to understand how to implement a new service is following the [service implementation documentation](./services.md#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used accross all Azure services. + +### Azure Service Common Patterns + +- Services communicate with Azure using the Azure Python SDK, mainly using the Azure Management Client (except for the Microsoft Entra ID service, that is using the Microsoft Graph API), you can find the documentation with all the management services [here](https://learn.microsoft.com/en-us/python/api/overview/azure/?view=azure-python). +- Every Azure service class inherits from `AzureService`, ensuring access to session, identity, configuration, and client utilities. +- The constructor (`__init__`) always calls `super().__init__` with the service Azure Management Client and Prowler provider object (e.g `super().__init__(WebSiteManagementClient, provider)`). +- Resource containers **must** be initialized in the constructor, and they should be dictionaries, with the key being the subscription ID, the value being a dictionary with the resource ID as key and the resource object as value. +- All Azure resources are represented as Pydantic `BaseModel` classes, providing type safety and structured access to resource attributes. Some are represented as dataclasses due to legacy reasons, but new resources should be represented as Pydantic `BaseModel` classes. +- Azure SDK functions are wrapped in try/except blocks, with specific handling for errors, always logging errors. It is a best practice to create a custom function for every Azure SDK call, in that way we can handle the errors in a more specific way. + +## Specific Patterns in Azure Checks + +The Azure checks pattern is described in [checks page](./checks.md). You can find all the currently implemented checks: + +- Directly in the code, within each service folder, each check has its own folder named after the name of the check. (e.g. [`prowler/providers/azure/services/storage/storage_blob_public_access_level_is_disabled/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/azure/services/storage/storage_blob_public_access_level_is_disabled)) +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. + +The best reference to understand how to implement a new check is the [Azure check implementation documentation](./checks.md#creating-a-check) and taking other similar checks as reference. + +### Check Report Class + +The `Check_Report_Azure` class models a single finding for an Azure resource in a check report. It is defined in [`prowler/lib/check/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/check/models.py) and inherits from the generic `Check_Report` base class. + +#### Purpose + +`Check_Report_Azure` extends the base report structure with Azure-specific fields, enabling detailed tracking of the resource, resource ID, name, subscription, and location associated with each finding. + +#### Constructor and Attribute Population + +When you instantiate `Check_Report_Azure`, you must provide the check metadata and a resource object. The class will attempt to automatically populate its Azure-specific attributes from the resource, using the following logic (in order of precedence): + +- **`resource_id`**: + - Uses `resource.id` if present. + - Otherwise, uses `resource.resource_id` if present. + - Defaults to an empty string if not available. + +- **`resource_name`**: + - Uses `resource.name` if present. + - Otherwise, uses `resource.resource_name` if present. + - Defaults to an empty string if not available. + +- **`subscription`**: + - Defaults to an empty string, it **must** be set in the check logic. + +- **`location`**: + - Uses `resource.location` if present. + - Defaults to an empty string if not available. + +If the resource object does not contain the required attributes, you must set them manually in the check logic. + +Other attributes are inherited from the `Check_Report` class, from which you **always** have to set the `status` and `status_extended` attributes in the check logic. + +#### Example Usage + +```python +report = Check_Report_Azure( + metadata=check_metadata, + resource=resource_object +) +report.subscription = subscription_id +report.status = "PASS" +report.status_extended = "Resource is compliant." +``` diff --git a/docs/developer-guide/checks.md b/docs/developer-guide/checks.md index 0476d24a2b..400da7134d 100644 --- a/docs/developer-guide/checks.md +++ b/docs/developer-guide/checks.md @@ -1,370 +1,339 @@ -# Create a new Check for a Provider +# Prowler Checks -Here you can find how to create new checks for Prowler. - -**To create a check is required to have a Prowler provider service already created, so if the service is not present or the attribute you want to audit is not retrieved by the service, please refer to the [Service](./services.md) documentation.** +This guide explains how to create new checks in Prowler. ## Introduction -The checks are the fundamental piece of Prowler. A check is a simply piece of code that ensures if something is configured against cybersecurity best practices. Then the check generates a finding with the result and includes the check's metadata to give the user more contextual information about the result, the risk and how to remediate it. +Checks are the core component of Prowler. A check is a piece of code designed to validate whether a configuration aligns with cybersecurity best practices. Execution of a check yields a finding, which includes the result and contextual metadata (e.g., outcome, risks, remediation). -To create a new check for a supported Prowler provider, you will need to create a folder with the check name inside the specific service for the selected provider. +### Creating a Check -We are going to use the `ec2_ami_public` check from the `AWS` provider as an example. So the folder name will be `prowler/providers/aws/services/ec2/ec2_ami_public` (following the format `prowler/providers//services//`), with the name of check following the pattern: `service_subservice_resource_action`. +To create a new check: + +- Prerequisites: A Prowler provider and service must exist. Verify support and check for pre-existing checks via [Prowler Hub](https://hub.prowler.com). If the provider or service is not present, please refer to the [Provider](./provider.md) and [Service](./services.md) documentation for creation instructions. + +- Navigate to the service directory. The path should be as follows: `prowler/providers//services/`. + +- Create a check-specific folder. The path should follow this pattern: `prowler/providers//services//`. Adhere to the [Naming Format for Checks](#naming-format-for-checks). + +- Populate the folder with files as specified in [File Creation](#file-creation). + +### Naming Format for Checks + +Checks must be named following the format: `service_subservice_resource_action`. + +The name components are: + +- `service` – The main service being audited (e.g., ec2, entra, iam, etc.) +- `subservice` – An individual component or subset of functionality within the service that is being audited. This may correspond to a shortened version of the class attribute accessed within the check. If there is no subservice, just omit. +- `resource` – The specific resource type being evaluated (e.g., instance, policy, role, etc.) +- `action` – The security aspect or configuration being checked (e.g., public, encrypted, enabled, etc.) + +### File Creation + +Each check in Prowler follows a straightforward structure. Within the newly created folder, three files must be added to implement the check logic: + +- `__init__.py` (empty file) – Ensures Python treats the check folder as a package. +- `.py` (code file) – Contains the check logic, following the prescribed format. Please refer to the [prowler's check code structure](./checks.md#prowlers-check-code-structure) for more information. +- `.metadata.json` (metadata file) – Defines the check's metadata for contextual information. Please refer to the [check metadata](./checks.md#) for more information. + +## Prowler's Check Code Structure + +Prowler's check structure is designed for clarity and maintainability. It follows a dynamic loading approach based on predefined paths, ensuring seamless integration of new checks into a provider's service without additional manual steps. + +Below the code for a generic check is presented. It is strongly recommended to consult other checks from the same provider and service to understand provider-specific details and patterns. This will help ensure consistency and proper implementation of provider-specific requirements. + +Report fields are the most dependent on the provider, consult the `CheckReport` class for more information on what can be included in the report [here](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/check/models.py). ???+ note - A subservice is an specific component of a service that is gonna be audited. Sometimes it could be the shortened name of the class attribute that is gonna be accessed in the check. + Legacy providers (AWS, Azure, GCP, Kubernetes) follow the `Check_Report_` naming convention. This is not recommended for current instances. Newer providers adopt the `CheckReport` naming convention. Learn more at [Prowler Code](https://github.com/prowler-cloud/prowler/tree/master/prowler/lib/check/models.py). -Inside that folder, we need to create three files: +```python title="Generic Check Class" +# Required Imports +# Import the base Check class and the provider-specific CheckReport class +from prowler.lib.check.models import Check, CheckReport +# Import the provider service client +from prowler.providers..services.._client import _client -- An empty `__init__.py`: to make Python treat this check folder as a package. -- A `check_name.py` with the above format containing the check's logic. Refer to the [check](./checks.md#check) -- A `check_name.metadata.json` containing the check's metadata. Refer to the [check metadata](./checks.md#check-metadata) +# Defining the Check Class +# Each check must be implemented as a Python class with the same name as its corresponding file. +# The class must inherit from the Check base class. +class (Check): + """Short description of what is being checked""" -## Check - -The Prowler's check structure is very simple and following it there is nothing more to do to include a check in a provider's service because the load is done dynamically based on the paths. - -The following is the code for the `ec2_ami_public` check: -```python title="Check Class" -# At the top of the file we need to import the following: -# - Check class which is in charge of the following: -# - Retrieve the check metadata and expose the `metadata()` -# to return a JSON representation of the metadata, -# read more at Check Metadata Model down below. -# - Enforce that each check requires to have the `execute()` function -from prowler.lib.check.models import Check, Check_Report_AWS - -# Then you have to import the provider service client -# read more at the Service documentation. -from prowler.providers.aws.services.ec2.ec2_client import ec2_client - -# For each check we need to create a python class called the same as the -# file which inherits from the Check class. -class ec2_ami_public(Check): - """ec2_ami_public verifies if an EC2 AMI is publicly shared""" - - # Then, within the check's class we need to create the "execute(self)" - # function, which is enforce by the "Check" class to implement - # the Check's interface and let Prowler to run this check. def execute(self): + """Execute - # Inside the execute(self) function we need to create - # the list of findings initialised to an empty list [] + Returns: + List[CheckReport]: A list of reports containing the result of the check. + """ findings = [] - - # Then, using the service client we need to iterate by the resource we - # want to check, in this case EC2 AMIs stored in the - # "ec2_client.images" object. - for image in ec2_client.images: - - # Once iterating for the images, we have to intialise - # the Check_Report_AWS class passing the check's metadata - # using the "metadata" function explained above. - report = Check_Report_AWS(self.metadata()) - - # For each Prowler check we MUST fill the following - # Check_Report_AWS fields: - # - region - # - resource_id - # - resource_arn - # - resource_tags - # - status - # - status_extended - report.region = image.region - report.resource_id = image.id - report.resource_arn = image.arn - # The resource_tags should be filled if the resource has the ability - # of having tags, please check the service first. - report.resource_tags = image.tags - - # Then we need to create the business logic for the check - # which always should be simple because the Prowler service - # must do the heavy lifting and the check should be in charge - # of parsing the data provided + # Iterate over the target resources using the provider service client + for resource in _client.: + # Initialize the provider-specific report class, passing metadata and resource + report = Check_Report_(metadata=self.metadata(), resource=resource) + # Set required fields and implement check logic report.status = "PASS" - report.status_extended = f"EC2 AMI {image.id} is not public." - - # In this example each "image" object has a boolean attribute - # called "public" to set if the AMI is publicly shared - if image.public: + report.status_extended = f"" + # If some of the information needed for the report is not inside the resource, it can be set it manually here. + # This depends on the provider and the resource that is being audited. + # report.region = resource.region + # report.resource_tags = getattr(resource, "tags", []) + # ... + # Example check logic (replace with actual logic): + if : report.status = "FAIL" - report.status_extended = ( - f"EC2 AMI {image.id} is currently public." - ) - - # Then at the same level as the "report" - # object we need to append it to the findings list. + report.status_extended = f"" findings.append(report) - - # Last thing to do is to return the findings list to Prowler return findings ``` -### Check Status +### Data Requirements for Checks in Prowler -All the checks MUST fill the `report.status` and `report.status_extended` with the following criteria: +One of the most important aspects when creating a new check is ensuring that all required data is available from the service client. Often, default API calls are insufficient. Extending the service class with new methods or resource attributes may be required to fetch and store requisite data. -- Status -- `report.status` - - `PASS` --> If the check is passing against the configured value. - - `FAIL` --> If the check is failing against the configured value. - - `MANUAL` --> This value cannot be used unless a manual operation is required in order to determine if the `report.status` is whether `PASS` or `FAIL`. -- Status Extended -- `report.status_extended` - - MUST end in a dot `.` - - MUST include the service audited with the resource and a brief explanation of the result generated, e.g.: `EC2 AMI ami-0123456789 is not public.` +### Statuses for Checks in Prowler -### Check Region +Required Fields: status and status\_extended -All the checks MUST fill the `report.region` with the following criteria: +Each check **must** populate the `report.status` and `report.status_extended` fields according to the following criteria: -- If the audited resource is regional use the `region` (the name changes depending on the provider: `location` in Azure and GCP and `namespace` in K8s) attribute within the resource object. -- If the audited resource is global use the `service_client.region` within the service client object. +- Status field: `report.status` + - `PASS` – Assigned when the check confirms compliance with the configured value. + - `FAIL` – Assigned when the check detects non-compliance with the configured value. + - `MANUAL` – This status must not be used unless manual verification is necessary to determine whether the status (`report.status`) passes (`PASS`) or fails (`FAIL`). -### Check Severity +- Status extended field: `report.status_extended` + - It **must** end with a period (`.`). + - It **must** include the audited service, the resource, and a concise explanation of the check result, for instance: `EC2 AMI ami-0123456789 is not public.`. -The severity of the checks are defined in the metadata file with the `Severity` field. The severity is always in lowercase and can be one of the following values: +### Prowler's Check Severity Levels -- `critical` -- `high` -- `medium` -- `low` -- `informational` +The severity of each check is defined in the metadata file using the `Severity` field. Severity values are always lowercase and must be one of the predefined categories below. -You may need to change it in the check's code if the check has different scenarios that could change the severity. This can be done by using the `report.check_metadata.Severity` attribute: +- `critical` – Issue that must be addressed immediately. +- `high` – Issue that should be addressed as soon as possible. +- `medium` – Issue that should be addressed within a reasonable timeframe. +- `low` – Issue that can be addressed in the future. +- `informational` – Not an issue but provides valuable information. + +If the check involves multiple scenarios that may alter its severity, adjustments can be made dynamically within the check's logic using the severity `report.check_metadata.Severity` attribute: ```python -if : +if : report.status = "PASS" report.check_metadata.Severity = "informational" - report.status_extended = f"RDS Instance {db_instance.id} certificate has over 6 months of validity left." -elif : - report.status = "PASS" + report.status_extended = f" is compliant with ." +elif : + report.status = "FAIL" report.check_metadata.Severity = "low" - report.status_extended = f"RDS Instance {db_instance.id} certificate has between 3 and 6 months of validity." -elif : + report.status_extended = f" is not compliant with : ." +elif : report.status = "FAIL" report.check_metadata.Severity = "medium" - report.status_extended = f"RDS Instance {db_instance.id} certificate less than 3 months of validity." -elif : + report.status_extended = f" is not compliant with : ." +elif : report.status = "FAIL" report.check_metadata.Severity = "high" - report.status_extended = f"RDS Instance {db_instance.id} certificate less than 1 month of validity." + report.status_extended = f" is not compliant with : ." else: report.status = "FAIL" report.check_metadata.Severity = "critical" - report.status_extended = ( - f"RDS Instance {db_instance.id} certificate has expired." - ) + report.status_extended = f" is not compliant with : ." ``` -### Resource ID, Name and ARN -All the checks MUST fill the `report.resource_id` and `report.resource_arn` with the following criteria: + +### Resource Identification in Prowler + +Each check **must** populate the report with an unique identifier for the audited resource. This identifier or identifiers are going to depend on the provider and the resource that is being audited. Here are the criteria for each provider: - AWS - - Resouce ID and resource ARN: - - If the resource audited is the AWS account: - - `resource_id` -> AWS Account Number - - `resource_arn` -> AWS Account Root ARN - - If we can’t get the ARN from the resource audited, we create a valid ARN with the `resource_id` part as the resource audited. Examples: - - Bedrock -> `arn::bedrock:::model-invocation-logging` - - DirectConnect -> `arn::directconnect:::dxcon` - - If there is no real resource to audit we do the following: - - resource_id -> `resource_type/unknown` - - resource_arn -> `arn:::::/unknown` + + - Amazon Resource ID — `report.resource_id`. + - The resource identifier. This is the name of the resource, the ID of the resource, or a resource path. Some resource identifiers include a parent resource (sub-resource-type/parent-resource/sub-resource) or a qualifier such as a version (resource-type:resource-name:qualifier). + - If the resource ID cannot be retrieved directly from the audited resource, it can be extracted from the ARN. It is the last part of the ARN after the last slash (`/`) or colon (`:`). + - If no actual resource to audit exists, this format can be used: `/unknown` + + - Amazon Resource Name — `report.resource_arn`. + - The [Amazon Resource Name (ARN)](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference-arns.html) of the audited entity. + - If the ARN cannot be retrieved directly from the audited resource, construct a valid ARN using the `resource_id` component as the audited entity. Examples: + - Bedrock — `arn::bedrock:::model-invocation-logging`. + - DirectConnect — `arn::directconnect:::dxcon`. + - If no actual resource to audit exists, this format can be used: `arn:::::/unknown`. - Examples: - - AWS Security Hub -> `arn::security-hub:::hub/unknown` - - Access Analyzer -> `arn::access-analyzer:::analyzer/unknown` - - GuardDuty -> `arn::guardduty:::detector/unknown` + - AWS Security Hub — `arn::security-hub:::hub/unknown`. + - Access Analyzer — `arn::access-analyzer:::analyzer/unknown`. + - GuardDuty — `arn::guardduty:::detector/unknown`. + - GCP - - Resource ID -- `report.resource_id` - - GCP Resource --> Resource ID - - Resource Name -- `report.resource_name` - - GCP Resource --> Resource Name + + - Resource ID — `report.resource_id`. + - Resource ID represents the full, [unambiguous path to a resource](https://google.aip.dev/122#full-resource-names), known as the full resource name. Typically, it follows the format: `//{api_service/resource_path}`. + - If the resource ID cannot be retrieved directly from the audited resource, by default the resource name is used. + - Resource Name — `report.resource_name`. + - Resource Name usually refers to the name of a resource within its service. + - Azure - - Resource ID -- `report.resource_id` - - Azure Resource --> Resource ID - - Resource Name -- `report.resource_name` - - Azure Resource --> Resource Name -### Python Model -The following is the Python model for the check's class. + - Resource ID — `report.resource_id`. + - Resource ID represents the full Azure Resource Manager path to a resource, which follows the format: `/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/{resourceProviderNamespace}/{resourceType}/{resourceName}`. + - Resource Name — `report.resource_name`. + - Resource Name usually refers to the name of a resource within its service. + - If the [resource name](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules) cannot be retrieved directly from the audited resource, the last part of the resource ID can be used. -As per April 11th 2024 the `Check_Metadata_Model` can be found [here](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/check/models.py#L36-L82). +- Kubernetes -```python -class Check(ABC, Check_Metadata_Model): - """Prowler Check""" + - Resource ID — `report.resource_id`. + - The UID of the Kubernetes object. This is a system-generated string that uniquely identifies the object within the cluster for its entire lifetime. See [Kubernetes Object Names and IDs - UIDs](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids). + - Resource Name — `report.resource_name`. + - The name of the Kubernetes object. This is a client-provided string that must be unique for the resource type within a namespace (for namespaced resources) or cluster (for cluster-scoped resources). Names typically follow DNS subdomain or label conventions. See [Kubernetes Object Names and IDs - Names](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names). - def __init__(self, **data): - """Check's init function. Calls the CheckMetadataModel init.""" - # Parse the Check's metadata file - metadata_file = ( - os.path.abspath(sys.modules[self.__module__].__file__)[:-3] - + ".metadata.json" - ) - # Store it to validate them with Pydantic - data = Check_Metadata_Model.parse_file(metadata_file).dict() - # Calls parents init function - super().__init__(**data) +- M365 - def metadata(self) -> dict: - """Return the JSON representation of the check's metadata""" - return self.json() + - Resource ID — `report.resource_id`. + - If the audited resource has a globally unique identifier such as a `guid`, use it as the `resource_id`. + - If no `guid` exists, use another unique and relevant identifier for the resource, such as the tenant domain, the internal policy ID, or a representative string following the format `/`. + - Resource Name — `report.resource_name`. + - Use the visible or descriptive name of the audited resource. If no explicit name is available, use a clear description of the resource or configuration being evaluated. + - Examples: + - For an organization: + - `resource_id`: Organization GUID + - `resource_name`: Organization name + - For a policy: + - `resource_id`: Unique policy ID + - `resource_name`: Policy display name + - For global configurations: + - `resource_id`: Tenant domain or representative string (e.g., "userSettings") + - `resource_name`: Description of the configuration (e.g., "SharePoint Settings") - @abstractmethod - def execute(self): - """Execute the check's logic""" -``` +- GitHub -### Using the audit config + - Resource ID — `report.resource_id`. + - The ID of the Github resource. This is a system-generated integer that uniquely identifies the resource within the Github platform. + - Resource Name — `report.resource_name`. + - The name of the Github resource. In the case of a repository, this is just the repository name. For full repository names use the resource `full_name`. -Prowler has a [configuration file](../tutorials/configuration_file.md) which is used to pass certain configuration values to the checks, like the following: +### Using the Audit Configuration + +Prowler has a [configuration file](../tutorials/configuration_file.md) which is used to pass certain configuration values to the checks. For example: ```python title="ec2_securitygroup_with_many_ingress_egress_rules.py" class ec2_securitygroup_with_many_ingress_egress_rules(Check): def execute(self): findings = [] - # max_security_group_rules, default: 50 max_security_group_rules = ec2_client.audit_config.get( "max_security_group_rules", 50 ) for security_group_arn, security_group in ec2_client.security_groups.items(): ``` -```yaml title="config.yaml" -# AWS Configuration -aws: - # AWS EC2 Configuration +We use the `audit_config` object to retrieve the value of `max_security_group_rules`, which is the default value of 50 if the configuration value is not present. - # aws.ec2_securitygroup_with_many_ingress_egress_rules - # The default value is 50 rules - max_security_group_rules: 50 +The configuration file is located at [`prowler/config/config.yaml`](https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml) and is used to pass certain configuration values to the checks. For example: + +```yaml title="config.yaml" + aws: + max_security_group_rules: 50 ``` -As you can see in the above code, within the service client, in this case the `ec2_client`, there is an object called `audit_config` which is a Python dictionary containing the values read from the configuration file. - -In order to use it, you have to check first if the value is present in the configuration file. If the value is not present, you can create it in the `config.yaml` file and then, read it from the check. +This `audit_config` object is a Python dictionary that stores values read from the configuration file. It can be accessed by the check using the `audit_config` attribute of the service client. ???+ note - It is mandatory to always use the `dictionary.get(value, default)` syntax to set a default value in the case the configuration value is not present. + Always use the `dictionary.get(value, default)` syntax to ensure a default value is set when the configuration value is not present. +## Metadata Structure for Prowler Checks -## Check Metadata +Each Prowler check must include a metadata file named `.metadata.json` that must be located in its directory. This file supplies crucial information for execution, reporting, and context. -Each Prowler check has metadata associated which is stored at the same level of the check's folder in a file called A `check_name.metadata.json` containing the check's metadata. +### Example Metadata File -???+ note - We are going to include comments in this example metadata JSON but they cannot be included because the JSON format does not allow comments. +Below is a generic example of a check metadata file. **Do not include comments in actual JSON files.** ```json { - # Provider holds the Prowler provider which the checks belongs to "Provider": "aws", - # CheckID holds check name - "CheckID": "ec2_ami_public", - # CheckTitle holds the title of the check - "CheckTitle": "Ensure there are no EC2 AMIs set as Public.", - # CheckType holds Software and Configuration Checks, check more here - # https://docs.aws.amazon.com/securityhub/latest/userguide/asff-required-attributes.html#Types - "CheckType": [ - "Infrastructure Security" - ], - # ServiceName holds the provider service name + "CheckID": "example_check_id", + "CheckTitle": "Example Check Title", + "CheckType": ["Infrastructure Security"], "ServiceName": "ec2", - # SubServiceName holds the service's subservice or resource used by the check "SubServiceName": "ami", - # ResourceIdTemplate holds the unique ID for the resource used by the check "ResourceIdTemplate": "arn:partition:service:region:account-id:resource-id", - # Severity holds the check's severity, always in lowercase (critical, high, medium, low or informational) "Severity": "critical", - # ResourceType only for AWS, holds the type from here - # https://docs.aws.amazon.com/securityhub/latest/userguide/asff-resources.html - # In case of not existing, use CloudFormation type but removing the "::" and using capital letters only at the beginning of each word. Example: "AWS::EC2::Instance" -> "AwsEc2Instance" - # CloudFormation type reference: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-template-resource-type-ref.html - # If the resource type does not exist in the CloudFormation types, use "Other". "ResourceType": "Other", - # Description holds the title of the check, for now is the same as CheckTitle - "Description": "Ensure there are no EC2 AMIs set as Public.", - # Risk holds the check risk if the result is FAIL - "Risk": "When your AMIs are publicly accessible, they are available in the Community AMIs where everyone with an AWS account can use them to launch EC2 instances. Your AMIs could contain snapshots of your applications (including their data), therefore exposing your snapshots in this manner is not advised.", - # RelatedUrl holds an URL with more information about the check purpose - "RelatedUrl": "", - # Remediation holds the information to help the practitioner to fix the issue in the case of the check raise a FAIL + "Description": "Example description of the check.", + "Risk": "Example risk if the check fails.", + "RelatedUrl": "https://example.com", "Remediation": { - # Code holds different methods to remediate the FAIL finding "Code": { - # CLI holds the command in the provider native CLI to remediate it - "CLI": "aws ec2 modify-image-attribute --region --image-id --launch-permission {\"Remove\":[{\"Group\":\"all\"}]}", - # NativeIaC holds the native IaC code to remediate it, use "https://docs.bridgecrew.io/docs" + "CLI": "example CLI command", "NativeIaC": "", - # Other holds the other commands, scripts or code to remediate it, use "https://www.trendmicro.com/cloudoneconformity" - "Other": "https://docs.prowler.com/checks/public_8#aws-console", - # Terraform holds the Terraform code to remediate it, use "https://docs.bridgecrew.io/docs" + "Other": "", "Terraform": "" }, - # Recommendation holds the recommendation for this check with a description and a related URL "Recommendation": { - "Text": "We recommend your EC2 AMIs are not publicly accessible, or generally available in the Community AMIs.", - "Url": "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/cancel-sharing-an-AMI.html" + "Text": "Example recommendation text.", + "Url": "https://example.com/remediation" } }, - # Categories holds the category or categories where the check can be included, if applied - "Categories": [ - "internet-exposed" - ], - # DependsOn is not actively used for the moment but it will hold other - # checks wich this check is dependant to + "Categories": ["example-category"], "DependsOn": [], - # RelatedTo is not actively used for the moment but it will hold other - # checks wich this check is related to "RelatedTo": [], - # Notes holds additional information not covered in this file "Notes": "" } ``` -### Remediation Code +### Metadata Fields and Their Purpose -For the Remediation Code we use the following knowledge base to fill it: +- **Provider** — The Prowler provider related to the check. The name **must** be lowercase and match the provider folder name. For supported providers refer to [Prowler Hub](https://hub.prowler.com/check) or directly to [Prowler Code](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers). -- Official documentation for the provider -- https://docs.prowler.com/checks/checks-index -- https://www.trendmicro.com/cloudoneconformity -- https://github.com/cloudmatos/matos/tree/master/remediations +- **CheckID** — The unique identifier for the check inside the provider, this field **must** match the check's folder and python file and json metadata file name. For more information about the naming refer to the [Naming Format for Checks](#naming-format-for-checks) section. -### RelatedURL and Recommendation +- **CheckTitle** — A concise, descriptive title for the check. -The RelatedURL field must be filled with an URL from the provider's official documentation like https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/sharingamis-intro.html +- **CheckType** — *For now this field is only standardized for the AWS provider*. + - For AWS this field must follow the [AWS Security Hub Types](https://docs.aws.amazon.com/securityhub/latest/userguide/asff-required-attributes.html#Types) format. So the common pattern to follow is `namespace/category/classifier`, refer to the attached documentation for the valid values for this fields. -Also, if not present you can use the Risk and Recommendation texts from the TrendMicro [CloudConformity](https://www.trendmicro.com/cloudoneconformity) guide. +- **ServiceName** — The name of the provider service being audited. This field **must** be in lowercase and match with the service folder name. For supported services refer to [Prowler Hub](https://hub.prowler.com/check) or directly to [Prowler Code](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers). +- **SubServiceName** — The subservice or resource within the service, if applicable. For more information refer to the [Naming Format for Checks](#naming-format-for-checks) section. -### Python Model -The following is the Python model for the check's metadata model. We use the Pydantic's [BaseModel](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel) as the parent class. +- **ResourceIdTemplate** — A template for the unique resource identifier. For more information refer to the [Prowler's Resource Identification](#prowlers-resource-identification) section. -As per August 5th 2023 the `Check_Metadata_Model` can be found [here](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/check/models.py#L34-L56). -```python -class Check_Metadata_Model(BaseModel): - """Check Metadata Model""" +- **Severity** — The severity of the finding if the check fails. Must be one of: `critical`, `high`, `medium`, `low`, or `informational`, this field **must** be in lowercase. To get more information about the severity levels refer to the [Prowler's Check Severity Levels](#prowlers-check-severity-levels) section. - Provider: str - CheckID: str - CheckTitle: str - CheckType: list[str] - ServiceName: str - SubServiceName: str - ResourceIdTemplate: str - Severity: str - ResourceType: str - Description: str - Risk: str - RelatedUrl: str - Remediation: Remediation - Categories: list[str] - DependsOn: list[str] - RelatedTo: list[str] - Notes: str - # We set the compliance to None to - # store the compliance later if supplied - Compliance: list = None -``` +- **ResourceType** — The type of resource being audited. *For now this field is only standardized for the AWS provider*. + + - For AWS use the [Security Hub resource types](https://docs.aws.amazon.com/securityhub/latest/userguide/asff-resources.html) or, if not available, the PascalCase version of the [CloudFormation type](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-template-resource-type-ref.html) (e.g., `AwsEc2Instance`). Use "Other" if no match exists. + +- **Description** — A short description of what the check does. + +- **Risk** — The risk or impact if the check fails, explaining why the finding matters. + +- **RelatedUrl** — A URL to official documentation or further reading about the check's purpose. If no official documentation is available, use the risk and recommendation text from trusted third-party sources. + +- **Remediation** — Guidance for fixing a failed check, including: + + - **Code** — Remediation commands or code snippets for CLI, Terraform, native IaC, or other tools like the Web Console. + + - **Recommendation** — A textual human readable recommendation. Here it is not necessary to include actual steps, but rather a general recommendation about what to do to fix the check. + +- **Categories** — One or more categories for grouping checks in execution (e.g., `internet-exposed`). For the current list of categories, refer to the [Prowler Hub](https://hub.prowler.com/check). + +- **DependsOn** — Currently not used. + +- **RelatedTo** — Currently not used. + +- **Notes** — Any additional information not covered by other fields. + +### Remediation Code Guidelines + +When providing remediation steps, reference the following sources: + +- Official provider documentation. +- [Prowler Checks Remediation Index](https://docs.prowler.com/checks/checks-index) +- [TrendMicro Cloud One Conformity](https://www.trendmicro.com/cloudoneconformity) +- [CloudMatos Remediation Repository](https://github.com/cloudmatos/matos/tree/master/remediations) + +### Python Model Reference + +The metadata structure is enforced in code using a Pydantic model. For reference, see the [`CheckMetadata`](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/check/models.py). diff --git a/docs/developer-guide/debugging.md b/docs/developer-guide/debugging.md index bf51d313c1..a323ec0187 100644 --- a/docs/developer-guide/debugging.md +++ b/docs/developer-guide/debugging.md @@ -1,14 +1,16 @@ -# Debugging +# Debugging in Prowler -Debugging in Prowler make things easier! -If you are developing Prowler, it's possible that you will encounter some situations where you have to inspect the code in depth to fix some unexpected issues during the execution. +Debugging in Prowler simplifies the development process, allowing developers to efficiently inspect and resolve unexpected issues during execution. -## VSCode +## Debugging with Visual Studio Code -In VSCode you can run the code using the integrated debugger. Please, refer to this [documentation](https://code.visualstudio.com/docs/editor/debugging) for guidance about the debugger in VSCode. -The following file is an example of the [debugging configuration](https://code.visualstudio.com/docs/editor/debugging#_launch-configurations) file that you can add to [Virtual Studio Code](https://code.visualstudio.com/). +Visual Studio Code (also referred to as VSCode) provides an integrated debugger for executing and analyzing Prowler code. Refer to the official VSCode debugger [documentation](https://code.visualstudio.com/docs/editor/debugging) for detailed instructions. -This file should inside the *.vscode* folder and its name has to be *launch.json*: +### Debugging Configuration Example + +The following file is an example of a [debugging configuration](https://code.visualstudio.com/docs/editor/debugging#_launch-configurations) file for [Virtual Studio Code](https://code.visualstudio.com/). + +This file must be placed inside the *.vscode* directory and named *launch.json*: ```json { diff --git a/docs/developer-guide/documentation.md b/docs/developer-guide/documentation.md index 8433370849..50ac0956e7 100644 --- a/docs/developer-guide/documentation.md +++ b/docs/developer-guide/documentation.md @@ -1,8 +1,28 @@ -## Contribute with documentation +## Contributing to Documentation -We use `mkdocs` to build this Prowler documentation site so you can easily contribute back with new docs or improving them. To install all necessary dependencies use `poetry install --with docs`. +Prowler documentation is built using `mkdocs`, allowing contributors to easily add or enhance documentation. -1. Install `mkdocs` with your favorite package manager. -2. Inside the `prowler` repository folder run `mkdocs serve` and point your browser to `http://localhost:8000` and you will see live changes to your local copy of this documentation site. -3. Make all needed changes to docs or add new documents. To do so just edit existing md files inside `prowler/docs` and if you are adding a new section or file please make sure you add it to `mkdocs.yaml` file in the root folder of the Prowler repo. -4. Once you are done with changes, please send a pull request to us for review and merge. Thank you in advance! +### Installation and Setup + +Install all necessary dependencies using: `poetry install --with docs`. + +1. Install `mkdocs` using your preferred package manager. + +2. Running the Documentation Locally +Navigate to the `prowler` repository folder. +Start the local documentation server by running: `mkdocs serve`. +Open `http://localhost:8000` in your browser to view live updates. + +3. Making Documentation Changes +Make all needed changes to docs or add new documents. Edit existing Markdown (.md) files inside `prowler/docs`. +To add new sections or files, update the `mkdocs.yaml` file located in the root directory of Prowler’s repository. + +4. Submitting Changes + +Once documentation updates are complete: + +Submit a pull request for review. + +The Prowler team will assess and merge contributions. + +Your efforts help improve Prowler documentation—thank you for contributing! diff --git a/docs/developer-guide/gcp-details.md b/docs/developer-guide/gcp-details.md new file mode 100644 index 0000000000..f9a7ecbd01 --- /dev/null +++ b/docs/developer-guide/gcp-details.md @@ -0,0 +1,133 @@ +# Google Cloud Provider + +This page details the [Google Cloud Platform (GCP)](https://cloud.google.com/) provider implementation in Prowler. + +By default, Prowler will audit all the GCP projects that the authenticated identity can access. To configure it, follow the [getting started](../index.md#google-cloud) page. + +## GCP Provider Classes Architecture + +The GCP provider implementation follows the general [Provider structure](./provider.md). This section focuses on the GCP-specific implementation, highlighting how the generic provider concepts are realized for GCP in Prowler. For a full overview of the provider pattern, base classes, and extension guidelines, see [Provider documentation](./provider.md). + +### Main Class + +- **Location:** [`prowler/providers/gcp/gcp_provider.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/gcp/gcp_provider.py) +- **Base Class:** Inherits from `Provider` (see [base class details](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/common/provider.py)). +- **Purpose:** Central orchestrator for GCP-specific logic, session management, credential validation, project and organization discovery, and configuration. +- **Key GCP Responsibilities:** + - Initializes and manages GCP sessions (supports Application Default Credentials, Service Account, OAuth, and impersonation). + - Validates credentials and sets up the GCP identity context. + - Loads and manages configuration, mutelist, and fixer settings. + - Discovers accessible GCP projects and organization metadata. + - Provides properties and methods for downstream GCP service classes to access session, identity, and configuration data. + +### Data Models + +- **Location:** [`prowler/providers/gcp/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/gcp/models.py) +- **Purpose:** Define structured data for GCP identity, project, and organization info. +- **Key GCP Models:** + - `GCPIdentityInfo`: Holds GCP identity metadata, such as the profile name. + - `GCPOrganization`: Represents a GCP organization with ID, name, and display name. + - `GCPProject`: Represents a GCP project with number, ID, name, organization, labels, and lifecycle state. + +### `GCPService` (Service Base Class) + +- **Location:** [`prowler/providers/gcp/lib/service/service.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/gcp/lib/service/service.py) +- **Purpose:** Abstract base class that all GCP service-specific classes inherit from. This implements the generic service pattern (described in [service page](./services.md#service-base-class)) specifically for GCP. +- **Key GCP Responsibilities:** + - Receives a `GcpProvider` instance to access session, identity, and configuration. + - Manages clients for all services by project. + - Filters projects to only those with the relevant API enabled. + - Provides `__threading_call__` method to make API calls in parallel by project or resource. + - Exposes common audit context (`project_ids`, `projects`, `default_project_id`, `audit_config`, `fixer_config`) to subclasses. + +### Exception Handling + +- **Location:** [`prowler/providers/gcp/exceptions/exceptions.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/gcp/exceptions/exceptions.py) +- **Purpose:** Custom exception classes for GCP-specific error handling, such as credential, session, and project access errors. + +### Session and Utility Helpers + +- **Location:** [`prowler/providers/gcp/lib/`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/gcp/lib/) +- **Purpose:** Helpers for argument parsing, mutelist management, and other cross-cutting concerns. + +## Specific Patterns in GCP Services + +The generic service pattern is described in [service page](./services.md#service-structure-and-initialisation). You can find all the currently implemented services in the following locations: + +- Directly in the code, in location [`prowler/providers/gcp/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/gcp/services) +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. + +The best reference to understand how to implement a new service is following the [service implementation documentation](./services.md#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used accross all GCP services. + +### GCP Service Common Patterns + +- Services communicate with GCP using the Google Cloud Python SDK, you can find the documentation with all the services [here](https://cloud.google.com/python/docs/reference). +- Every GCP service class inherits from `GCPService`, ensuring access to session, identity, configuration, and client utilities. +- The constructor (`__init__`) always calls `super().__init__` with the service name, provider, region (default "global"), and API version (default "v1"). Usually, the service name is the class name in lowercase, so it is called like `super().__init__(__class__.__name__, provider)`. +- Resource containers **must** be initialized in the constructor, typically as dictionaries keyed by resource ID and the value is the resource object. +- Only projects with the API enabled are included in the audit scope. +- Resource discovery and attribute collection can be parallelized using `self.__threading_call__`, typically by region/zone or resource. +- All GCP resources are represented as Pydantic `BaseModel` classes, providing type safety and structured access to resource attributes. +- Each GCP API calls are wrapped in try/except blocks, always logging errors. +- Tags and additional attributes that cannot be retrieved from the default call should be collected and stored for each resource using dedicated methods and threading. + +## Specific Patterns in GCP Checks + +The GCP checks pattern is described in [checks page](./checks.md). You can find all the currently implemented checks: + +- Directly in the code, within each service folder, each check has its own folder named after the name of the check. (e.g. [`prowler/providers/gcp/services/iam/iam_sa_user_managed_key_unused/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/gcp/services/iam/iam_sa_user_managed_key_unused)) +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. + +The best reference to understand how to implement a new check is following the [GCP check implementation documentation](./checks.md#creating-a-check) and taking other similar checks as reference. + +### Check Report Class + +The `Check_Report_GCP` class models a single finding for a GCP resource in a check report. It is defined in [`prowler/lib/check/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/check/models.py) and inherits from the generic `Check_Report` base class. + +#### Purpose + +`Check_Report_GCP` extends the base report structure with GCP-specific fields, enabling detailed tracking of the resource, project, and location associated with each finding. + +#### Constructor and Attribute Population + +When you instantiate `Check_Report_GCP`, you must provide the check metadata and a resource object. The class will attempt to automatically populate its GCP-specific attributes from the resource, using the following logic (in order of precedence): + +- **`resource_id`**: + - Uses the explicit `resource_id` argument if provided. + - Otherwise, uses `resource.id` if present. + - Otherwise, uses `resource.name` if present. + - Defaults to an empty string if none are available. + +- **`resource_name`**: + - Uses the explicit `resource_name` argument if provided. + - Otherwise, uses `resource.name` if present. + - Defaults to an empty string. + +- **`project_id`**: + - Uses the explicit `project_id` argument if provided. + - Otherwise, uses `resource.project_id` if present. + - Defaults to an empty string. + +- **`location`**: + - Uses the explicit `location` argument if provided. + - Otherwise, uses `resource.location` if present. + - Otherwise, uses `resource.region` if present. + - Defaults to "global" if none are available. + +All these attributes can be overridden by passing the corresponding argument to the constructor. If the resource object does not contain the required attributes, you must set them manually. +Others attributes are inherited from the `Check_Report` class, from that ones you **always** have to set the `status` and `status_extended` attributes in the check logic. + +#### Example Usage + +```python +report = Check_Report_GCP( + metadata=check_metadata, + resource=resource_object, + resource_id="custom-id", # Optional override + resource_name="custom-name", # Optional override + project_id="my-gcp-project", # Optional override + location="us-central1" # Optional override +) +report.status = "PASS" +report.status_extended = "Resource is compliant." +``` diff --git a/docs/developer-guide/github-details.md b/docs/developer-guide/github-details.md new file mode 100644 index 0000000000..0dc3c5460d --- /dev/null +++ b/docs/developer-guide/github-details.md @@ -0,0 +1,116 @@ +# GitHub Provider + +This page details the [GitHub](https://github.com/) provider implementation in Prowler. + +By default, Prowler will audit the GitHub account - scanning all repositories, organizations, and applications that your configured credentials can access. To configure it, follow the [getting started](../index.md#github) page. + +## GitHub Provider Classes Architecture + +The GitHub provider implementation follows the general [Provider structure](./provider.md). This section focuses on the GitHub-specific implementation, highlighting how the generic provider concepts are realized for GitHub in Prowler. For a full overview of the provider pattern, base classes, and extension guidelines, see [Provider documentation](./provider.md). + +### `GithubProvider` (Main Class) + +- **Location:** [`prowler/providers/github/github_provider.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/github/github_provider.py) +- **Base Class:** Inherits from `Provider` (see [base class details](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/common/provider.py)). +- **Purpose:** Central orchestrator for GitHub-specific logic, session management, credential validation, and configuration. +- **Key GitHub Responsibilities:** + - Initializes and manages GitHub sessions (supports Personal Access Token, OAuth App, and GitHub App authentication). + - Validates credentials and sets up the GitHub identity context. + - Loads and manages configuration, mutelist, and fixer settings. + - Provides properties and methods for downstream GitHub service classes to access session, identity, and configuration data. + +### Data Models + +- **Location:** [`prowler/providers/github/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/github/models.py) +- **Purpose:** Define structured data for GitHub identity, session, and output options. +- **Key GitHub Models:** + - `GithubSession`: Holds authentication tokens and keys for the session. + - `GithubIdentityInfo`, `GithubAppIdentityInfo`: Store account or app identity metadata. + +### `GithubService` (Service Base Class) + +- **Location:** [`prowler/providers/github/lib/service/service.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/github/lib/service/service.py) +- **Purpose:** Abstract base class for all GitHub service-specific classes. +- **Key GitHub Responsibilities:** + - Receives a `GithubProvider` instance to access session, identity, and configuration. + - Manages GitHub API clients for the authenticated user or app. + - Exposes common audit context (`audit_config`, `fixer_config`) to subclasses. + +### Exception Handling + +- **Location:** [`prowler/providers/github/exceptions/exceptions.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/github/exceptions/exceptions.py) +- **Purpose:** Custom exception classes for GitHub-specific error handling, such as credential and session errors. + +### Session and Utility Helpers + +- **Location:** [`prowler/providers/github/lib/`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/github/lib/) +- **Purpose:** Helpers for argument parsing, mutelist management, and other cross-cutting concerns. + +## Specific Patterns in GitHub Services + +The generic service pattern is described in [service page](./services.md#service-structure-and-initialisation). You can find all the currently implemented services in the following locations: + +- Directly in the code, in location [`prowler/providers/github/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/github/services) +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. + +The best reference to understand how to implement a new service is following the [service implementation documentation](./services.md#adding-a-new-service) and by taking other already implemented services as reference. + +### GitHub Service Common Patterns + +- Services communicate with GitHub using the PyGithub Python SDK. See the [official documentation](https://pygithub.readthedocs.io/). +- Every GitHub service class inherits from `GithubService`, ensuring access to session, identity, configuration, and client utilities. +- The constructor (`__init__`) always calls `super().__init__` with the service name and provider (e.g. `super().__init__(__class__.__name__, provider))`). Ensure that the service name in PyGithub is the same that you use in the constructor. Usually is used the `__class__.__name__` to get the service name because it is the same as the class name. +- Resource containers **must** be initialized in the constructor, typically as dictionaries keyed by resource ID or name. +- All GitHub resources are represented as Pydantic `BaseModel` classes, providing type safety and structured access to resource attributes. +- GitHub API calls are wrapped in try/except blocks, always logging errors. + +## Specific Patterns in GitHub Checks + +The GitHub checks pattern is described in [checks page](./checks.md). You can find all the currently implemented checks in: + +- Directly in the code, within each service folder, each check has its own folder named after the name of the check. (e.g. [`prowler/providers/github/services/repository/repository_secret_scanning_enabled/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/github/services/repository/repository_secret_scanning_enabled)) +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. + +The best reference to understand how to implement a new check is the [GitHub check implementation documentation](./checks.md#creating-a-check) and by taking other checks as reference. + +### Check Report Class + +The `CheckReportGithub` class models a single finding for a GitHub resource in a check report. It is defined in [`prowler/lib/check/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/check/models.py) and inherits from the generic `Check_Report` base class. + +#### Purpose + +`CheckReportGithub` extends the base report structure with GitHub-specific fields, enabling detailed tracking of the resource, name, and owner associated with each finding. + +#### Constructor and Attribute Population + +When you instantiate `CheckReportGithub`, you must provide the check metadata and a resource object. The class will attempt to automatically populate its GitHub-specific attributes from the resource, using the following logic (in order of precedence): + +- **`resource_id`**: + - Uses the explicit `resource_id` argument if provided. + - Otherwise, uses `resource.id` if present. + - Defaults to an empty string if not available. + +- **`resource_name`**: + - Uses the explicit `resource_name` argument if provided. + - Otherwise, uses `resource.name` if present. + - Defaults to an empty string if not available. + +- **`owner`**: + - Uses the explicit `owner` argument if provided. + - Otherwise, uses `resource.owner` for repositories and `resource.name` for organizations. + - Defaults to an empty string if not available. + +If the resource object does not contain the required attributes, you must set them manually in the check logic. + +Other attributes are inherited from the `Check_Report` class, from which you **always** have to set the `status` and `status_extended` attributes in the check logic. + +#### Example Usage + +```python +report = CheckReportGithub( + metadata=check_metadata, + resource=resource_object +) +report.status = "PASS" +report.status_extended = "Resource is compliant." +``` diff --git a/docs/developer-guide/integration-testing.md b/docs/developer-guide/integration-testing.md index d188b8288a..0d03319c5e 100644 --- a/docs/developer-guide/integration-testing.md +++ b/docs/developer-guide/integration-testing.md @@ -1,3 +1,3 @@ # Integration Tests -Coming soon ... +Coming soon ... \ No newline at end of file diff --git a/docs/developer-guide/integrations.md b/docs/developer-guide/integrations.md index 340df30119..e3f6789a8e 100644 --- a/docs/developer-guide/integrations.md +++ b/docs/developer-guide/integrations.md @@ -2,66 +2,89 @@ ## Introduction -Integrating Prowler with external tools enhances its functionality and seamlessly embeds it into your workflows. Prowler supports a wide range of integrations to streamline security assessments and reporting. Common integration targets include messaging platforms like Slack, project management tools like Jira, and cloud services such as AWS Security Hub. +Integrating Prowler with external tools enhances its functionality and enables seamless workflow automation. Prowler supports a variety of integrations to optimize security assessments and reporting. -* Consult the [Prowler Developer Guide](https://docs.prowler.com/projects/prowler-open-source/en/latest/) to understand how Prowler works and the way that you can integrate it with the desired product! -* Identify the best approach for the specific platform you’re targeting. +### Supported Integration Targets + +- Messaging Platforms – Example: Slack + +- Project Management Tools – Example: Jira + +- Cloud Services – Example: AWS Security Hub + +### Integration Guidelines +To integrate Prowler with a specific product: + +Refer to the [Prowler Developer Guide](https://docs.prowler.com/projects/prowler-open-source/en/latest/) to understand its architecture and integration mechanisms. + +* Identify the most suitable integration method for the intended platform. ## Steps to Create an Integration -### Identify the Integration Purpose +### Defining the Integration Purpose -* Clearly define the objective of the integration. For example: - * Sending Prowler findings to a platform for alerts, tracking, or further analysis. - * Review existing integrations in the [`prowler/lib/outputs`](https://github.com/prowler-cloud/prowler/tree/master/prowler/lib/outputs) folder for inspiration and implementation examples. +* Before implementing an integration, clearly define its objective. Common purposes include: -### Develop the Integration + * Sending Prowler findings to a platform for alerting, tracking, or further analysis. + * For inspiration and implementation examples, please review the existing integrations in the [`prowler/lib/outputs`](https://github.com/prowler-cloud/prowler/tree/master/prowler/lib/outputs) folder. + +### Developing the Integration * Script Development: + * Write a script to process Prowler’s output and interact with the target platform’s API. - * For example, to send findings, parse Prowler’s results and use the platform’s API to create entries or notifications. + * If the goal is to send findings, parse Prowler’s results and use the platform’s API to create entries or notifications. + * Configuration: - * Ensure your script includes configurable options for environment-specific settings, such as API endpoints and authentication tokens. + + * Ensure the script supports environment-specific settings, such as: + + - API endpoints + + - Authentication tokens + + - Any necessary configurable parameters. ### Fundamental Structure * Integration Class: - * Create a class that encapsulates attributes and methods for the integration. - Here is an example with Jira integration: + + * To implement an integration, create a class that encapsulates the required attributes and methods for interacting with the target platform. Example: Jira Integration + ```python title="Jira Class" class Jira: """ Jira class to interact with the Jira API [Note] - This integration is limited to a single Jira Cloud, therefore all the issues will be created for same Jira Cloud ID. We will need to work on the ability of providing a Jira Cloud ID if the user is present in more than one. + This integration is limited to a single Jira Cloud instance, meaning all issues will be created under the same Jira Cloud ID. Future improvements will include the ability to specify a Jira Cloud ID for users associated with multiple accounts. - Attributes: - - _redirect_uri: The redirect URI - - _client_id: The client ID + Attributes + - _redirect_uri: The redirect URI used + - _client_id: The client identifier - _client_secret: The client secret - _access_token: The access token - _refresh_token: The refresh token - _expiration_date: The authentication expiration - - _cloud_id: The cloud ID + - _cloud_id: The cloud identifier - _scopes: The scopes needed to authenticate, read:jira-user read:jira-work write:jira-work - AUTH_URL: The URL to authenticate with Jira - PARAMS_TEMPLATE: The template for the parameters to authenticate with Jira - TOKEN_URL: The URL to get the access token from Jira - API_TOKEN_URL: The URL to get the accessible resources from Jira - Methods: - - __init__: Initialize the Jira object - - input_authorization_code: Input the authorization code - - auth_code_url: Generate the URL to authorize the application - - get_auth: Get the access token and refresh token - - get_cloud_id: Get the cloud ID from Jira - - get_access_token: Get the access token - - refresh_access_token: Refresh the access token from Jira - - test_connection: Test the connection to Jira and return a Connection object - - get_projects: Get the projects from Jira - - get_available_issue_types: Get the available issue types for a project - - send_findings: Send the findings to Jira and create an issue + Methods + __init__: Initializes the Jira object + - input_authorization_code: Inputs the authorization code + - auth_code_url: Generates the URL to authorize the application + - get_auth: Gets the access token and refreshes it + - get_cloud_id: Gets the cloud identifier from Jira + - get_access_token: Gets the access token + - refresh_access_token: Refreshes the access token from Jira + - test_connection: Tests the connection to Jira and returns a Connection object + - get_projects: Gets the projects from Jira + - get_available_issue_types: Gets the available issue types for a project + - send_findings: Sends the findings to Jira and creates an issue Raises: - JiraGetAuthResponseError: Failed to get the access token and refresh token @@ -128,9 +151,17 @@ Integrating Prowler with external tools enhances its functionality and seamlessl # More properties and methods ``` + * Test Connection Method: - * Implement a method to validate credentials or tokens, ensuring the connection to the target platform is successful. - The following is the code for the `test_connection` method for the `Jira` class: + + * Validating Credentials or Tokens + + To ensure a successful connection to the target platform, implement a method that validates authentication credentials or tokens. + + #### Method Implementation + + The following example demonstrates the `test_connection` method for the `Jira` class: + ```python title="Test connection" @staticmethod def test_connection( @@ -142,8 +173,8 @@ Integrating Prowler with external tools enhances its functionality and seamlessl """Test the connection to Jira Args: - - redirect_uri: The redirect URI - - client_id: The client ID + - redirect_uri: The redirect URI used + - client_id: The client identifier - client_secret: The client secret - raise_on_exception: Whether to raise an exception or not @@ -215,9 +246,15 @@ Integrating Prowler with external tools enhances its functionality and seamlessl ) return Connection(is_connected=False, error=error) ``` + * Send Findings Method: + * Add a method to send Prowler findings to the target platform, adhering to its API specifications. - The following is the code for the `send_findings` method for the `Jira` class: + + #### Method Implementation + + The following example demonstrates the `send_findings` method for the `Jira` class: + ```python title="Send findings method" def send_findings( self, @@ -321,16 +358,19 @@ Integrating Prowler with external tools enhances its functionality and seamlessl ) ``` -### Testing +### Testing the Integration -* Test the integration in a controlled environment to confirm it behaves as expected. -* Verify that Prowler’s findings are accurately transmitted and correctly processed by the target platform. -* Simulate edge cases to ensure robust error handling. +* Conduct integration testing in a controlled environment to validate expected behavior. Ensure the following: + + * Transmission Accuracy – Verify that Prowler findings are correctly sent and processed by the target platform. + * Error Handling – Simulate edge cases to assess robustness and failure recovery mechanisms. ### Documentation -* Provide clear, detailed documentation for your integration: - * Setup instructions, including any required dependencies. - * Configuration details, such as environment variables or authentication steps. - * Example use cases and troubleshooting tips. -* Good documentation ensures maintainability and simplifies onboarding for team members. +* Ensure the following elements are included: + + * Setup Instructions – List all necessary dependencies and installation steps. + * Configuration Details – Specify required environment variables, authentication steps, etc. + * Example Use Cases – Provide practical scenarios demonstrating functionality. + * Troubleshooting Guide – Document common issues and resolution steps. + * Comprehensive and clear documentation improves maintainability and simplifies onboarding. diff --git a/docs/developer-guide/introduction.md b/docs/developer-guide/introduction.md index 93a1ac7f36..abc83fb682 100644 --- a/docs/developer-guide/introduction.md +++ b/docs/developer-guide/introduction.md @@ -1,75 +1,176 @@ -# Developer Guide +# Introduction to developing in Prowler -You can extend Prowler Open Source in many different ways, in most cases you will want to create your own checks and compliance security frameworks, here is where you can learn about how to get started with it. We also include how to create custom outputs, integrations and more. +Extending Prowler -## Get the code and install all dependencies +Prowler can be extended in various ways, with common use cases including: -First of all, you need a version of Python 3.9 or higher and also `pip` installed to be able to install all dependencies required. +- New security checks +- New compliance frameworks +- New output formats +- New integrations +- New proposed features -Then, to start working with the Prowler Github repository you need to fork it to be able to propose changes for new features, bug fixing, etc. To fork the Prowler repo please refer to [this guide](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo?tool=webui#forking-a-repository). +All the relevant information for these cases is included in this guide. -Once that is satisfied go ahead and clone your forked repo: +## Getting the Code and Installing All Dependencies + +### Prerequisites + +Before proceeding, ensure the following: + +- Git is installed. +- Python 3.9 or higher is installed. +- `poetry` is installed to manage dependencies. + +### Forking the Prowler Repository + +To contribute to Prowler, fork the Prowler GitHub repository. This allows you to propose changes, submit new features, and fix bugs. For guidance on forking, refer to the [official GitHub documentation](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo?tool=webui#forking-a-repository). + +### Cloning Your Forked Repository + +Once your fork is created, clone it using the following commands: ``` git clone https://github.com//prowler cd prowler ``` -For isolation and to avoid conflicts with other environments, we recommend using `poetry`, a Python dependency management tool. You can install it by following the instructions [here](https://python-poetry.org/docs/#installation). -Then install all dependencies including the ones for developers: +### Dependency Management and Environment Isolation + +To prevent conflicts between environments, we recommend using `poetry`, a Python dependency management solution. Install it by following the [instructions](https://python-poetry.org/docs/#installation). + +### Installing Dependencies + +To install all required dependencies, including those needed for development, run: + ``` poetry install --with dev -eval $(poetry env activate) \ +eval $(poetry env activate) ``` -> [!IMPORTANT] -> Starting from Poetry v2.0.0, `poetry shell` has been deprecated in favor of `poetry env activate`. -> -> If your poetry version is below 2.0.0 you must keep using `poetry shell` to activate your environment. -> In case you have any doubts, consult the Poetry environment activation guide: https://python-poetry.org/docs/managing-environments/#activating-the-environment -## Contributing with your code or fixes to Prowler +???+ important + Starting from Poetry v2.0.0, `poetry shell` has been deprecated in favor of `poetry env activate`. + If your poetry version is below 2.0.0 you must keep using `poetry shell` to activate your environment. + In case you have any doubts, consult the [Poetry environment activation guide](https://python-poetry.org/docs/managing-environments/#activating-the-environment). + +## Contributing to Prowler + +### Ways to Contribute + +Here are some ideas for collaborating with Prowler: + +1. **Review Current Issues**: Check out our [GitHub Issues](https://github.com/prowler-cloud/prowler/issues) page. We often tag issues as `good first issue` - these are perfect for new contributors as they are typically well-defined and manageable in scope. + +2. **Expand Prowler's Capabilities**: Prowler is constantly evolving, and you can be a part of its growth. Whether you are adding checks, supporting new services, or introducing integrations, your contributions help improve the tool for everyone. Here is how you can get involved: + + - **Adding New Checks** + Want to improve Prowler's detection capabilities for your favorite cloud provider? You can contribute by writing new checks. To get started, follow the [create a new check guide](./checks.md). + + - **Adding New Services** + One key service for your favorite cloud provider is missing? Add it to Prowler! To add a new service, check out the [create a new service guide](./services.md). Do not forget to include relevant checks to validate functionality. + + - **Adding New Providers** + If you would like to extend Prowler to work with a new cloud provider, follow the [create a new provider guide](./provider.md). This typically involves setting up new services and checks to ensure compatibility. + + - **Adding New Output Formats** + Want to tailor how results are displayed or exported? You can add custom output formats by following the [create a new output format guide](./outputs.md). + + - **Adding New Integrations** + Prowler can work with other tools and platforms through integrations. If you would like to add one, see the [create a new integration guide](./integrations.md). + + - **Proposing or Implementing Features** + Got an idea to make Prowler better? Whether it is a brand-new feature or an enhancement to an existing one, you are welcome to propose it or help implement community-requested improvements. + +3. **Improve Documentation**: Help make Prowler more accessible by enhancing our documentation, fixing typos, or adding examples/tutorials. See the tutorial of how we write our documentation [here](./documentation.md). + +4. **Bug Fixes**: If you find any issues or bugs, you can report them in the [GitHub Issues](https://github.com/prowler-cloud/prowler/issues) page and if you want you can also fix them. + +Remember, our community is here to help! If you need guidance, do not hesitate to ask questions in the issues or join our [Slack workspace](https://goto.prowler.com/slack). + +### Pre-Commit Hooks + +This repository uses Git pre-commit hooks managed by the [pre-commit](https://pre-commit.com/) tool, it is installed with `poetry install --with dev`. Next, run the following command in the root of this repository: -This repo has git pre-commit hooks managed via the [pre-commit](https://pre-commit.com/) tool. [Install](https://pre-commit.com/#install) it how ever you like, then in the root of this repo run: ```shell pre-commit install ``` -You should get an output like the following: + +Successful installation should produce the following output: + ```shell pre-commit installed at .git/hooks/pre-commit ``` -Before we merge any of your pull requests we pass checks to the code, we use the following tools and automation to make sure the code is secure and dependencies up-to-dated: +### Code Quality and Security Checks + +Before merging pull requests, several automated checks and utilities ensure code security and updated dependencies: + ???+ note - These should have been already installed if you ran `poetry install --with dev` + These should have been already installed if `poetry install --with dev` was already run. - [`bandit`](https://pypi.org/project/bandit/) for code security review. - [`safety`](https://pypi.org/project/safety/) and [`dependabot`](https://github.com/features/security) for dependencies. -- [`hadolint`](https://github.com/hadolint/hadolint) and [`dockle`](https://github.com/goodwithtech/dockle) for our containers security. -- [`Snyk`](https://docs.snyk.io/integrations/snyk-container-integrations/container-security-with-docker-hub-integration) in Docker Hub. -- [`clair`](https://github.com/quay/clair) in Amazon ECR. -- [`vulture`](https://pypi.org/project/vulture/), [`flake8`](https://pypi.org/project/flake8/), [`black`](https://pypi.org/project/black/) and [`pylint`](https://pypi.org/project/pylint/) for formatting and best practices. +- [`hadolint`](https://github.com/hadolint/hadolint) and [`dockle`](https://github.com/goodwithtech/dockle) for container security. +- [`Snyk`](https://docs.snyk.io/integrations/snyk-container-integrations/container-security-with-docker-hub-integration) for container security in Docker Hub. +- [`clair`](https://github.com/quay/clair) for container security in Amazon ECR. +- [`vulture`](https://pypi.org/project/vulture/), [`flake8`](https://pypi.org/project/flake8/), [`black`](https://pypi.org/project/black/), and [`pylint`](https://pypi.org/project/pylint/) for formatting and best practices. -You can see all dependencies in file `pyproject.toml`. +Additionally, ensure the latest version of [`TruffleHog`](https://github.com/trufflesecurity/trufflehog) is installed to scan for sensitive data in the code. Follow the official [installation guide](https://github.com/trufflesecurity/trufflehog?tab=readme-ov-file#floppy_disk-installation) for setup. -Moreover, you would need to install [`TruffleHog`](https://github.com/trufflesecurity/trufflehog) on the latest version to check for secrets in the code. You can install it using the official installation guide [here](https://github.com/trufflesecurity/trufflehog?tab=readme-ov-file#floppy_disk-installation). +### Dependency Management -Additionally, please ensure to follow the code documentation practices outlined in this guide: [Google Python Style Guide - Comments and Docstrings](https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings). +All dependencies are listed in the `pyproject.toml` file. + +For proper code documentation, refer to the following and follow the code documentation practices presented there: [Google Python Style Guide - Comments and Docstrings](https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings). ???+ note - If you have any trouble when committing to the Prowler repository, add the `--no-verify` flag to the `git commit` command. + If you encounter issues when committing to the Prowler repository, use the `--no-verify` flag with the `git commit` command. + +### Repository Folder Structure + +Understanding the layout of the Prowler codebase will help you quickly find where to add new features, checks, or integrations. The following is a high-level overview from the root of the repository: + +``` +prowler/ +├── prowler/ # Main source code for Prowler SDK (CLI, providers, services, checks, compliances, config, etc.) +├── api/ # API server and related code +├── dashboard/ # Local Dashboard extracted from the CLI output +├── ui/ # Web UI components +├── util/ # Utility scripts and helpers +├── tests/ # Prowler SDK test suite +├── docs/ # Documentation, including this guide +├── examples/ # Example output formats for providers and scripts +├── permissions/ # Permission-related files and policies +├── contrib/ # Community-contributed scripts or modules +├── kubernetes/ # Kubernetes deployment files +├── .github/ # GitHub related files (workflows, issue templates, etc.) +├── pyproject.toml # Python project configuration (Poetry) +├── poetry.lock # Poetry lock file +├── README.md # Project overview and getting started +├── Makefile # Common development commands +├── Dockerfile # SDK Docker container +├── docker-compose.yml # Prowler App Docker compose +└── ... # Other supporting files +``` ## Pull Request Checklist -If you create or review a PR in https://github.com/prowler-cloud/prowler please follow this checklist: +When creating or reviewing a pull request in https://github.com/prowler-cloud/prowler, follow [this checklist](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md#checklist). -- [ ] Make sure you've read the Prowler Developer Guide at https://docs.prowler.cloud/en/latest/developer-guide/introduction/ -- [ ] Are we following the style guide, hence installed all the linters and formatters? Please check https://docs.prowler.cloud/en/latest/developer-guide/introduction/#contributing-with-your-code-or-fixes-to-prowler -- [ ] Are we increasing/decreasing the test coverage? Please, review if we need to include/modify tests for the new code. -- [ ] Are we modifying outputs? Please review it carefully. -- [ ] Do we need to modify the Prowler documentation to reflect the changes introduced? -- [ ] Are we introducing possible breaking changes? Are we modifying a core feature? +## Contribution Appreciation +If you enjoy swag, we’d love to thank you for your contribution with laptop stickers or other Prowler merchandise! -## Want some swag as appreciation for your contribution? +To request swag: Share your pull request details in our [Slack workspace](https://goto.prowler.com/slack). -If you are like us and you love swag, we are happy to thank you for your contribution with some laptop stickers or whatever other swag we may have at that time. Please, tell us more details and your pull request link in our [Slack workspace here](https://goto.prowler.com/slack). You can also reach out to Toni de la Fuente on Twitter [here](https://twitter.com/ToniBlyx), his DMs are open. +You can also reach out to Toni de la Fuente on [Twitter](https://twitter.com/ToniBlyx)—his DMs are open! + +# Testing a Pull Request from a Specific Branch + +To test Prowler from a specific branch (for example, to try out changes from a pull request before it is merged), you can use `pipx` to install directly from GitHub: + +```sh +pipx install "git+https://github.com/prowler-cloud/prowler.git@branch-name" +``` + +Replace `branch-name` with the name of the branch you want to test. This will install Prowler in an isolated environment, allowing you to try out the changes safely. \ No newline at end of file diff --git a/docs/developer-guide/kubernetes-details.md b/docs/developer-guide/kubernetes-details.md new file mode 100644 index 0000000000..8b08b51e64 --- /dev/null +++ b/docs/developer-guide/kubernetes-details.md @@ -0,0 +1,117 @@ +# Kubernetes Provider + +This page details the [Kubernetes](https://kubernetes.io/) provider implementation in Prowler. + +By default, Prowler will audit all namespaces in the Kubernetes cluster accessible by the configured context. To configure it, follow the [getting started](../index.md#kubernetes) page. + +## Kubernetes Provider Classes Architecture + +The Kubernetes provider implementation follows the general [Provider structure](./provider.md). This section focuses on the Kubernetes-specific implementation, highlighting how the generic provider concepts are realized for Kubernetes in Prowler. For a full overview of the provider pattern, base classes, and extension guidelines, see [Provider documentation](./provider.md). + +### `KubernetesProvider` (Main Class) + +- **Location:** [`prowler/providers/kubernetes/kubernetes_provider.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/kubernetes/kubernetes_provider.py) +- **Base Class:** Inherits from `Provider` (see [base class details](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/common/provider.py)). +- **Purpose:** Central orchestrator for Kubernetes-specific logic, session management, context and namespace discovery, credential validation, and configuration. +- **Key Kubernetes Responsibilities:** + - Initializes and manages Kubernetes sessions (supports kubeconfig file or content, context selection, and namespace scoping). + - Validates credentials and sets up the Kubernetes identity context. + - Loads and manages configuration, mutelist, and fixer settings. + - Discovers accessible namespaces and cluster metadata. + - Provides properties and methods for downstream Kubernetes service classes to access session, identity, and configuration data. + +### Data Models + +- **Location:** [`prowler/providers/kubernetes/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/kubernetes/models.py) +- **Purpose:** Define structured data for Kubernetes identity and session info. +- **Key Kubernetes Models:** + - `KubernetesIdentityInfo`: Holds Kubernetes identity metadata, such as context, cluster, and user. + - `KubernetesSession`: Stores the Kubernetes API client and context information. + +### `KubernetesService` (Service Base Class) + +- **Location:** [`prowler/providers/kubernetes/lib/service/service.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/kubernetes/lib/service/service.py) +- **Purpose:** Abstract base class that all Kubernetes service-specific classes inherit from. This implements the generic service pattern (described in [service page](./services.md#service-base-class)) specifically for Kubernetes. +- **Key Kubernetes Responsibilities:** + - Receives a `KubernetesProvider` instance to access session, identity, and configuration. + - Manages the Kubernetes API client and context. + - Provides a `__threading_call__` method to make API calls in parallel by resource. + - Exposes common audit context (`context`, `api_client`, `audit_config`, `fixer_config`) to subclasses. + +### Exception Handling + +- **Location:** [`prowler/providers/kubernetes/exceptions/exceptions.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/kubernetes/exceptions/exceptions.py) +- **Purpose:** Custom exception classes for Kubernetes-specific error handling, such as session, API, and configuration errors. + +### Session and Utility Helpers + +- **Location:** [`prowler/providers/kubernetes/lib/`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/kubernetes/lib/) +- **Purpose:** Helpers for argument parsing, mutelist management, and other cross-cutting concerns. + +## Specific Patterns in Kubernetes Services + +The generic service pattern is described in [service page](./services.md#service-structure-and-initialisation). You can find all the currently implemented services in the following locations: + +- Directly in the code, in location [`prowler/providers/kubernetes/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/kubernetes/services) +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. + +The best reference to understand how to implement a new service is following the [service implementation documentation](./services.md#adding-a-new-service) and taking other already implemented services as reference. + +### Kubernetes Service Common Patterns + +- Services communicate with Kubernetes using the Kubernetes Python SDK. See the [official documentation](https://github.com/kubernetes-client/python/blob/master/kubernetes/README.md/). +- Every Kubernetes service class inherits from `KubernetesService`, ensuring access to session, identity, configuration, and client utilities. +- The constructor (`__init__`) always calls `super().__init__` with the provider object, and initializes resource containers (typically as dictionaries keyed by resource UID or name). +- Resource discovery and attribute collection can be parallelized using `self.__threading_call__`. +- All Kubernetes resources are represented as Pydantic `BaseModel` classes, providing type safety and structured access to resource attributes. +- Kubernetes API calls are wrapped in try/except blocks, always logging errors. +- Additional attributes that cannot be retrieved from the default call should be collected and stored for each resource using dedicated methods and threading. + +## Specific Patterns in Kubernetes Checks + +The Kubernetes checks pattern is described in [checks page](./checks.md). You can find all the currently implemented checks in: + +- Directly in the code, within each service folder, each check has its own folder named after the name of the check. (e.g. [`prowler/providers/kubernetes/services/rbac/rbac_minimize_wildcard_use_roles/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/kubernetes/services/rbac/rbac_minimize_wildcard_use_roles)) +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. + +The best reference to understand how to implement a new check is following the [Kubernetes check implementation documentation](./checks.md#creating-a-check) and taking other checks as reference. + +### Check Report Class + +The `Check_Report_Kubernetes` class models a single finding for a Kubernetes resource in a check report. It is defined in [`prowler/lib/check/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/check/models.py) and inherits from the generic `Check_Report` base class. + +#### Purpose + +`Check_Report_Kubernetes` extends the base report structure with Kubernetes-specific fields, enabling detailed tracking of the resource, name, and namespace associated with each finding. + +#### Constructor and Attribute Population + +When you instantiate `Check_Report_Kubernetes`, you must provide the check metadata and a resource object. The class will attempt to automatically populate its Kubernetes-specific attributes from the resource, using the following logic (in order of precedence): + +- **`resource_id`**: + - Uses `resource.uid` if present. + - Otherwise, uses `resource.name` if present. + - Defaults to an empty string if none are available. + +- **`resource_name`**: + - Uses `resource.name` if present. + - Defaults to an empty string if not available. + +- **`namespace`**: + - Uses `resource.namespace` if present. + - Defaults to "cluster-wide" for cluster-scoped resources. + +If the resource object does not contain the required attributes, you must set them manually in the check logic. + +Other attributes are inherited from the `Check_Report` class, from which you **always** have to set the `status` and `status_extended` attributes in the check logic. + +#### Example Usage + +```python +report = Check_Report_Kubernetes( + metadata=check_metadata, + resource=resource_object +) +report.status = "PASS" +report.status_extended = "Resource is compliant." +``` diff --git a/docs/developer-guide/m365-details.md b/docs/developer-guide/m365-details.md new file mode 100644 index 0000000000..0840b9856c --- /dev/null +++ b/docs/developer-guide/m365-details.md @@ -0,0 +1,131 @@ +# Microsoft 365 (M365) Provider + +This page details the [Microsoft 365 (M365)](https://www.microsoft.com/en-us/microsoft-365) provider implementation in Prowler. + +By default, Prowler will audit the Microsoft Entra ID tenant and its supported services. To configure it, follow the [getting started](../index.md#microsoft-365) page. + +--- + +## PowerShell Requirements for M365 Checks + +> **Most Microsoft 365 checks in Prowler require PowerShell, not just the Microsoft Graph API.** + +- **PowerShell is essential** for retrieving data from Exchange Online, Teams, Defender, Purview, and other M365 services. Many checks cannot be performed using only the Graph API. +- **PowerShell 7.4 or higher is required** (7.5 recommended). PowerShell 5.1 and earlier versions are not supported for M365 checks. +- **Required modules:** + - [ExchangeOnlineManagement](https://www.powershellgallery.com/packages/ExchangeOnlineManagement/3.6.0) (≥ 3.6.0) + - [MicrosoftTeams](https://www.powershellgallery.com/packages/MicrosoftTeams/6.6.0) (≥ 6.6.0) +- If you use Prowler Cloud or the official containers, PowerShell is pre-installed. For local or pip installations, you must install PowerShell and the modules yourself. See [Requirements: Supported PowerShell Versions](../getting-started/requirements.md#supported-powershell-versions) and [Needed PowerShell Modules](../getting-started/requirements.md#needed-powershell-modules). +- For more details and troubleshooting, see [Use of PowerShell in M365](../tutorials/microsoft365/use-of-powershell.md). + +--- + +## M365 Provider Classes Architecture + +The M365 provider implementation follows the general [Provider structure](./provider.md). This section focuses on the M365-specific implementation, highlighting how the generic provider concepts are realized for M365 in Prowler. For a full overview of the provider pattern, base classes, and extension guidelines, see [Provider documentation](./provider.md). + +### `M365Provider` (Main Class) + +- **Location:** [`prowler/providers/m365/m365_provider.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/m365/m365_provider.py) +- **Base Class:** Inherits from `Provider` (see [base class details](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/common/provider.py)). +- **Purpose:** Central orchestrator for M365-specific logic, session management, credential validation, region/authority configuration, and identity context. +- **Key M365 Responsibilities:** + - Initializes and manages M365 sessions (supports Service Principal, environment variables, Azure CLI, browser, and user/password authentication). + - Validates credentials and sets up the M365 identity context. + - Manages the Microsoft Graph API client and the PowerShell client. + - Loads and manages configuration, mutelist, and fixer settings. + - Provides properties and methods for downstream M365 service classes to access session, identity, and configuration data. + +### Data Models + +- **Location:** [`prowler/providers/m365/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/m365/models.py) +- **Purpose:** Define structured data for M365 identity, session, region configuration, and credentials. +- **Key M365 Models:** + - `M365IdentityInfo`: Holds M365 identity metadata, including tenant ID, domain(s), user, and location. + - `M365RegionConfig`: Stores the specific region/authority and API base URL for the tenant. + - `M365Credentials`: Represents credentials for authentication (user, password, client ID, client secret, tenant ID, etc.). + +### `M365Service` (Service Base Class) + +- **Location:** [`prowler/providers/m365/lib/service/service.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/m365/lib/service/service.py) +- **Purpose:** Abstract base class for all M365 service-specific classes. +- **Key M365 Responsibilities:** + - Receives an `M365Provider` instance to access session, identity, and configuration. + - Manages the Microsoft Graph API client for the service. + - Initializes a PowerShell client for most services if credentials and identity are available. + - Exposes common audit context (`audit_config`, `fixer_config`) to subclasses. + +### Exception Handling + +- **Location:** [`prowler/providers/m365/exceptions/exceptions.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/m365/exceptions/exceptions.py) +- **Purpose:** Custom exception classes for M365-specific error handling, such as credential, session, region, and argument errors. + +### Session and Utility Helpers + +- **Location:** [`prowler/providers/m365/lib/`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/m365/lib/) +- **Purpose:** Helpers for argument parsing, region/authority setup, mutelist management, PowerShell integration, and other cross-cutting concerns. + + > **Key File: [`m365_powershell.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/m365/lib/powershell/m365_powershell.py)** + > + > This is the core module for Microsoft 365 PowerShell integration. It manages authentication, session handling, and provides a comprehensive set of methods for interacting with Microsoft Teams, Exchange Online, and Defender policies via PowerShell. + > + > This module provides secure credential management and authentication using MSAL and PowerShell. It handles automated installation and initialization of required PowerShell modules. The module offers a rich set of methods for retrieving and managing Teams, Exchange, and Defender configurations. It serves as the central component for all M365 provider operations that require PowerShell automation. + +## Specific Patterns in M365 Services + +The generic service pattern is described in [service page](./services.md#service-structure-and-initialisation). You can find all the currently implemented services in the following locations: + +- Directly in the code, in location [`prowler/providers/m365/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/m365/services) +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. + +The best reference to understand how to implement a new service is by following the [service implementation documentation](./services.md#adding-a-new-service) and by taking other already implemented services as reference. + +### M365 Service Common Patterns + +- Services communicate with Microsoft 365 using the Microsoft Graph API **and/or PowerShell**. See the [official documentation](https://learn.microsoft.com/en-us/graph/api/overview) and [PowerShell reference](https://learn.microsoft.com/en-us/powershell/). +- Every M365 service class inherits from `M365Service`, ensuring access to session, identity, configuration, and client utilities. +- The constructor (`__init__`) always calls `super().__init__` with the provider object, and initializes the Graph client and the PowerShell client. +- Resource containers **must** be initialized in the constructor, typically as objects that represent the different settings of the service. +- All M365 resources are represented as Pydantic `BaseModel` classes, providing type safety and structured access to resource attributes. +- Microsoft Graph API and PowerShell calls are wrapped in try/except blocks, always logging errors. +- To retrieve some data in the services, it is so common that you have to create a new method also in the `m365_powershell.py` file to later be called in the service. + +## Specific Patterns in M365 Checks + +The M365 checks pattern is described in [checks page](./checks.md). You can find all the currently implemented checks in: + +- Directly in the code, within each service folder, each check has its own folder named after the name of the check. (e.g. [`prowler/providers/m365/services/entra/entra_users_mfa_enabled/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/m365/services/entra/entra_users_mfa_enabled)) +- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view. + +The best reference to understand how to implement a new check is following the [M365 check implementation documentation](./checks.md#creating-a-check) and by taking other checks as reference. + +### Check Report Class + +The `CheckReportM365` class models a single finding for a Microsoft 365 resource in a check report. It is defined in [`prowler/lib/check/models.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/check/models.py) and inherits from the generic `Check_Report` base class. + +#### Purpose + +`CheckReportM365` extends the base report structure with M365-specific fields, enabling detailed tracking of the resource, name, and location associated with each finding. + +#### Constructor and Attribute Population + +When you instantiate `CheckReportM365`, you must provide the check metadata and a resource object. The class will attempt to automatically populate its M365-specific attributes from the resource, using the following logic (in order of precedence): + +- **`resource_id`**: A required field that **must** be explicitly set in the constructor to identify the resource being checked. +- **`resource_name`**: A required field that **must** be explicitly set in the constructor to provide a human-readable name for the resource. +- **`location`**: A required field that can be explicitly set in the constructor to indicate where the resource is located. If not specified, defaults to "global". + +If the resource object does not contain the required attributes, you must set them manually in the check logic. + +Other attributes are inherited from the `Check_Report` class, from which you **always** have to set the `status` and `status_extended` attributes in the check logic. + +#### Example Usage + +```python +report = CheckReportM365( + metadata=check_metadata, + resource=resource_object +) +report.status = "PASS" +report.status_extended = "Resource is compliant." +``` diff --git a/docs/developer-guide/outputs.md b/docs/developer-guide/outputs.md index ca187101da..5131f9844d 100644 --- a/docs/developer-guide/outputs.md +++ b/docs/developer-guide/outputs.md @@ -2,21 +2,38 @@ ## Introduction -Prowler can generate outputs in multiple formats, allowing users to customize the way findings are presented. This is particularly useful when integrating Prowler with third-party tools, creating specialized reports, or simply tailoring the data to meet specific requirements. A custom output format gives you the flexibility to extract and display only the most relevant information in the way you need it. +Prowler supports multiple output formats, allowing users to tailor findings presentation to their needs. Custom output formats are valuable when integrating Prowler with third-party tools, generating specialized reports, or adapting data for specific workflows. By defining a custom output format, users can refine how findings are structured, extracting and displaying only the most relevant information. -* Prowler organizes its outputs in the `/lib/outputs` directory. Each format (e.g., JSON, CSV, HTML) is implemented as a Python class. -* Outputs are generated based on findings collected during a scan. Each finding is represented as a structured dictionary containing details like resource IDs, severities, descriptions, and more. -* Consult the [Prowler Developer Guide](https://docs.prowler.com/projects/prowler-open-source/en/latest/) to understand how Prowler works and the way that you can create it with the desired output! -* Identify the best approach for the specific output you’re targeting. +- Output Organization in Prowler + + Prowler outputs are managed within the `/lib/outputs` directory. Each format—such as JSON, CSV, HTML—is implemented as a Python class. + +- Outputs are generated based on scan findings, which are stored as structured dictionaries containing details such as: + + - Resource IDs + + - Severities + + - Descriptions + + - Other relevant metadata + +- Creation Guidelines + + Refer to the [Prowler Developer Guide](https://docs.prowler.com/projects/prowler-open-source/en/latest/) for insights into Prowler’s architecture and best practices for creating custom outputs. + +- Identify the most suitable integration method for the output being targeted. ## Steps to Create a Custom Output Format ### Schema -* Output Class: - * The class must inherit from `Output`. Review the [Output Class](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/outputs/output.py). - * Create a class that encapsulates attributes and methods for the output. - The following is the code for the `CSV` class: +- Output Class: + + - The class must inherit from `Output`. Review the [Output Class](https://github.com/prowler-cloud/prowler/blob/master/prowler/lib/outputs/output.py). + + - Create a class that encapsulates the required attributes and methods for interacting with the target platform. Below the code for the `CSV` class is presented: + ```python title="CSV Class" class CSV(Output): def transform(self, findings: List[Finding]) -> None: @@ -28,118 +45,137 @@ Prowler can generate outputs in multiple formats, allowing users to customize th """ ... ``` -* Transform Method: - * This method will transform the findings provided by Prowler to a specific format. - The following is the code for the `transform` method for the `CSV` class: - ```python title="Transform" - def transform(self, findings: List[Finding]) -> None: - """Transforms the findings into the CSV format. - Args: - findings (list[Finding]): a list of Finding objects - """ - try: - for finding in findings: - finding_dict = {} - finding_dict["AUTH_METHOD"] = finding.auth_method - finding_dict["TIMESTAMP"] = finding.timestamp - finding_dict["ACCOUNT_UID"] = finding.account_uid - finding_dict["ACCOUNT_NAME"] = finding.account_name - finding_dict["ACCOUNT_EMAIL"] = finding.account_email - finding_dict["ACCOUNT_ORGANIZATION_UID"] = ( - finding.account_organization_uid - ) - finding_dict["ACCOUNT_ORGANIZATION_NAME"] = ( - finding.account_organization_name - ) - finding_dict["ACCOUNT_TAGS"] = unroll_dict( - finding.account_tags, separator=":" - ) - finding_dict["FINDING_UID"] = finding.uid - finding_dict["PROVIDER"] = finding.metadata.Provider - finding_dict["CHECK_ID"] = finding.metadata.CheckID - finding_dict["CHECK_TITLE"] = finding.metadata.CheckTitle - finding_dict["CHECK_TYPE"] = unroll_list(finding.metadata.CheckType) - finding_dict["STATUS"] = finding.status.value - finding_dict["STATUS_EXTENDED"] = finding.status_extended - finding_dict["MUTED"] = finding.muted - finding_dict["SERVICE_NAME"] = finding.metadata.ServiceName - finding_dict["SUBSERVICE_NAME"] = finding.metadata.SubServiceName - finding_dict["SEVERITY"] = finding.metadata.Severity.value - finding_dict["RESOURCE_TYPE"] = finding.metadata.ResourceType - finding_dict["RESOURCE_UID"] = finding.resource_uid - finding_dict["RESOURCE_NAME"] = finding.resource_name - finding_dict["RESOURCE_DETAILS"] = finding.resource_details - finding_dict["RESOURCE_TAGS"] = unroll_dict(finding.resource_tags) - finding_dict["PARTITION"] = finding.partition - finding_dict["REGION"] = finding.region - finding_dict["DESCRIPTION"] = finding.metadata.Description - finding_dict["RISK"] = finding.metadata.Risk - finding_dict["RELATED_URL"] = finding.metadata.RelatedUrl - finding_dict["REMEDIATION_RECOMMENDATION_TEXT"] = ( - finding.metadata.Remediation.Recommendation.Text - ) - finding_dict["REMEDIATION_RECOMMENDATION_URL"] = ( - finding.metadata.Remediation.Recommendation.Url - ) - finding_dict["REMEDIATION_CODE_NATIVEIAC"] = ( - finding.metadata.Remediation.Code.NativeIaC - ) - finding_dict["REMEDIATION_CODE_TERRAFORM"] = ( - finding.metadata.Remediation.Code.Terraform - ) - finding_dict["REMEDIATION_CODE_CLI"] = ( - finding.metadata.Remediation.Code.CLI - ) - finding_dict["REMEDIATION_CODE_OTHER"] = ( - finding.metadata.Remediation.Code.Other - ) - finding_dict["COMPLIANCE"] = unroll_dict( - finding.compliance, separator=": " - ) - finding_dict["CATEGORIES"] = unroll_list(finding.metadata.Categories) - finding_dict["DEPENDS_ON"] = unroll_list(finding.metadata.DependsOn) - finding_dict["RELATED_TO"] = unroll_list(finding.metadata.RelatedTo) - finding_dict["NOTES"] = finding.metadata.Notes - finding_dict["PROWLER_VERSION"] = finding.prowler_version - self._data.append(finding_dict) - except Exception as error: - logger.error( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - ``` -* Batch Write Data To File Method: - * This method will write the modeled object to a file. - The following is the code for the `batch_write_data_to_file` method for the `CSV` class: - ```python title="Batch Write Data To File" - def batch_write_data_to_file(self) -> None: - """Writes the findings to a file using the CSV format using the `Output._file_descriptor`.""" - try: - if ( - getattr(self, "_file_descriptor", None) - and not self._file_descriptor.closed - and self._data - ): - csv_writer = DictWriter( - self._file_descriptor, - fieldnames=self._data[0].keys(), - delimiter=";", - ) - csv_writer.writeheader() - for finding in self._data: - csv_writer.writerow(finding) - self._file_descriptor.close() - except Exception as error: - logger.error( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - ``` + - Transform Method: -### Integration With The Current Code + - This method will transform the findings provided by Prowler to a specific format. + + #### Method Implementation + + The following example demonstrates the `transform` method for the `CSV` class: + + ```python title="Transform" + def transform(self, findings: List[Finding]) -> None: + """Transforms the findings into the CSV format. + + Args: + findings (list[Finding]): a list of Finding objects + + """ + try: + for finding in findings: + finding_dict = {} + finding_dict["AUTH_METHOD"] = finding.auth_method + finding_dict["TIMESTAMP"] = finding.timestamp + finding_dict["ACCOUNT_UID"] = finding.account_uid + finding_dict["ACCOUNT_NAME"] = finding.account_name + finding_dict["ACCOUNT_EMAIL"] = finding.account_email + finding_dict["ACCOUNT_ORGANIZATION_UID"] = ( + finding.account_organization_uid + ) + finding_dict["ACCOUNT_ORGANIZATION_NAME"] = ( + finding.account_organization_name + ) + finding_dict["ACCOUNT_TAGS"] = unroll_dict( + finding.account_tags, separator=":" + ) + finding_dict["FINDING_UID"] = finding.uid + finding_dict["PROVIDER"] = finding.metadata.Provider + finding_dict["CHECK_ID"] = finding.metadata.CheckID + finding_dict["CHECK_TITLE"] = finding.metadata.CheckTitle + finding_dict["CHECK_TYPE"] = unroll_list(finding.metadata.CheckType) + finding_dict["STATUS"] = finding.status.value + finding_dict["STATUS_EXTENDED"] = finding.status_extended + finding_dict["MUTED"] = finding.muted + finding_dict["SERVICE_NAME"] = finding.metadata.ServiceName + finding_dict["SUBSERVICE_NAME"] = finding.metadata.SubServiceName + finding_dict["SEVERITY"] = finding.metadata.Severity.value + finding_dict["RESOURCE_TYPE"] = finding.metadata.ResourceType + finding_dict["RESOURCE_UID"] = finding.resource_uid + finding_dict["RESOURCE_NAME"] = finding.resource_name + finding_dict["RESOURCE_DETAILS"] = finding.resource_details + finding_dict["RESOURCE_TAGS"] = unroll_dict(finding.resource_tags) + finding_dict["PARTITION"] = finding.partition + finding_dict["REGION"] = finding.region + finding_dict["DESCRIPTION"] = finding.metadata.Description + finding_dict["RISK"] = finding.metadata.Risk + finding_dict["RELATED_URL"] = finding.metadata.RelatedUrl + finding_dict["REMEDIATION_RECOMMENDATION_TEXT"] = ( + finding.metadata.Remediation.Recommendation.Text + ) + finding_dict["REMEDIATION_RECOMMENDATION_URL"] = ( + finding.metadata.Remediation.Recommendation.Url + ) + finding_dict["REMEDIATION_CODE_NATIVEIAC"] = ( + finding.metadata.Remediation.Code.NativeIaC + ) + finding_dict["REMEDIATION_CODE_TERRAFORM"] = ( + finding.metadata.Remediation.Code.Terraform + ) + finding_dict["REMEDIATION_CODE_CLI"] = ( + finding.metadata.Remediation.Code.CLI + ) + finding_dict["REMEDIATION_CODE_OTHER"] = ( + finding.metadata.Remediation.Code.Other + ) + finding_dict["COMPLIANCE"] = unroll_dict( + finding.compliance, separator=": " + ) + finding_dict["CATEGORIES"] = unroll_list(finding.metadata.Categories) + finding_dict["DEPENDS_ON"] = unroll_list(finding.metadata.DependsOn) + finding_dict["RELATED_TO"] = unroll_list(finding.metadata.RelatedTo) + finding_dict["NOTES"] = finding.metadata.Notes + finding_dict["PROWLER_VERSION"] = finding.prowler_version + self._data.append(finding_dict) + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + ``` + + - Batch Write Data To File Method: + + - This method will write the modeled object to a file. + + #### Method Implementation + + The following example demonstrates the `batch_write_data_to_file` method for the `CSV` class: + + ```python title="Batch Write Data To File" + def batch_write_data_to_file(self) -> None: + """Writes the findings to a file using the CSV format using the `Output._file_descriptor`.""" + try: + if ( + getattr(self, "_file_descriptor", None) + and not self._file_descriptor.closed + and self._data + ): + csv_writer = DictWriter( + self._file_descriptor, + fieldnames=self._data[0].keys(), + delimiter=";", + ) + csv_writer.writeheader() + for finding in self._data: + csv_writer.writerow(finding) + self._file_descriptor.close() + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + ``` + +### Integrating the Custom Output Format into Prowler + +Once the custom output format is created, it must be integrated into Prowler to ensure compatibility with the existing architecture. + +#### Reviewing Current Supported Outputs + +Before implementing the new output format, examine the usage of currently supported formats to understand their structure and integration approach. Example: CSV Output Creation in Prowler + +Below is an example of how Prowler generates and processes CSV output within its [codebase](https://github.com/prowler-cloud/prowler/blob/master/prowler/__main__.py): -Once that the desired output format is created it has to be integrated with Prowler. Take a look at the the usage from the current supported output in order to add the new one. -Here is an example of the CSV output creation inside [prowler code](https://github.com/prowler-cloud/prowler/blob/master/prowler/__main__.py): ```python title="CSV creation" if mode == "csv": csv_output = CSV( @@ -148,19 +184,23 @@ if mode == "csv": file_path=f"{filename}{csv_file_suffix}", ) generated_outputs["regular"].append(csv_output) - # Write CSV Finding Object to file + # Write CSV Finding Object to file. csv_output.batch_write_data_to_file() ``` ### Testing -* Verify that Prowler’s findings are accurately writed in the desired output format. -* Simulate edge cases to ensure robust error handling. +* Verify that Prowler’s findings are accurately typed in the desired output format. + +* Error Handling – Simulate edge cases to assess robustness and failure recovery mechanisms. ### Documentation -* Provide clear, detailed documentation for your output: - * Setup instructions, including any required dependencies. +* Ensure the following elements are included: + + * Setup Instructions – List all necessary dependencies and installation steps. * Configuration details. - * Example use cases and troubleshooting tips. -* Good documentation ensures maintainability and simplifies onboarding for new users. + * Example Use Cases – Provide practical scenarios demonstrating functionality. + * Troubleshooting Guide – Document common issues and resolution steps. + +* Comprehensive and clear documentation improves maintainability and simplifies onboarding of new users. diff --git a/docs/developer-guide/provider.md b/docs/developer-guide/provider.md index 823f694a77..8d4708640f 100644 --- a/docs/developer-guide/provider.md +++ b/docs/developer-guide/provider.md @@ -1,187 +1,78 @@ - -# Create a new Provider for Prowler - -Here you can find how to create a new Provider in Prowler to give support for making all security checks needed and make your cloud safer! +# Prowler Providers ## Introduction -Providers are the foundation on which Prowler is built, a simple definition for a cloud provider could be "third-party company that offers a platform where any IT resource you need is available at any time upon request". The most well-known cloud providers are Amazon Web Services, Azure from Microsoft and Google Cloud which are already supported by Prowler. +Providers form the backbone of Prowler, enabling security assessments across various cloud environments. -To create a new provider that is not supported now by Prowler and add your security checks you must create a new folder to store all the related files within it (services, checks, etc.). It must be store in route `prowler/providers//`. +A provider is any platform or service that offers resources, data, or functionality that can be audited for security and compliance. This includes: -Inside that folder, you MUST create the following files and folders: +- Cloud Infrastructure Providers (like Amazon Web Services, Microsoft Azure, and Google Cloud) +- Software as a Service (SaaS) Platforms (like Microsoft 365) +- Development Platforms (like GitHub) +- Container Orchestration Platforms (like Kubernetes) -- A `lib` folder: to store all extra functions. -- A `services` folder: to store all [services](./services.md) to audit. -- An empty `__init__.py`: to make Python treat this service folder as a package. -- A `_provider.py`, containing all the provider's logic necessary to get authenticated in the provider, configurations and extra data useful for final report. -- A `models.py`, containing all the models necessary for the new provider. +For providers supported by Prowler, refer to [Prowler Hub](https://hub.prowler.com/). -## Provider +???+ important + There are some custom providers added by the community, like [NHN Cloud](https://www.nhncloud.com/), that are not maintained by the Prowler team, but can be used in the Prowler CLI. They can be checked directly at the [Prowler GitHub repository](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers). -The structure for Prowler's providers is set up in such a way that they can be utilized through a generic service specific to each provider. This is achieved by passing the required parameters to the constructor, which in turn initializes all the necessary session values. +## Adding a New Provider + +To integrate an unsupported Prowler provider and implement its security checks, create a dedicated folder for all related files (e.g., services, checks)." + +This folder must be placed within [`prowler/providers//`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers). + +Within this folder the following folders are also to be created: + +- `lib` – Stores additional utility functions and core files required by every provider. The following files and subfolders are commonly found in every provider's `lib` folder: + + - `service/service.py` – Provides a generic service class to be inherited by all services. + - `arguments/arguments.py` – Handles provider-specific argument parsing. + - `mutelist/mutelist.py` – Manages the mutelist functionality for the provider. + +- `services` – Stores all [services](./services.md) that the provider offers and want to be audited by [Prowler checks](./checks.md). + +- `__init__.py` (empty) – Ensures Python recognizes this folder as a package. + +- `_provider.py` – Defines authentication logic, configurations, and other provider-specific data. + +- `models.py` – Contains necessary models for the new provider. + +By adhering to this structure, Prowler can effectively support services and security checks for additional providers. + +???+ important + If your new provider requires a Python library (such as an official SDK or API client) to connect to its services, make sure to add it as a dependency in the `pyproject.toml` file. This ensures that all contributors and users have the necessary packages installed when working with your provider. + +## Provider Structure in Prowler + +Prowler's provider architecture is designed to facilitate security audits through a generic service tailored to each provider. This is accomplished by passing the necessary parameters to the constructor, which initializes all required session values. ### Base Class -All the providers in Prowler inherits from the same [base class](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/common/provider.py). It is an [abstract base class](https://docs.python.org/3/library/abc.html) that defines the interface for all provider classes. The code of the class is the next: - -```python title="Provider Base Class" - -from abc import ABC, abstractmethod -from typing import Any - -class Provider(ABC): - """ - The Provider class is an abstract base class that defines the interface for all provider classes in the auditing system. - - Attributes: - type (property): The type of the provider. - identity (property): The identity of the provider for auditing. - session (property): The session of the provider for auditing. - audit_config (property): The audit configuration of the provider. - output_options (property): The output configuration of the provider for auditing. - - Methods: - print_credentials(): Displays the provider's credentials used for auditing in the command-line interface. - setup_session(): Sets up the session for the provider. - validate_arguments(): Validates the arguments for the provider. - get_checks_to_execute_by_audit_resources(): Returns a set of checks based on the input resources to scan. - - Note: - This is an abstract base class and should not be instantiated directly. Each provider should implement its own - version of the Provider class by inheriting from this base class and implementing the required methods and properties. - """ - - @property - @abstractmethod - def type(self) -> str: - """ - type method stores the provider's type. - - This method needs to be created in each provider. - """ - raise NotImplementedError() - - @property - @abstractmethod - def identity(self) -> str: - """ - identity method stores the provider's identity to audit. - - This method needs to be created in each provider. - """ - raise NotImplementedError() - - @abstractmethod - def setup_session(self) -> Any: - """ - setup_session sets up the session for the provider. - - This method needs to be created in each provider. - """ - raise NotImplementedError() - - @property - @abstractmethod - def session(self) -> str: - """ - session method stores the provider's session to audit. - - This method needs to be created in each provider. - """ - raise NotImplementedError() - - @property - @abstractmethod - def audit_config(self) -> str: - """ - audit_config method stores the provider's audit configuration. - - This method needs to be created in each provider. - """ - raise NotImplementedError() - - @abstractmethod - def print_credentials(self) -> None: - """ - print_credentials is used to display in the CLI the provider's credentials used to audit. - - This method needs to be created in each provider. - """ - raise NotImplementedError() - - @property - @abstractmethod - def output_options(self) -> str: - """ - output_options method returns the provider's audit output configuration. - - This method needs to be created in each provider. - """ - raise NotImplementedError() - - @output_options.setter - @abstractmethod - def output_options(self, value: str) -> Any: - """ - output_options.setter sets the provider's audit output configuration. - - This method needs to be created in each provider. - """ - raise NotImplementedError() - - def validate_arguments(self) -> None: - """ - validate_arguments validates the arguments for the provider. - - This method can be overridden in each provider if needed. - """ - raise NotImplementedError() - - def get_checks_to_execute_by_audit_resources(self) -> set: - """ - get_checks_to_execute_by_audit_resources returns a set of checks based on the input resources to scan. - - This is a fallback that returns None if the service has not implemented this function. - """ - return set() - - @property - @abstractmethod - def mutelist(self): - """ - mutelist method returns the provider's mutelist. - - This method needs to be created in each provider. - """ - raise NotImplementedError() - - @mutelist.setter - @abstractmethod - def mutelist(self, path: str): - """ - mutelist.setter sets the provider's mutelist. - - This method needs to be created in each provider. - """ - raise NotImplementedError() -``` +All Prowler providers inherit from the same base class located in [`prowler/providers/common/provider.py`](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/common/provider.py). It is an [abstract base class](https://docs.python.org/3/library/abc.html) that defines the interface for all provider classes. ### Provider Class -Due to the complexity and differences of each provider use the rest of the providers as a template for the implementation. +#### Provider Implementation Guidance + +Given the complexity and variability of providers, use existing provider implementations as templates when developing new integrations. - [AWS](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/aws/aws_provider.py) - [GCP](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/gcp/gcp_provider.py) - [Azure](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/azure/azure_provider.py) - [Kubernetes](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/kubernetes/kubernetes_provider.py) -- [M365](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/m365/m365_provider.py) +- [Microsoft365](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/microsoft365/microsoft365_provider.py) +- [GitHub](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/github/github_provider.py) -To facilitate understanding here is a pseudocode of how the most basic provider could be with examples. +### Basic Provider Implementation: Pseudocode Example + +To simplify understanding, the following pseudocode outlines the fundamental structure of a provider, including library imports necessary for authentication. ```python title="Provider Example Class" -# Library imports to authenticate in the Provider +# Library Imports for Authentication + +# When implementing authentication for a provider, import the required libraries. from prowler.config.config import load_and_validate_config_file from prowler.lib.logger import logger @@ -190,14 +81,14 @@ from prowler.lib.utils.utils import print_boxes from prowler.providers.common.models import Audit_Metadata from prowler.providers.common.provider import Provider from prowler.providers..models import ( - # All providers models needed + # All provider models needed. ProviderSessionModel, ProviderIdentityModel, ProviderOutputOptionsModel ) class NewProvider(Provider): - # All properties from the class, some of this are properties in the base class + # All properties from the class, some of which are properties in the base class. _type: str = "" _session: _identity: @@ -213,20 +104,30 @@ class NewProvider(Provider): arguments (dict): A dictionary containing configuration arguments. """ logger.info("Setting provider ...") - # First get from arguments the necessary from the cloud account (subscriptions or projects or whatever the provider use for storing services) - # Set the session with the method enforced by parent class + # Initializing the Provider Session + + # Steps: + + # - Retrieve Account Information + # - Extract relevant account identifiers (subscriptions, projects, or other service references) from the provided arguments. + + # Establish a Session + + # Use the method enforced by the parent class to set up the session: self._session = self.setup_session(credentials_file) - # Set the Identity class normaly the provider class give by Python provider library + # Define Provider Identity + # Assign the identity class, typically provided by the Python provider library: self._identity = () - # Set the provider configuration + # Configure the Provider + # Set the provider-specific configuration. self._audit_config = load_and_validate_config_file( self._type, arguments.config_file ) - # All enforced properties by the parent class + # All the enforced properties by the parent class. @property def identity(self): return self._identity @@ -252,7 +153,7 @@ class NewProvider(Provider): Sets up the Provider session. Args: - Can include all necessary arguments to setup the session + Can include all necessary arguments to set up the session Returns: Credentials necessary to communicate with the provider. @@ -262,11 +163,8 @@ class NewProvider(Provider): """ This method is enforced by parent class and is used to print all relevant information during the prowler execution as a header of execution. - Normally the Account ID, User name or stuff like this is displayed in colors using the colorama module (Fore). + Displaying Account Information with Color Formatting. In Prowler, Account IDs, usernames, and other identifiers are typically displayed using color formatting provided by the colorama module (Fore). """ def print_credentials(self): pass - - - ``` diff --git a/docs/developer-guide/security-compliance-framework.md b/docs/developer-guide/security-compliance-framework.md index 53406a5a0b..95b7677605 100644 --- a/docs/developer-guide/security-compliance-framework.md +++ b/docs/developer-guide/security-compliance-framework.md @@ -1,20 +1,25 @@ -# Create a new security compliance framework - +# Creating a New Security Compliance Framework in Prowler ## Introduction -If you want to create or contribute with your own security frameworks or add public ones to Prowler you need to make sure the checks are available if not you have to create your own. Then create a compliance file per provider like in `prowler/compliance//` and name it as `__.json` then follow the following format to create yours. + +To create or contribute a custom security framework for Prowler—or to integrate a public framework—you must ensure the necessary checks are available. If they are missing, they must be implemented before proceeding. + +Each framework is defined in a compliance file per provider. The file should follow the structure used in `prowler/compliance//` and be named `__.json`. Follow the format below to create your own. ## Compliance Framework -Each file version of a framework will have the following structure at high level with the case that each framework needs to be generally identified, one requirement can be also called one control but one requirement can be linked to multiple prowler checks.: -- `Framework`: string. Distinguish name of the framework, like CIS -- `Provider`: string. Provider where the framework applies, such as AWS, Azure, OCI,... -- `Version`: string. Version of the framework itself, like 1.4 for CIS. -- `Requirements`: array of objects. Include all requirements or controls with the mapping to Prowler. -- `Requirements_Id`: string. Unique identifier per each requirement in the specific framework -- `Requirements_Description`: string. Description as in the framework. -- `Requirements_Attributes`: array of objects. Includes all needed attributes per each requirement, like levels, sections, etc. Whatever helps to create a dedicated report with the result of the findings. Attributes would be taken as closely as possible from the framework's own terminology directly. -- `Requirements_Checks`: array. Prowler checks that are needed to prove this requirement. It can be one or multiple checks. In case of no automation possible this can be empty. +### Compliance Framework Structure + +Each compliance framework file consists of structured metadata that identifies the framework and maps security checks to requirements or controls. Please note that a single requirement can be linked to multiple Prowler checks: + +- `Framework`: string – The distinguished name of the framework (e.g., CIS). +- `Provider`: string – The cloud provider where the framework applies (AWS, Azure, OCI). +- `Version`: string – The framework version (e.g., 1.4 for CIS). +- `Requirements`: array of objects. – Defines security requirements and their mapping to Prowler checks. All requirements or controls are to be included with the mapping to Prowler. +- `Requirements_Id`: string – A unique identifier for each requirement within the framework +- `Requirements_Description`: string – The requirement description as specified in the framework. +- `Requirements_Attributes`: array of objects. – Contains relevant metadata such as security levels, sections, and any additional data needed for reporting with the result of the findings. Attributes should be derived directly from the framework’s own terminology, ensuring consistency with its established definitions. +- `Requirements_Checks`: array. The Prowler checks that are needed to prove this requirement. It can be one or multiple checks. In case automation is not feasible, this can be empty. ``` { @@ -23,9 +28,9 @@ Each file version of a framework will have the following structure at high level "Requirements": [ { "Id": "", - "Description": "Requirement full description", + "Description": "Full description of the requirement", "Checks": [ - "Here is the prowler check or checks that is going to be executed" + "Here is the prowler check or checks that will be executed" ], "Attributes": [ { @@ -38,4 +43,4 @@ Each file version of a framework will have the following structure at high level } ``` -Finally, to have a proper output file for your reports, your framework data model has to be created in `prowler/lib/outputs/models.py` and also the CLI table output in `prowler/lib/outputs/compliance.py`. Also, you need to add a new conditional in `prowler/lib/outputs/file_descriptors.py` if you create a new CSV model. +Finally, to have a proper output file for your reports, your framework data model has to be created in `prowler/lib/outputs/models.py` and also the CLI table output in `prowler/lib/outputs/compliance.py`. Also, you need to add a new conditional in `prowler/lib/outputs/file_descriptors.py` if creating a new CSV model. diff --git a/docs/developer-guide/services.md b/docs/developer-guide/services.md index 6859bd0c07..f23036a5dd 100644 --- a/docs/developer-guide/services.md +++ b/docs/developer-guide/services.md @@ -1,197 +1,184 @@ -# Create a new Provider Service +# Prowler Services -Here you can find how to create a new service, or to complement an existing one, for a Prowler Provider. +Here you can find how to create a new service, or to complement an existing one, for a [Prowler Provider](./provider.md). + +???+note + First ensure that the provider you want to add the service is already created. It can be checked [here](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers). If the provider is not present, please refer to the [Provider](./provider.md) documentation to create it from scratch. ## Introduction -In Prowler, a service is basically a solution that is offered by a cloud provider i.e. [ec2](https://aws.amazon.com/ec2/). Essentially it is a class that stores all the necessary stuff that we will need later in the checks to audit some aspects of our Cloud account. +In Prowler, a **service** represents a specific solution or resource offered by one of the supported [Prowler Providers](./provider.md), for example, [EC2](https://aws.amazon.com/ec2/) in AWS, or [Microsoft Exchange](https://www.microsoft.com/en-us/microsoft-365/exchange/exchange-online) in M365. Services are the building blocks that allow Prowler interact directly with the various resources exposed by each provider. -To create a new service, you will need to create a folder inside the specific provider, i.e. `prowler/providers//services//`. +Each service is implemented as a class that encapsulates all the logic, data models, and API interactions required to gather and store information about that service's resources. All of this data is used by the [Prowler checks](./checks.md) to generate the security findings. -Inside that folder, you MUST create the following files: +## Adding a New Service -- An empty `__init__.py`: to make Python treat this service folder as a package. -- A `_service.py`, containing all the service's logic and API calls. -- A `_client_.py`, containing the initialization of the service's class we have just created so the checks's checks can use it. +To create a new service, a new folder must be created inside the specific provider following this pattern: `prowler/providers//services//`. -## Service +Within this folder the following files are also to be created: -The Prowler's service structure is the following and the way to initialise it is just by importing the service client in a check. +- `__init__.py` (empty) – Ensures Python recognizes this folder as a package. +- `_service.py` – Contains all the logic and API calls of the service. +- `_client_.py` – Contains the initialization of the freshly created service's class so that the checks can use it. + +## Service Structure and Initialisation + +The Prowler's service structure is as outlined below. To initialise it, just import the service client in a check. ### Service Base Class -All the Prowler provider's services inherits from a base class depending on the provider used. +All Prowler provider service should inherit from a common base class to avoid code duplication. This base class handles initialization and storage of functions and objects needed across services. The exact implementation depends on the provider's API requirements, but the following are the most common responsibilities: + +- Initialize/store clients to interact with the provider's API. +- Store the audit and fixer configuration. +- Implement threading logic where applicable. + +For reference, the base classes for each provider can be checked here: - [AWS Service Base Class](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/aws/lib/service/service.py) - [GCP Service Base Class](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/gcp/lib/service/service.py) - [Azure Service Base Class](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/azure/lib/service/service.py) - [Kubernetes Service Base Class](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/kubernetes/lib/service/service.py) - -Each class is used to initialize the credentials and the API's clients to be used in the service. If some threading is used it must be coded there. +- [M365 Service Base Class](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/m365/lib/service/service.py) +- [GitHub Service Base Class](https://github.com/prowler-cloud/prowler/blob/master/prowler/providers/github/lib/service/service.py) ### Service Class -Due to the complexity and differences of each provider API we are going to use an example service to guide you in how can it be created. +Due to the complexity and differences across provider APIs, the following example demonstrates best practices for structuring a service in Prowler. -The following is the `_service.py` file: +File `_service.py`: -```python title="Service Class" +```python title="Example Service Class" from datetime import datetime from typing import Optional -# The following is just for the AWS provider -from botocore.client import ClientError - -# To use the Pydantic's BaseModel +# To use the Pydantic's BaseModel. from pydantic import BaseModel -# Prowler logging library +# Prowler logging library. from prowler.lib.logger import logger -# Prowler resource filter, only for the AWS provider -from prowler.lib.scan_filters.scan_filters import is_resource_filtered - -# Provider parent class +# Provider parent class. from prowler.providers..lib.service.service import ServiceParentClass - -# Create a class for the Service +# Create a class for the Service. class (ServiceParentClass): - def __init__(self, provider): - # Call Service Parent Class __init__ - # We use the __class__.__name__ to get it automatically - # from the Service Class name but you can pass a custom - # string if the provider's API service name is different + def __init__(self, provider: Provider): + """Initialize the Service Class + + Args: + provider: Prowler Provider object. + """ + # Call Service Parent Class __init__. + # The __class__.__name__ is used to obtain it automatically. + # From the Service Class name, but a custom one can be passed. + # String in case the provider's API service name is different. super().__init__(__class__.__name__, provider) - # Create an empty dictionary of items to be gathered, - # using the unique ID as the dictionary key - # e.g., instances + # Create an empty dictionary of items to be gathered, using the unique ID as the dictionary’s key, e.g., instances. self. = {} - # If you can parallelize by regions or locations - # you can use the __threading_call__ function - # available in the Service Parent Class + # If parallelization can be carried out by regions or locations, the function __threading_call__ to be used must be implemented in the Service Parent Class. + # If it is not implemented, you can make it in a sequential way, just calling the function. self.__threading_call__(self.__describe___) - # Optionally you can create another function to retrieve - # more data about each item without parallel - self.__describe___() + # If it is needed you can create another function to retrieve more data from the items. + # Here we are using the second parameter of the __threading_call__ function to create one thread per item. + # You can also make it sequential without using the __threading_call__ function iterating over the items inside the function. + self.__threading_call__(self.__describe___, self..values()) + # In case of use the __threading_call__ function, you have to pass the regional_client to the function, as a parameter. def __describe___(self, regional_client): - """Get ALL """ + """Get all and store in the self. dictionary + + Args: + regional_client: Regional client object. + """ logger.info(" - Describing ...") - # We MUST include a try/except block in each function + # A try-except block must be created in each function. try: - - # Call to the provider API to retrieve the data we want + # If pagination is supported by the provider, is always better to use it, call to the provider API to retrieve the desired data. describe__paginator = regional_client.get_paginator("describe_") - # Paginator to get every item + # Paginator to get every item. for page in describe__paginator.paginate(): - # Another try/except within the loop for to continue looping - # if something unexpected happens + # Another try-except within the for loop to continue iterating in case something unexpected happens. try: for in page[""]: - # For the AWS provider we MUST include the following lines to retrieve - # or not data for the resource passed as argument using the --resource-arn - if not self.audit_resources or ( - is_resource_filtered([""], self.audit_resources) - ): - # Then we have to include the retrieved resource in the object - # previously created - self.[] = - ( - arn=stack[""], - name=stack[""], - tags=stack.get("Tags", []), - region=regional_client.region, - ) + # Adding Retrieved Resources to the Object + + # Once the resource has been retrieved, it must be included in the previously created object to ensure proper data handling within the service. + self.[] = + ( + arn=stack[""], + name=stack[""], + tags=stack.get("Tags", []), + region=regional_client.region, + ) except Exception as error: logger.error( f"{} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - # In the except part we have to use the following code to log the errors - except Exception as error: - # Depending on each provider we can use the following fields in the logger: - # - AWS: regional_client.region or self.region - # - GCP: project_id and location - # - Azure: subscription + # Logging Errors in Exception Handling + # When handling exceptions, use the following approach to log errors appropriately based on the cloud provider being used: + except Exception as error: + # Depending on each provider we can must use different fields in the logger, e.g.: AWS: regional_client.region or self.region, GCP: project_id and location, Azure: subscription logger.error( f"{} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - def __describe___(self): - """Get Details for a """ - logger.info(" - Describing to get specific details...") + def __describe___(self, item: ): + """Get details for a - # We MUST include a try/except block in each function + Args: + item: Item object. + """ + logger.info(" - Describing to get specific details...") + # A try-except block must be created in each function. try: - # Loop over the items retrieved in the previous function - for in self.: + _details = self.regional_clients[.region].describe_( + =.name + ) - # When we perform calls to the Provider API within a for loop we have - # to include another try/except block because in the cloud there are - # ephemeral resources that can be deleted at the time we are checking them - try: - _details = self.regional_clients[.region].describe_( - =.name - ) - - # For example, check if item is Public. Here is important if we are - # getting values from a dictionary we have to use the "dict.get()" - # function with a default value in the case this value is not present - .public = _details.get("Public", False) - - - # In this except block, for example for the AWS Provider we can use - # the botocore.ClientError exception and check for a specific error code - # to raise a WARNING instead of an ERROR if some resource is not present. - except ClientError as error: - if error.response["Error"]["Code"] == "InvalidInstanceID.NotFound": - logger.warning( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - else: - logger.error( - f"{} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - continue - - # In the except part we have to use the following code to log the errors + # E.g., check if item is Public. This case is important: if values are being retrieved from a dictionary, the function "dict.get()" must be used with a default value in case this value is not present. + .public = _details.get("Public", False) except Exception as error: - # Depending on each provider we can use the following fields in the logger: - # - AWS: regional_client.region or self.region - # - GCP: project_id and location - # - Azure: subscription - + # Fields for logging errors with relevant item information, e.g.: AWS: .region, GCP: .project_id, Azure: .region logger.error( f"{.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) ``` + ???+note - To avoid fake findings, when Prowler can't retrieve the items, because an Access Denied or similar error, we set that items value as `None`. + To prevent false findings, when Prowler fails to retrieve items due to Access Denied or similar errors, the affected item's value is set to `None`. #### Service Models -Service models are classes that are used in the service to design all that we need to store in each class object extrated from API calls. We use the Pydantic's [BaseModel](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel) to take advantage of the data validation. +Service models define structured classes used within services to store and process data extracted from API calls. + +Using Pydantic for Data Validation + +Prowler leverages Pydantic's [BaseModel](https://docs.pydantic.dev/latest/api/base_model/#pydantic.BaseModel) to enforce data validation. ```python title="Service Model" -# In each service class we have to create some classes using -# the Pydantic's Basemodel for the resources we want to audit. + +# Implementation Approach + +# Each service class should include custom model classes using Pydantic's BaseModel for the resources being audited. + class (BaseModel): """ holds a """ - arn: str - """[].arn""" + id: str + """[].id""" name: str """[].name""" @@ -202,26 +189,34 @@ class (BaseModel): public: bool """[].public""" - # We can create Optional attributes set to None by default + # Optional attributes can be created set to None by default. + tags: Optional[list] """[].tags""" ``` -#### Service Objects -In the service each group of resources should be created as a Python [dictionary](https://docs.python.org/3/tutorial/datastructures.html#dictionaries). This is because we are performing lookups all the time and the Python dictionary lookup has [O(1) complexity](https://en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions). -We MUST set as the dictionary key a unique ID, like the resource Unique ID or ARN. +#### Service Attributes + +*Optimized Data Storage with Python Dictionaries* + +Each group of resources within a service should be structured as a Python [dictionary](https://docs.python.org/3/tutorial/datastructures.html#dictionaries) to enable efficient lookups. The dictionary lookup operation has [O(1) complexity](https://en.wikipedia.org/wiki/Big_O_notation#Orders_of_common_functions), and lookups are constantly executed. + +*Assigning Unique Identifiers* + +Each dictionary key must be a unique ID to identify the resource in a univocal way. Example: + ```python -self.vpcs = {} -self.vpcs["vpc-01234567890abcdef"] = VPC_Object_Class() +self.virtual_machines = {} +self.virtual_machines["vm-01234567890abcdef"] = VirtualMachine() ``` ### Service Client Each Prowler service requires a service client to use the service in the checks. -The following is the `_client.py` containing the initialization of the service's class we have just created so the service's checks can use them: +The following is the `_client.py` file, which contains the initialization of the freshly created service's class so that service checks can use it. This file is almost the same for all the services among the providers: ```python from prowler.providers.common.provider import Provider @@ -230,11 +225,13 @@ from prowler.providers..services.. _client = (Provider.get_global_provider()) ``` -## Permissions +## Provider Permissions in Prowler -It is really important to check if the current Prowler's permissions for each provider are enough to implement a new service. If we need to include more please refer to the following documentaion and update it: +Before implementing a new service, verify that Prowler’s existing permissions for each provider are sufficient. If additional permissions are required, refer to the relevant documentation and update accordingly. -- AWS: https://docs.prowler.cloud/en/latest/getting-started/requirements/#aws-authentication -- Azure: https://docs.prowler.cloud/en/latest/getting-started/requirements/#permissions -- GCP: https://docs.prowler.cloud/en/latest/getting-started/requirements/#gcp-authentication -- M365: https://docs.prowler.cloud/en/latest/getting-started/requirements/#m365-authentication +Provider-Specific Permissions Documentation: + +- [AWS](../getting-started/requirements.md#authentication) +- [Azure](../getting-started/requirements.md#needed-permissions) +- [GCP](../getting-started/requirements.md#needed-permissions_1) +- [M365](../getting-started/requirements.md#needed-permissions_2) diff --git a/docs/developer-guide/unit-testing.md b/docs/developer-guide/unit-testing.md index 9707fcabd8..42674c7899 100644 --- a/docs/developer-guide/unit-testing.md +++ b/docs/developer-guide/unit-testing.md @@ -1,20 +1,20 @@ -# Unit Tests +# Unit Tests for Prowler Checks -The unit tests for the Prowler checks varies between each provider supported. +Unit tests for Prowler checks vary based on the provider being evaluated. -Here we left some good reads about unit testing and things we've learnt through all the process. +Below are key resources and insights gained throughout the testing process. **Python Testing** - https://docs.python-guide.org/writing/tests/ -**Where to patch** +**Where to Patch** - https://docs.python.org/3/library/unittest.mock.html#where-to-patch - https://stackoverflow.com/questions/893333/multiple-variables-in-a-with-statement - ​https://docs.python.org/3/reference/compound_stmts.html#the-with-statement -**Utils to trace mocking and test execution** +**Utilities for Tracing Mocking and Test Execution** - https://news.ycombinator.com/item?id=36054868 - https://docs.python.org/3/library/sys.html#sys.settrace @@ -22,175 +22,251 @@ Here we left some good reads about unit testing and things we've learnt through ## General Recommendations -When creating tests for some provider's checks we follow these guidelines trying to cover as much test scenarios as possible: +When writing tests for Prowler provider checks, follow these guidelines to maximize coverage across test scenarios: -1. Create a test without resource to generate 0 findings, because Prowler will generate 0 findings if a service does not contain the resources the check is looking for audit. -2. Create test to generate both a `PASS` and a `FAIL` result. -3. Create tests with more than 1 resource to evaluate how the check behaves and if the number of findings is right. +1. Zero Findings Scenario: +Develop tests where no resources exist. Prowler returns zero findings if the audited service lacks the required resources. -## How to run Prowler tests +2. Positive and Negative Outcomes: +Create tests that generate both a passing (`PASS`) and a failing (`FAIL`) result. -To run the Prowler test suite you need to install the testing dependencies already included in the `pyproject.toml` file. If you didn't install it yet please read the developer guide introduction [here](./introduction.md#get-the-code-and-install-all-dependencies). +3. Multi-Resource Evaluations: +Design tests with multiple resources to verify check behavior and ensure the correct number of findings. -Then in the project's root path execute `pytest -n auto -vvv -s -x` or use the `Makefile` with `make test`. +## Running Prowler Tests -Other commands to run tests: +To execute the Prowler test suite, install the necessary dependencies listed in the `pyproject.toml` file. -- Run tests for a provider: `pytest -n auto -vvv -s -x tests/providers//services` -- Run tests for a provider service: `pytest -n auto -vvv -s -x tests/providers//services/` -- Run tests for a provider check: `pytest -n auto -vvv -s -x tests/providers//services//` +### Prerequisites + +If you have not installed Prowler yet, refer to the [developer guide introduction](./introduction.md#get-the-code-and-install-all-dependencies). + +### Executing Tests + +Navigate to the project's root directory and execute: `pytest -n auto -vvv -s -x` + +Alternatively, use: +`Makefile` with `make test`. + +Other Commands for Running Tests + +- Running tests for a provider: +`pytest -n auto -vvv -s -x tests/providers//services` +- Running tests for a provider service: +`pytest -n auto -vvv -s -x tests/providers//services/` +- Running tests for a provider check: +`pytest -n auto -vvv -s -x tests/providers//services//` ???+ note - Refer to the [pytest documentation](https://docs.pytest.org/en/7.1.x/getting-started.html) documentation for more information. + Refer to the [pytest documentation](https://docs.pytest.org/en/7.1.x/getting-started.html) for more details. -## AWS +## AWS Testing Approaches -For the AWS provider we have ways to test a Prowler check based on the following criteria: +For AWS provider, different testing approaches apply based on API coverage based on several criteria. ???+ note - We use and contribute to the [Moto](https://github.com/getmoto/moto) library which allows us to easily mock out tests based on AWS infrastructure. **It's awesome!** + Prowler leverages and contributes to the[Moto](https://github.com/getmoto/moto) library for mocking AWS infrastructure in tests. -- AWS API calls covered by [Moto](https://github.com/getmoto/moto): - - Service tests with `@mock_aws` - - Checks tests with `@mock_aws` -- AWS API calls not covered by Moto: - - Service test with `mock_make_api_call` - - Checks tests with [MagicMock](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.MagicMock) -- AWS API calls partially covered by Moto: - - Service test with `@mock_aws` and `mock_make_api_call` - - Checks tests with `@mock_aws` and `mock_make_api_call` +- AWS API Calls Covered by [Moto](https://github.com/getmoto/moto): + - Service Tests: `@mock_aws` + - Checks Tests: `@mock_aws` -In the following section we are going to explain all of the above scenarios with examples. The main difference between those scenarios comes from if the [Moto](https://github.com/getmoto/moto) library covers the AWS API calls made by the service. You can check the covered API calls [here](https://github.com/getmoto/moto/blob/master/IMPLEMENTATION_COVERAGE.md). +- AWS API Calls Not Covered by Moto: + - Service Tests: `mock_make_api_call` + - Checks Tests: [MagicMock](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.MagicMock) -### Checks +- AWS API Calls Partially Covered by Moto: + - Service Tests: `@mock_aws` and `mock_make_api_call` + - Check Tests: `@mock_aws` and `mock_make_api_call` -For the AWS tests examples we are going to use the tests for the `iam_password_policy_uppercase` check. +#### AWS Check Testing Scenarios -This section is going to be divided based on the API coverage of the [Moto](https://github.com/getmoto/moto) library. +The following section provides examples for each testing scenario. The primary distinction between these scenarios depends on whether the [Moto](https://github.com/getmoto/moto) library covers the AWS API calls made by the service. You can review the supported API calls [here](https://github.com/getmoto/moto/blob/master/IMPLEMENTATION_COVERAGE.md). -#### API calls covered +### AWS Check Testing Approach -If the [Moto](https://github.com/getmoto/moto) library covers the API calls we want to test, we can use the `@mock_aws` decorator. This will mocked out all the API calls made to AWS keeping the state within the code decorated, in this case the test function. +For AWS test examples, we reference tests for the `iam_password_policy_uppercase` check. + +This section is categorized based on [Moto](https://github.com/getmoto/moto) API coverage. + +#### API Calls Covered by Moto + +When the [Moto](https://github.com/getmoto/moto) library supports the API calls required for testing, use the `@mock_aws` decorator. This ensures that all AWS API calls within the decorated function are properly mocked while maintaining state within the test. ```python -# We need to import the unittest.mock to allow us to patch some objects -# not to use shared ones between test, hence to isolate the test +# Import unittest.mock to enable object patching +# This prevents shared objects between tests, ensuring test isolation from unittest import mock -# Boto3 client and session to call the AWS APIs +# Import Boto3 client and session for AWS API calls from boto3 import client, session -# Moto decorator +# Import Moto decorator for mocking AWS services from moto import mock_aws -# Constants used +# Define constants for test execution AWS_ACCOUNT_NUMBER = "123456789012" AWS_REGION = "us-east-1" -# We always name the test classes like Test_ +# Test class naming convention: Test_ class Test_iam_password_policy_uppercase: - # We include the Moto decorator + # Apply the Moto decorator for AWS service mocking @mock_aws - # We name the tests with test___ + # Test naming convention: test___ def test_iam_password_policy_no_uppercase_flag(self): - # First, we have to create an IAM client + Steps + + # Step 1: Create an IAM client for API calls in the specified region iam_client = client("iam", region_name=AWS_REGION) - # Then, since all the AWS accounts have a password - # policy we want to set to False the RequireUppercaseCharacters + # Step 2: Modify the account password policy to disable uppercase character enforcement + + # Action: Setting RequireUppercaseCharacters to False + iam_client.update_account_password_policy(RequireUppercaseCharacters=False) - # The aws_provider is mocked using set_mocked_aws_provider to use it as the return of the get_global_provider method. - # this mocked provider is defined in fixtures + # Step 3: Mock the AWS provider to ensure isolated testing + + # Using 'set_mocked_aws_provider' allows overriding the provider response + # This mocked provider is defined in test fixtures + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) - # The Prowler service import MUST be made within the decorated - # code not to make real API calls to the AWS service. + # Step 4: Ensure Prowler service imports occur within the decorated function + # This prevents accidental real API calls to AWS during test execution + from prowler.providers.aws.services.iam.iam_service import IAM - # Prowler for AWS uses a shared object called aws_provider where it stores - # the info related with the provider + # Mocking AWS Provider and IAM Client for Prowler Tests + + #Prowler for AWS relies on a shared object, aws_provider, which stores provider-related information. + + # To ensure proper test isolation and prevent shared objects between tests, we apply mocking techniques. + + # Mocking Global AWS Provider + + #To mock the global provider, we use mock.patch() to override the get_global_provider() method, ensuring aws_provider is the return value. + with mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", return_value=aws_provider, ), - # We have to mock also the iam_client from the check to enforce that the iam_client used is the one - # created within this check because patch != import, and if you execute tests in parallel some objects - # can be already initialised hence the check won't be isolated - mock.patch( + + # Mocking IAM Client for Test Isolation + + #In addition to mocking the provider, we must also mock the iam_client from the check. This ensures that the IAM client used in the test is the one explicitly created within the test. + + # ⚠️ Important: + + # patch != import—simply importing does not ensure proper isolation. + + # Running tests in parallel may cause unintended object initialization, impacting test integrity. + + with mock.patch( "prowler.providers.aws.services.iam.iam_password_policy_uppercase.iam_password_policy_uppercase.iam_client", new=IAM(aws_provider), ): - # We import the check within the two mocks not to initialise the iam_client with some shared information from - # the aws_provider or the IAM service. + # Importing the IAM Check + + # To prevent initialization issues, import the check inside the two-mock context. + + # This ensures the IAM client does not retain shared data from aws_provider or the IAM service. + from prowler.providers.aws.services.iam.iam_password_policy_uppercase.iam_password_policy_uppercase import ( iam_password_policy_uppercase, ) - # Once imported, we only need to instantiate the check's class + # Executing the IAM Check + + # Once imported, instantiate the check’s class. + check = iam_password_policy_uppercase() - # And then, call the execute() function to run the check - # against the IAM client we've set up. + # Then run the execute function() + # against the set up IAM client. + result = check.execute() - # Last but not least, we need to assert all the fields - # from the check's results + # Validating the Check Results + # Finally, assert all fields to verify expected results. + assert len(results) == 1 assert result[0].status == "FAIL" - assert result[0].status_extended == "IAM password policy does not require at least one uppercase letter." + assert result[0].status_extended == "IAM password policy does not srequire at least one uppercase letter." assert result[0].resource_arn == f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:root" assert result[0].resource_id == AWS_ACCOUNT_NUMBER assert result[0].resource_tags == [] assert result[0].region == AWS_REGION ``` -#### API calls not covered +#### Handling API Calls Not Covered by Moto -If the IAM service for the check's we want to test is not covered by Moto, we have to inject the objects in the service client using [MagicMock](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.MagicMock). As we have pointed above, we cannot instantiate the service since it will make real calls to the AWS APIs. +If the IAM service required for testing is not supported by the Moto library, use [MagicMock](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.MagicMock) to inject objects into the service client. + +???+ warning + As stated above, direct service instantiation must be avoided to prevent actual AWS API calls. ???+ note - The following example uses the IAM GetAccountPasswordPolicy which is covered by Moto but this is only for demonstration purposes. + The example below demonstrates the IAM GetAccountPasswordPolicy API, which is covered by Moto, but is used for instructional purposes only. -The following code shows how to use MagicMock to create the service objects. +#### Mocking Service Objects Using MagicMock + +The following code demonstrates how to use MagicMock to create service objects. ```python -# We need to import the unittest.mock to allow us to patch some objects -# not to use shared ones between test, hence to isolate the test +# Import unittest.mock to enable object patching +# This prevents shared objects between tests, ensuring test isolation + from unittest import mock -# Constants used +# Define constants for test execution + AWS_ACCOUNT_NUMBER = "123456789012" AWS_REGION = "us-east-1" -# We always name the test classes like Test_ +# Test class naming convention: Test_ + class Test_iam_password_policy_uppercase: - # We name the tests with test___ + # Test naming convention: test___ + def test_iam_password_policy_no_uppercase_flag(self): - # Mocked client with MagicMock + + # Mock IAM client with MagicMock + mocked_iam_client = mock.MagicMock - # Since the IAM Password Policy has their own model we have to import it + # Import IAM PasswordPolicy model, as it has its own model + from prowler.providers.aws.services.iam.iam_service import PasswordPolicy - # Create the mock PasswordPolicy object + # Create a mock PasswordPolicy object with predefined attributes + mocked_iam_client.password_policy = PasswordPolicy( length=5, symbols=True, numbers=True, - # We set the value to False to test the check + # The value must be set to False to trigger a failure scenario uppercase=False, lowercase=True, allow_change=False, expiration=True, ) - # In this scenario we have to mock also the IAM service and the iam_client from the check to enforce # that the iam_client used is the one created within this check because patch != import, and if you # execute tests in parallel some objects can be already initialised hence the check won't be isolated. - # In this case we don't use the Moto decorator, we use the mocked IAM client for both objects + # In this scenario, both the IAM service and the iam_client from the check must be mocked to ensure test isolation. This guarantees that the iam_client used in the test is the one explicitly instantiated within the test itself. + + # Note: Simply applying a patch does not modify imports (patch != import). + + # If tests are executed in parallel, objects may already be initialized, + # leading to unintended shared state and breaking test isolation. + + # Unlike other cases, we do not use the Moto decorator here. + + # Instead, we mock the IAM client for both objects to prevent real AWS API interactions. + with mock.patch( "prowler.providers.aws.services.iam.iam_service.IAM", new=mocked_iam_client, @@ -198,21 +274,31 @@ class Test_iam_password_policy_uppercase: "prowler.providers.aws.services.iam.iam_client.iam_client", new=mocked_iam_client, ): - # We import the check within the two mocks not to initialise the iam_client with some shared information from - # the aws_provider or the IAM service. + # Importing the IAM Check + + # To prevent initialization issues, import the check inside the two-mock context. + + # This ensures the IAM client does not retain shared data from aws_provider or the IAM service. + from prowler.providers.aws.services.iam.iam_password_policy_uppercase.iam_password_policy_uppercase import ( iam_password_policy_uppercase, ) - # Once imported, we only need to instantiate the check's class + # Executing the IAM Check + + # Once imported, instantiate the check’s class. + check = iam_password_policy_uppercase() - # And then, call the execute() function to run the check - # against the IAM client we've set up. + # Then run the execute function() + # against the set up IAM client. + result = check.execute() - # Last but not least, we need to assert all the fields - # from the check's results + # Validating the Check Results + + # Finally, assert all fields to verify expected results. + assert len(results) == 1 assert result[0].status == "FAIL" assert result[0].status_extended == "IAM password policy does not require at least one uppercase letter." @@ -222,14 +308,15 @@ class Test_iam_password_policy_uppercase: assert result[0].region == AWS_REGION ``` -As it can be seen in the above scenarios, the check execution should always be into the context of mocked/patched objects. This way we ensure it reviews only the objects created under the scope the test. +#### Ensuring Test Isolation with Mocked/Patched Objects -#### API calls partially covered +In all above scenarios, check execution must occur within the context of mocked or patched objects. This guarantees that the test only evaluates objects explicitly created within its scope, preventing interference from shared state or external dependencies. -If the API calls we want to use in the service are partially covered by the Moto decorator we have to create our own mocked API calls to use it in combination. +#### Handling Partially Covered API Calls -To do so, you need to mock the `botocore.client.BaseClient._make_api_call` function, which is the Boto3 function in charge of making the real API call to the AWS APIs, using `mock.patch `: +When a service requires API calls that are partially covered by the Moto decorator, additional mocking is necessary. In such cases, custom mocked API calls must be implemented alongside Moto to ensure full coverage. +To achieve this, mock the `botocore.client.BaseClient._make_api_call` function—the method responsible for making actual API requests to AWS—using `mock.patch `: ```python @@ -239,13 +326,18 @@ from unittest.mock import patch from moto import mock_aws # Original botocore _make_api_call function + orig = botocore.client.BaseClient._make_api_call # Mocked botocore _make_api_call function + def mock_make_api_call(self, operation_name, kwarg): - # As you can see the operation_name has the get_account_password_policy snake_case form but - # we are using the GetAccountPasswordPolicy form. - # Rationale -> https://github.com/boto/botocore/blob/develop/botocore/client.py#L810:L816 + + # The 'operation_name' follows the snake_case format (get_account_password_policy), + # but we use the PascalCase form (GetAccountPasswordPolicy) for consistency with Boto3 conventions. + + # Reference: https://github.com/boto/botocore/blob/develop/botocore/client.py#L810:L816 + if operation_name == 'GetAccountPasswordPolicy': return { 'PasswordPolicy': { @@ -261,29 +353,41 @@ def mock_make_api_call(self, operation_name, kwarg): 'HardExpiry': True|False } } - # If we don't want to patch the API call + + # If API call patching is not required, return the original method execution. + return orig(self, operation_name, kwarg) -# We always name the test classes like Test_ +# Test class naming convention: Test_ + class Test_iam_password_policy_uppercase: - # We include the custom API call mock decorator for the service we want to use + # Apply custom API call mock decorator for the required service + @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) - # We include also the IAM Moto decorator for the API calls supported + + # Also include IAM Moto decorator for supported API calls + @mock_iam - # We name the tests with test___ + + # Test naming convention: test___ + def test_iam_password_policy_no_uppercase_flag(self): - # Check the previous section to see the check test since is the same + + # Refer to the previous section for the check test, as the implementation remains unchanged. ``` -Note that this does not use Moto, to keep it simple, but if you use any `moto`-decorators in addition to the patch, the call to `orig(self, operation_name, kwarg)` will be intercepted by Moto. +???+ note + This example does not use Moto to simplify the setup. + However, if additional `moto` decorators are applied alongside the patch, Moto will automatically intercept the call to `orig(self, operation_name, kwarg)`. ???+ note - The above code comes from here https://docs.getmoto.org/en/latest/docs/services/patching_other_services.html + The source of the above implementation can be found here:[Patch Other Services with Moto](https://docs.getmoto.org/en/latest/docs/services/patching\_other\_services.html) -#### Mocking more than one service +#### Mocking Several Services + +Since the provider is being mocked, multiple attributes can be configured to customize its behavior: -Since we are mocking the provider, it can be customized setting multiple attributes to the provider: ```python def set_mocked_aws_provider( audited_regions: list[str] = [], @@ -306,8 +410,7 @@ def set_mocked_aws_provider( ) -> AwsProvider: ``` -If the test your are creating belongs to a check that uses more than one provider service, you should mock each of the services used. For example, the check `cloudtrail_logs_s3_bucket_access_logging_enabled` requires the CloudTrail and the S3 client, hence the service's mock part of the test will be as follows: - +If a test is designed for a check that interacts with multiple provider services, each service used must be individually mocked. For instance, if the check `cloudtrail_logs_s3_bucket_access_logging_enabled` relies on both the CloudTrail and S3 clients, the test's service mocking section should be structured as follows: ```python with mock.patch( @@ -328,42 +431,45 @@ with mock.patch( ): ``` - -As you can see in the above code, it is required to mock the AWS audit info and both services used. - +As demonstrated in the code above, mocking both the AWS audit information and all utilized services is mandatory for proper test execution. #### Patching vs. Importing -This is an important topic within the Prowler check's unit testing. Due to the dynamic nature of the check's load, the process of importing the service client from a check is the following: +Properly understanding patching versus importing is critical for unit testing with Prowler checks. Given the dynamic nature of the check-loading mechanism, the process for importing a service client within a check follows this structured approach: 1. `.py`: -```python -from prowler.providers..services.._client import _client -``` + + ```python + from prowler.providers..services.._client import _client + ``` + 2. `_client.py`: -```python -from prowler.providers.common.provider import Provider -from prowler.providers..services.._service import -_client = (Provider.get_global_provider()) -``` + ```python + from prowler.providers.common.provider import Provider + from prowler.providers..services.._service import -Due to the above import path it's not the same to patch the following objects because if you run a bunch of tests, either in parallel or not, some clients can be already instantiated by another check, hence your test execution will be using another test's service instance: + _client = (Provider.get_global_provider()) + ``` + +Due to the import path structure, patching certain objects does not always ensure full isolation. If multiple tests—executed sequentially or in parallel—reuse service clients, some instances may already be initialized by another check. This can lead to unintended shared state, affecting test accuracy: - `_client` imported at `.py` - `_client` initialised at `_client.py` - `` imported at `_client.py` -A useful read about this topic can be found in the following article: https://stackoverflow.com/questions/8658043/how-to-mock-an-import +#### Additional Resources on Mocking Imports +For a deeper understanding of mocking imports in Python, refer to the following article: https://stackoverflow.com/questions/8658043/how-to-mock-an-import -#### Different ways to mock the service client +#### Approaches to Mocking a Service Client -##### Mocking the service client at the service client level +1\. Mocking the Service Client at the Service Client Level -Mocking a service client using the following code ... +2\. Mocking a Service Client via Below Code Implementation + +Once all required attributes are configured for the mocked provider, it can be used as the service client for test execution: -Once the needed attributes are set for the mocked provider, you can use the mocked provider: ```python title="Mocking the service_client" with mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", @@ -373,18 +479,20 @@ with mock.patch( new=(set_mocked_aws_provider([])), ): ``` + will cause that the service will be initialised twice: -1. When the `(set_mocked_aws_provider([]))` is mocked out using `mock.patch` to have the object ready for the patching. -2. At the `_client.py` when we are patching it since the `mock.patch` needs to go to that object an initialise it, hence the `(set_mocked_aws_provider([]))` will be called again. +1. When `(set_mocked_aws_provider([]))` is mocked out using `mock.patch`, it must be properly prepared before patching to ensure test consistency. -Then, when we import the `_client.py` at `.py`, since we are mocking where the object is used, Python will use the mocked one. +2. At the point of patching, in `_client.py`, and since `mock.patch` needs to access said object and initialise it, `(set_mocked_aws_provider([]))` will be called again. -In the [next section](./unit-testing.md#mocking-the-service-and-the-service-client-at-the-service-client-level) you will see an improved version to mock objects. +Later, when importing `_client.py` at `.py`, Python uses the mocked instance since the patch was applied at the correct reference point. +In the [next section](./unit-testing.md#mocking-the-service-and-the-service-client-at-the-service-client-level) we will explore an improved approach to mock objects. -##### Mocking the service and the service client at the service client level -Mocking a service client using the following code ... +##### Mocking the Service and the Service Client at the Service Client Level + +##### Mocking a Service Client via Below Code Implementation ```python title="Mocking the service and the service_client" with mock.patch( @@ -398,35 +506,42 @@ with mock.patch( new=service_client, ): ``` -will cause that the service will be initialised once, just when the `set_mocked_aws_provider([])` is mocked out using `mock.patch`. -Then, at the check_level when Python tries to import the client with `from prowler.providers..services.._client`, since it is already mocked out, the execution will continue using the `service_client` without getting into the `_client.py`. +will cause that the service is initialized only once—at the moment of mocking out `set_mocked_aws_provider([])` using `mock.patch`. +Later, when Python attempts to import the client at the check level, the execution continues using`from prowler.providers..services.._client`. As a result of it being already mocked out, the execution will continue using `service_client` without getting into `_client.py`. -### Services +### Testing AWS Services -For testing the AWS services we have to follow the same logic as with the AWS checks, we have to check if the AWS API calls made by the service are covered by Moto and we have to test the service `__init__` to verify that the information is being correctly retrieved. +AWS service testing follows the same methodology as AWS checks: +Verify whether the AWS API calls made by the service are covered by Moto. -The service tests could act as *Integration Tests* since we test how the service retrieves the information from the provider, but since Moto or the custom mock objects mocks that calls this test will fall into *Unit Tests*. +Execute tests on the service `__init__` to ensure correct information retrieval. -Please refer to the [AWS checks tests](./unit-testing.md#checks) for more information on how to create tests and check the existing services tests [here](https://github.com/prowler-cloud/prowler/tree/master/tests/providers/aws/services). +While service tests resemble *Integration Tests*, as they assess how the service interacts with the provider, they ultimately fall under *Unit Tests*, due to the use of Moto or custom mock objects. + +For detailed guidance on test creation and existing service tests, refer to the [AWS checks test](./unit-testing.md#checks) [documentation](https://github.com/prowler-cloud/prowler/tree/master/tests/providers/aws/services). ## GCP -### Checks +### GCP Check Testing Approach -For the GCP Provider we don't have any library to mock out the API calls we use. So in this scenario we inject the objects in the service client using [MagicMock](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.MagicMock). +Currently the GCP Provider does not have a dedicated library for mocking API calls. To ensure proper test isolation, objects must be manually injected into the service client using [MagicMock](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.MagicMock). -The following code shows how to use MagicMock to create the service objects for a GCP check test. It is a real example adapted for informative purposes. +Mocking Service Objects Using MagicMock + +The following code demonstrates how to use MagicMock to create service objects for a GCP check test. This is a real-world implementation, adapted for instructional clarity. ```python from re import search from unittest import mock -# Import some constant values needed in every check +# Import constant values needed in every check + from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider -# We are going to create a test for the compute_project_os_login_enabled check +# Create a test for the compute_project_os_login_enabled check + class Test_compute_project_os_login_enabled: def test_one_compliant_project(self): @@ -437,13 +552,15 @@ class Test_compute_project_os_login_enabled: id=GCP_PROJECT_ID, enable_oslogin=True, ) - # Mocked client with MagicMock + # Mock IAM client with MagicMock compute_client = mock.MagicMock compute_client.project_ids = [GCP_PROJECT_ID] compute_client.projects = [project] - # In this scenario we have to mock the app_client from the check to enforce that the compute_client used is the one created above - # And also is mocked the return value of get_global_provider function to return our GCP mocked provider defined in fixtures + # In this scenario, the app_client from the check must be mocked to ensure that the compute_client used in the test is the explicitly created instance. + + # Additionally, the return value of the get_global_provider function is mocked to return the predefined GCP mocked provider from the test fixtures. + with mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", return_value=set_mocked_gcp_provider(), @@ -451,16 +568,24 @@ class Test_compute_project_os_login_enabled: "prowler.providers.gcp.services.compute.compute_project_os_login_enabled.compute_project_os_login_enabled.compute_client", new=compute_client, ): - # We import the check within the two mocks + # Import the check within the two mocks + from prowler.providers.gcp.services.compute.compute_project_os_login_enabled.compute_project_os_login_enabled import ( compute_project_os_login_enabled, ) - # Once imported, we only need to instantiate the check's class + + # Executing the IAM Check + # Once imported, instantiate the check’s class. + check = compute_project_os_login_enabled() - # And then, call the execute() function to run the check - # against the Compute client we've set up. + + # Then run the execute function() + # against the set up Compute client. + result = check.execute() + # Assert the expected results + assert len(result) == 1 assert result[0].status == "PASS" assert search( @@ -471,7 +596,10 @@ class Test_compute_project_os_login_enabled: assert result[0].location == "global" assert result[0].project_id == GCP_PROJECT_ID - # Complementary test to make more coverage for different scenarios + # Complementary Test + + # The following is an additional test for a wider scenario coverage + def test_one_non_compliant_project(self): from prowler.providers.gcp.services.compute.compute_service import Project @@ -510,19 +638,28 @@ class Test_compute_project_os_login_enabled: ``` -### Services +### Testing GCP Services -For testing Google Cloud Services, we have to follow the same logic as with the Google Cloud checks. We still mocking all API calls, but in this case, every API call to set up an attribute is defined in [fixtures file](https://github.com/prowler-cloud/prowler/blob/master/tests/providers/gcp/gcp_fixtures.py) in `mock_api_client` function. Remember that EVERY method of a service must be tested. +The testing of Google Cloud Services follows the same principles as the one of Google Cloud checks. While all API calls must be mocked, attribute setup for API calls in this scenario is defined in the fixtures file, specifically within the [fixtures file](https://github.com/prowler-cloud/prowler/blob/master/tests/providers/gcp/gcp_fixtures.py) in the `mock_api_client` function. -The following code shows a real example of a testing class, but it has more comments than usual for educational purposes. +???+ important + Every method within a service must be tested to ensure full coverage and accurate validation. + +The following example presents a real testing class, but includes additional comments for educational purposes, explaining key concepts and implementation details. ```python title="BigQuery Service Test" -# We need to import the unittest.mock.patch to allow us to patch some objects -# not to use shared ones between test, hence to isolate the test + +# Import unittest.mock.patch to enable object patching +# This prevents shared objects between tests, ensuring test isolation + from unittest.mock import patch + # Import the class needed from the service file + from prowler.providers.gcp.services.bigquery.bigquery_service import BigQuery -# Necessary constans and functions from fixtures file + +# Use necessary constants and functions from fixtures file + from tests.providers.gcp.gcp_fixtures import ( GCP_PROJECT_ID, mock_api_client, @@ -532,10 +669,10 @@ from tests.providers.gcp.gcp_fixtures import ( class TestBigQueryService: - # Only method needed to test full service + # The only method needed to test full service def test_service(self): - # In this case we are mocking the __is_api_active__ to ensure our mocked project is used - # And all the client to use our mocked API calls + # Mocking '__is_api_active__' ensures that the test utilizes the predefined mocked project instead of a real instance. + # Additionally, all client interactions are patched to use the mocked API calls. with patch( "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", new=mock_is_api_active, @@ -547,7 +684,7 @@ class TestBigQueryService: bigquery_client = BigQuery( set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) ) - # Check all attributes of the tested class is well set up according API calls mocked from GCP fixture file + # Verify that all attributes of the tested class are correctly initialized based on the API calls mocked from the GCP fixture file. assert bigquery_client.service == "bigquery" assert bigquery_client.project_ids == [GCP_PROJECT_ID] @@ -581,16 +718,28 @@ class TestBigQueryService: assert not bigquery_client.tables[1].cmk_encryption assert bigquery_client.tables[1].project_id == GCP_PROJECT_ID ``` -As it can be confusing where all these values come from, I'll give an example to make this clearer. First we need to check -what is the API call used to obtain the datasets. In this case if we check the service the call is -`self.client.datasets().list(projectId=project_id)`. -Now in the fixture file we have to mock this call in our `MagicMock` client in the function `mock_api_client`. The best way to mock -is following the actual format, add one function where the client is passed to be changed, the format of this function name must be -`mock_api__calls` (*endpoint* refers to the first attribute pointed after *client*). +Clarifying Value Origins with an Example -In the example of BigQuery the function is called `mock_api_dataset_calls`. And inside of this function we found an assignation to -be used in the `_get_datasets` method in BigQuery class: +Understanding where specific values originate can be challenging, so the following example provides clarity. + +- Step 1: Identify the API Call for Dataset Retrieval + + To determine how datasets are obtained, examine the API call used by the service. In this case, the relevant service call is: `self.client.datasets().list(projectId=project_id)`. + +- Step 2: Mocking the API Call in the Fixture File + + In the fixture file, mock this call in the `MagicMock` client, in the function `mock_api_client`. + +- Step 3: Structuring the Mock Function + + The best approach for mocking is to adhere to the service’s existing format: + +Define a dedicated function that modifies the client. + +Follow the naming convention: `mock_api__calls` (*endpoint* refers to the first attribute pointed after *client*). + +For BigQuery, the mock function is called `mock_api_dataset_calls`. Within this function, an assignment is made for use in the `_get_datasets` method of the BigQuery class: ```python # Mocking datasets @@ -619,39 +768,46 @@ client.datasets().list().execute.return_value = { } ``` - ## Azure -### Checks +### Azure Check Testing Approach -For the Azure Provider we don't have any library to mock out the API calls we use. So in this scenario we inject the objects in the service client using [MagicMock](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.MagicMock). +Currently the Azure Provider does not have a dedicated library for mocking API calls. To ensure proper test isolation, objects must be manually injected into the service client using [MagicMock](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.MagicMock). -The following code shows how to use MagicMock to create the service objects for a Azure check test. It is a real example adapted for informative purposes. +Mocking Service Objects Using MagicMock + +The following code demonstrates how to use MagicMock to create service objects for an Azure check test. This is a real-world implementation, adapted for instructional clarity. ```python title="app_ensure_http_is_redirected_to_https_test.py" -# We need to import the unittest.mock to allow us to patch some objects -# not to use shared ones between test, hence to isolate the test + +# Import unittest.mock to enable object patching +# This prevents shared objects between tests, ensuring test isolation + from unittest import mock from uuid import uuid4 # Import some constans values needed in almost every check + from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, ) -# We are going to create a test for the app_ensure_http_is_redirected_to_https check +# Create a test for the app_ensure_http_is_redirected_to_https check + class Test_app_ensure_http_is_redirected_to_https: - # We name the tests with test___ + # Test naming convention: test___ def test_app_http_to_https_disabled(self): resource_id = f"/subscriptions/{uuid4()}" - # Mocked client with MagicMock + # Mock IAM client with MagicMock app_client = mock.MagicMock - # In this scenario we have to mock the app_client from the check to enforce that the app_client used is the one created above - # And also is mocked the return value of get_global_provider function to return our Azure mocked provider defined in fixtures + # In this scenario, the app_client from the check must be mocked to ensure that the app_client used in the test is the explicitly created instance. + + # Additionally, the return value of the get_global_provider function is mocked to return the predefined Azure mocked provider from the test fixtures. + with mock.patch( "prowler.providers.common.provider.Provider.get_global_provider", return_value=set_mocked_azure_provider(), @@ -659,7 +815,7 @@ class Test_app_ensure_http_is_redirected_to_https: "prowler.providers.azure.services.app.app_ensure_http_is_redirected_to_https.app_ensure_http_is_redirected_to_https.app_client", new=app_client, ): - # We import the check within the two mocks + # Import the check within the two mocks from prowler.providers.azure.services.app.app_ensure_http_is_redirected_to_https.app_ensure_http_is_redirected_to_https import ( app_ensure_http_is_redirected_to_https, ) @@ -681,10 +837,11 @@ class Test_app_ensure_http_is_redirected_to_https: ) } } - # Once imported, we only need to instantiate the check's class + # Executing the IAM Check + # Once imported, instantiate the check’s class. check = app_ensure_http_is_redirected_to_https() - # And then, call the execute() function to run the check - # against the App client we've set up. + # Then run the execute function() + # against the set up App client. result = check.execute() # Assert the expected results assert len(result) == 1 @@ -698,7 +855,9 @@ class Test_app_ensure_http_is_redirected_to_https: assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert result[0].location == "West Europe" - # Complementary test to make more coverage for different scenarios + # Complementary Test + # The following is an additional test for a wider scenario coverage + def test_app_http_to_https_enabled(self): resource_id = f"/subscriptions/{uuid4()}" app_client = mock.MagicMock @@ -744,28 +903,38 @@ class Test_app_ensure_http_is_redirected_to_https: ``` -### Services +### Testing Azure Services -For testing Azure services, we have to follow the same logic as with the Azure checks. We still mock all the API calls, but in this case, every method that uses an API call to set up an attribute is mocked with the [patch](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch) decorator at the beginning of the class. Remember that every method of a service MUST be tested. +The testing of Azure Services follows the same principles as the one of Google Cloud checks. All API calls are still mocked, but for methods that initialize attributes via an API call, use the [patch](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.patch) decorator at the beginning of the class to ensure proper mocking. -The following code shows a real example of a testing class, but it has more comments than usual for educational purposes. +???+ important "Remember" + Every method within a service must be tested to ensure full coverage and accurate validation. + +The following example presents a real testing class, but includes additional comments for educational purposes, explaining key concepts and implementation details. ```python title="AppInsights Service Test" -# We need to import the unittest.mock.patch to allow us to patch some objects -# not to use shared ones between test, hence to isolate the test + +# Import unittest.mock.patch to enable object patching +# This prevents shared objects between tests, ensuring test isolation + from unittest.mock import patch + # Import the models needed from the service file + from prowler.providers.azure.services.appinsights.appinsights_service import ( AppInsights, Component, ) + # Import some constans values needed in almost every check + from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, ) -# Function to mock the service function _get_components, this function task is to return a possible value that real function could returns +# Function to mock the service function _get_components; the aim of this function is to return a possible value that a real function could return. + def mock_appinsights_get_components(_): return { AZURE_SUBSCRIPTION_ID: { @@ -777,24 +946,25 @@ def mock_appinsights_get_components(_): } } -# Patch decorator to use the mocked function instead the function with the real API call +# Patch decorator to use the mocked function instead of the function with the real API call + @patch( "prowler.providers.azure.services.appinsights.appinsights_service.AppInsights._get_components", new=mock_appinsights_get_components, ) class Test_AppInsights_Service: - # Mandatory test for every service, this method test the instance of the client is correct + # Mandatory test for every service; this method tests if the instance of the client is correct. def test_get_client(self): app_insights = AppInsights(set_mocked_azure_provider()) assert ( app_insights.clients[AZURE_SUBSCRIPTION_ID].__class__.__name__ == "ApplicationInsightsManagementClient" ) - # Second typical method that test if subscriptions is defined inside the client object + # Second typical method that tests if subscriptions are defined inside the client object. def test__get_subscriptions__(self): app_insights = AppInsights(set_mocked_azure_provider()) assert app_insights.subscriptions.__class__.__name__ == "dict" - # Test for the function _get_components, inside this client is used the mocked function + # Test for the function _get_components; the mocked function is used within this client. def test_get_components(self): appinsights = AppInsights(set_mocked_azure_provider()) assert len(appinsights.components) == 1 diff --git a/docs/getting-started/requirements.md b/docs/getting-started/requirements.md index efe66d8999..37b8ab5663 100644 --- a/docs/getting-started/requirements.md +++ b/docs/getting-started/requirements.md @@ -70,9 +70,13 @@ 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: - - `Domain.Read.All` + - `Directory.Read.All` - `Policy.Read.All` - `UserAuthenticationMethod.Read.All` (used only for the Entra checks related with multifactor authentication) + + ???+ note + You can replace `Directory.Read.All` with `Domain.Read.All` that is a more restrictive permission but you won't be able to run the Entra checks related with DirectoryRoles and GetUsers. + - **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: - `Reader` - `ProwlerRole` (custom role with minimal permissions defined in [prowler-azure-custom-role](https://github.com/prowler-cloud/prowler/blob/master/permissions/prowler-azure-custom-role.json)) @@ -169,6 +173,8 @@ 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 **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. + ???+ warning + If the user is newly created, you need to sign in with that account first, as Microsoft will prompt you to change the password. If you don’t complete this step, user authentication will fail because Microsoft marks the initial password as expired. ???+ 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. @@ -203,12 +209,17 @@ Prowler for M365 requires two types of permission scopes to be set (if you want - **Service Principal Application Permissions**: These are set at the **application** level and are used to retrieve data from the identity being assessed: - `AuditLog.Read.All`: Required for Entra service. - - `Domain.Read.All`: Required for all services. - - `Organization.Read.All`: Required for retrieving tenant information. + - `Directory.Read.All`: Required for all services. - `Policy.Read.All`: Required for all services. - `SharePointTenantSettings.Read.All`: Required for SharePoint service. - `User.Read` (IMPORTANT: this must be set as **delegated**): Required for the sign-in. + ???+ note + You can replace `Directory.Read.All` with `Domain.Read.All` is a more restrictive permission but you won't be able to run the Entra checks related with DirectoryRoles and GetUsers. + + > If you do this you will need to add also the `Organization.Read.All` permission to the service principal application in order to authenticate. + + - **Powershell Modules Permissions**: 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/tutorials/azure/getting-started-azure.md b/docs/tutorials/azure/getting-started-azure.md index 3fabdcc3a5..94c5d189e9 100644 --- a/docs/tutorials/azure/getting-started-azure.md +++ b/docs/tutorials/azure/getting-started-azure.md @@ -90,11 +90,14 @@ A Service Principal is required to grant Prowler the necessary privileges. Assign the following Microsoft Graph permissions: - - Domain.Read.All +- Directory.Read.All - - Policy.Read.All +- Policy.Read.All - - UserAuthenticationMethod.Read.All (optional, for MFA checks) +- UserAuthenticationMethod.Read.All (optional, for MFA checks) + +???+ note + You can replace `Directory.Read.All` with `Domain.Read.All` that is a more restrictive permission but you won't be able to run the Entra checks related with DirectoryRoles and GetUsers. 1. Go to your App Registration > `API permissions` @@ -107,7 +110,7 @@ Assign the following Microsoft Graph permissions: 3. Search and select: - - `Domain.Read.All` + - `Directory.Read.All` - `Policy.Read.All` - `UserAuthenticationMethod.Read.All` diff --git a/docs/tutorials/azure/img/domain-permission.png b/docs/tutorials/azure/img/domain-permission.png index 467ec8ff36..2d442bac46 100644 Binary files a/docs/tutorials/azure/img/domain-permission.png 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 dc1e66c96c..d7790292e9 100644 --- a/docs/tutorials/configuration_file.md +++ b/docs/tutorials/configuration_file.md @@ -31,6 +31,7 @@ The following list includes all the AWS checks with configurable variables that | `cloudtrail_threat_detection_privilege_escalation` | `threat_detection_privilege_escalation_minutes` | Integer | | `cloudwatch_log_group_no_secrets_in_logs` | `secrets_ignore_patterns` | List of Strings | | `cloudwatch_log_group_retention_policy_specific_days_enabled` | `log_group_retention_days` | Integer | +| `codebuild_github_allowed_organizations` | `github_allowed_organizations` | List of Strings | | `codebuild_project_no_secrets_in_variables` | `excluded_sensitive_environment_variables` | List of Strings | | `codebuild_project_no_secrets_in_variables` | `secrets_ignore_patterns` | List of Strings | | `config_recorder_all_regions_enabled` | `mute_non_default_regions` | Boolean | diff --git a/docs/tutorials/microsoft365/getting-started-m365.md b/docs/tutorials/microsoft365/getting-started-m365.md index f45fde5c2c..46184d84e2 100644 --- a/docs/tutorials/microsoft365/getting-started-m365.md +++ b/docs/tutorials/microsoft365/getting-started-m365.md @@ -95,12 +95,18 @@ 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: + - `AuditLog.Read.All`: Required for Entra service. -- `Domain.Read.All`: Required for all services. +- `Directory.Read.All`: Required for all services. - `Policy.Read.All`: Required for all services. - `SharePointTenantSettings.Read.All`: Required for SharePoint service. - `User.Read` (IMPORTANT: this is set as **delegated**): Required for the sign-in. +???+ note + You can replace `Directory.Read.All` with `Domain.Read.All` is a more restrictive permission but you won't be able to run the Entra checks related with DirectoryRoles and GetUsers. + + > If you do this you will need to add also the `Organization.Read.All` permission to the service principal application in order to authenticate. + Follow these steps to assign the permissions: 1. Go to your App Registration > Select your Prowler App created before > click on `API permissions` @@ -113,8 +119,7 @@ Follow these steps to assign the permissions: 3. Search and select every permission below and once all are selected click on `Add permissions`: - `AuditLog.Read.All`: Required for Entra service. - - `Domain.Read.All` - - `Organization.Read.All` + - `Directory.Read.All` - `Policy.Read.All` - `SharePointTenantSettings.Read.All` @@ -134,7 +139,7 @@ Follow these steps to assign the permissions: ![Permission Screenshots](./img/directory-permission-delegated.png) -6. Click `Add permissions`, then **grant admin consent** +6. After adding all the permissions, click on `Grant admin consent` ![Grant Admin Consent](./img/grant-admin-consent.png) @@ -171,6 +176,9 @@ Follow these steps to assign the role: ![Grant Admin Consent for Role](./img/grant-admin-consent-for-role.png) +???+ warning + Remember that if the user is newly created, you need to sign in with that account first, as Microsoft will prompt you to change the password. If you don’t complete this step, user authentication will fail because Microsoft marks the initial password as expired. + --- ## Step 4: Add credentials to Prowler Cloud/App diff --git a/docs/tutorials/microsoft365/img/app-permissions.png b/docs/tutorials/microsoft365/img/app-permissions.png index 386447d31f..eb330ee552 100644 Binary files a/docs/tutorials/microsoft365/img/app-permissions.png 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 index 4d094ee082..601f032a8d 100644 Binary files a/docs/tutorials/microsoft365/img/final-permissions-m365.png and b/docs/tutorials/microsoft365/img/final-permissions-m365.png differ diff --git a/docs/tutorials/microsoft365/img/grant-admin-consent.png b/docs/tutorials/microsoft365/img/grant-admin-consent.png index b5953c6771..0b242308f9 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/reporting.md b/docs/tutorials/reporting.md index 032065052a..fef53a2a41 100644 --- a/docs/tutorials/reporting.md +++ b/docs/tutorials/reporting.md @@ -116,7 +116,7 @@ The following table shows the mapping between the CSV headers and the the provid ### JSON-OCSF -The JSON-OCSF output format implements the [Detection Finding](https://schema.ocsf.io/1.1.0/classes/detection_finding) from the [OCSF v1.1.0](https://schema.ocsf.io/1.1.0) +The JSON-OCSF output format implements the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) from the [OCSF](https://schema.ocsf.io) ```json [{ diff --git a/mkdocs.yml b/mkdocs.yml index 3ceb8c241a..bf597ce494 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -107,18 +107,27 @@ nav: - Authentication: tutorials/microsoft365/authentication.md - Use of PowerShell: tutorials/microsoft365/use-of-powershell.md - Developer Guide: - - Introduction: developer-guide/introduction.md - - Provider: developer-guide/provider.md - - Services: developer-guide/services.md - - Checks: developer-guide/checks.md - - Documentation: developer-guide/documentation.md - - Compliance: developer-guide/security-compliance-framework.md - - Outputs: developer-guide/outputs.md - - Integrations: developer-guide/integrations.md - - Testing: + - General Concepts: + - Introduction: developer-guide/introduction.md + - Providers: developer-guide/provider.md + - Services: developer-guide/services.md + - Checks: developer-guide/checks.md + - Outputs: developer-guide/outputs.md + - Integrations: developer-guide/integrations.md + - Compliance: developer-guide/security-compliance-framework.md + - Provider Specific Details: + - AWS: developer-guide/aws-details.md + - Azure: developer-guide/azure-details.md + - Google Cloud: developer-guide/gcp-details.md + - Kubernetes: developer-guide/kubernetes-details.md + - Microsoft 365: developer-guide/m365-details.md + - GitHub: developer-guide/github-details.md + - Miscellaneous: + - Documentation: developer-guide/documentation.md + - Testing: - Unit Tests: developer-guide/unit-testing.md - Integration Tests: developer-guide/integration-testing.md - - Debugging: developer-guide/debugging.md + - Debugging: developer-guide/debugging.md - Security: security.md - Contact Us: contact.md - Troubleshooting: troubleshooting.md diff --git a/poetry.lock b/poetry.lock index 4003e954d4..21e760e7eb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -12,6 +12,21 @@ files = [ {file = "about_time-4.2.1-py3-none-any.whl", hash = "sha256:8bbf4c75fe13cbd3d72f49a03b02c5c7dca32169b6d49117c257e7eb3eaee341"}, ] +[[package]] +name = "aiodns" +version = "3.5.0" +description = "Simple DNS resolver for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiodns-3.5.0-py3-none-any.whl", hash = "sha256:6d0404f7d5215849233f6ee44854f2bb2481adf71b336b2279016ea5990ca5c5"}, + {file = "aiodns-3.5.0.tar.gz", hash = "sha256:11264edbab51896ecf546c18eb0dd56dff0428c6aa6d2cd87e643e07300eb310"}, +] + +[package.dependencies] +pycares = ">=4.9.0" + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -128,6 +143,22 @@ yarl = ">=1.17.0,<2.0" [package.extras] speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.2.0) ; sys_platform == \"linux\" or sys_platform == \"darwin\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +[[package]] +name = "aiomultiprocess" +version = "0.9.1" +description = "AsyncIO version of the standard multiprocessing module" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "aiomultiprocess-0.9.1-py3-none-any.whl", hash = "sha256:3a7b3bb3c38dbfb4d9d1194ece5934b6d32cf0280e8edbe64a7d215bba1322c6"}, + {file = "aiomultiprocess-0.9.1.tar.gz", hash = "sha256:f0231dbe0291e15325d7896ebeae0002d95a4f2675426ca05eb35f24c60e495b"}, +] + +[package.extras] +dev = ["attribution (==1.7.1)", "black (==24.4.0)", "coverage (==7.4.4)", "flake8 (==7.0.0)", "flake8-bugbear (==24.4.21)", "flit (==3.9.0)", "mypy (==1.9.0)", "usort (==1.0.8.post1)", "uvloop (==0.19.0) ; sys_platform != \"win32\""] +docs = ["sphinx (==7.3.7)", "sphinx-mdinclude (==0.6.0)"] + [[package]] name = "aiosignal" version = "1.3.2" @@ -159,6 +190,18 @@ files = [ about-time = "4.2.1" grapheme = "0.6.0" +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + [[package]] name = "antlr4-python3-runtime" version = "4.13.2" @@ -194,6 +237,39 @@ doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] +[[package]] +name = "argcomplete" +version = "3.6.2" +description = "Bash tab completion for argparse" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "argcomplete-3.6.2-py3-none-any.whl", hash = "sha256:65b3133a29ad53fb42c48cf5114752c7ab66c1c38544fdf6460f450c09b42591"}, + {file = "argcomplete-3.6.2.tar.gz", hash = "sha256:d0519b1bc867f5f4f4713c41ad0aba73a4a5f007449716b16f385f2166dc6adf"}, +] + +[package.extras] +test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] + +[[package]] +name = "asteval" +version = "1.0.5" +description = "Safe, minimalistic evaluator of python expression using ast module" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "asteval-1.0.5-py3-none-any.whl", hash = "sha256:082b95312578affc8a6d982f7d92b7ac5de05634985c87e7eedd3188d31149fa"}, + {file = "asteval-1.0.5.tar.gz", hash = "sha256:bac3c8dd6d2b789e959cfec9bb296fb8338eec066feae618c462132701fbc665"}, +] + +[package.extras] +all = ["asteval[dev,doc,test]"] +dev = ["build", "twine"] +doc = ["Sphinx"] +test = ["coverage", "pytest", "pytest-cov"] + [[package]] name = "astroid" version = "3.3.9" @@ -498,6 +574,23 @@ azure-mgmt-core = ">=1.3.2" isodate = ">=0.6.1" typing-extensions = ">=4.6.0" +[[package]] +name = "azure-mgmt-databricks" +version = "2.0.0" +description = "Microsoft Azure Data Bricks Management Client Library for Python" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "azure-mgmt-databricks-2.0.0.zip", hash = "sha256:70d11362dc2d17f5fb1db0cfe65c1af55b8f136f1a0db9a5b51e7acf760cf5b9"}, + {file = "azure_mgmt_databricks-2.0.0-py3-none-any.whl", hash = "sha256:0c29434a7339e74231bd171a6c08dcdf8153abaebd332658d7f66b8ea143fa17"}, +] + +[package.dependencies] +azure-common = ">=1.1,<2.0" +azure-mgmt-core = ">=1.3.2,<2.0.0" +isodate = ">=0.6.1,<1.0.0" + [[package]] name = "azure-mgmt-keyvault" version = "10.3.1" @@ -751,6 +844,100 @@ test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", toml = ["tomli (>=1.1.0) ; python_version < \"3.11\""] yaml = ["PyYAML"] +[[package]] +name = "bc-detect-secrets" +version = "1.5.44" +description = "Tool for detecting secrets in the codebase" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "bc_detect_secrets-1.5.44-py3-none-any.whl", hash = "sha256:0ab63d6c4f6680ec2dbe42cc3c63480568c55dbb6254afcc5bb6d4375a4e1d27"}, + {file = "bc_detect_secrets-1.5.44.tar.gz", hash = "sha256:bebd82c56055c600335f85db95f7ca3b434087f16292a0396a60705de1b94183"}, +] + +[package.dependencies] +pyyaml = "*" +requests = "*" +unidiff = "*" + +[package.extras] +gibberish = ["gibberish-detector"] +word-list = ["pyahocorasick"] + +[[package]] +name = "bc-jsonpath-ng" +version = "1.6.1" +description = "A final implementation of JSONPath for Python that aims to be standard compliant, including arithmetic and binary comparison operators and providing clear AST for metaprogramming." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "bc-jsonpath-ng-1.6.1.tar.gz", hash = "sha256:6ea4e379c4400a511d07605b8d981950292dd098a5619d143328af4e841a2320"}, + {file = "bc_jsonpath_ng-1.6.1-py3-none-any.whl", hash = "sha256:2c85bb1d194376808fe1fc49558dd484e39024b15c719995e22de811e6ba4dc8"}, +] + +[package.dependencies] +decorator = "*" +ply = "*" + +[[package]] +name = "bc-python-hcl2" +version = "0.4.2" +description = "A parser for HCL2" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "bc-python-hcl2-0.4.2.tar.gz", hash = "sha256:ac8ff59fb9bd437ea29b89a7d7c507fd0a1e957845bae9aeac69f2892b8d681e"}, + {file = "bc_python_hcl2-0.4.2-py3-none-any.whl", hash = "sha256:90d2afbaa2c7e77b7b30bf58180084e11d95287f7c3e19c5bfbdb54ab2fd80e9"}, +] + +[package.dependencies] +lark = ">=1.0.0" + +[[package]] +name = "beartype" +version = "0.21.0" +description = "Unbearably fast near-real-time hybrid runtime-static type-checking in pure Python." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "beartype-0.21.0-py3-none-any.whl", hash = "sha256:b6a1bd56c72f31b0a496a36cc55df6e2f475db166ad07fa4acc7e74f4c7f34c0"}, + {file = "beartype-0.21.0.tar.gz", hash = "sha256:f9a5078f5ce87261c2d22851d19b050b64f6a805439e8793aecf01ce660d3244"}, +] + +[package.extras] +dev = ["autoapi (>=0.9.0)", "click", "coverage (>=5.5)", "equinox ; sys_platform == \"linux\"", "jax[cpu] ; sys_platform == \"linux\"", "jaxtyping ; sys_platform == \"linux\"", "langchain", "mypy (>=0.800) ; platform_python_implementation != \"PyPy\"", "nuitka (>=1.2.6) ; sys_platform == \"linux\"", "numba ; python_version < \"3.13.0\"", "numpy ; sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "pandera", "pydata-sphinx-theme (<=0.7.2)", "pygments", "pyright (>=1.1.370)", "pytest (>=4.0.0)", "rich-click", "sphinx", "sphinx (>=4.2.0,<6.0.0)", "sphinxext-opengraph (>=0.7.5)", "sqlalchemy", "tox (>=3.20.1)", "typing-extensions (>=3.10.0.0)", "xarray"] +doc-rtd = ["autoapi (>=0.9.0)", "pydata-sphinx-theme (<=0.7.2)", "sphinx (>=4.2.0,<6.0.0)", "sphinxext-opengraph (>=0.7.5)"] +test = ["click", "coverage (>=5.5)", "equinox ; sys_platform == \"linux\"", "jax[cpu] ; sys_platform == \"linux\"", "jaxtyping ; sys_platform == \"linux\"", "langchain", "mypy (>=0.800) ; platform_python_implementation != \"PyPy\"", "nuitka (>=1.2.6) ; sys_platform == \"linux\"", "numba ; python_version < \"3.13.0\"", "numpy ; sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "pandera", "pygments", "pyright (>=1.1.370)", "pytest (>=4.0.0)", "rich-click", "sphinx", "sqlalchemy", "tox (>=3.20.1)", "typing-extensions (>=3.10.0.0)", "xarray"] +test-tox = ["click", "equinox ; sys_platform == \"linux\"", "jax[cpu] ; sys_platform == \"linux\"", "jaxtyping ; sys_platform == \"linux\"", "langchain", "mypy (>=0.800) ; platform_python_implementation != \"PyPy\"", "nuitka (>=1.2.6) ; sys_platform == \"linux\"", "numba ; python_version < \"3.13.0\"", "numpy ; sys_platform != \"darwin\" and platform_python_implementation != \"PyPy\"", "pandera", "pygments", "pyright (>=1.1.370)", "pytest (>=4.0.0)", "rich-click", "sphinx", "sqlalchemy", "typing-extensions (>=3.10.0.0)", "xarray"] +test-tox-coverage = ["coverage (>=5.5)"] + +[[package]] +name = "beautifulsoup4" +version = "4.13.4" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.7.0" +groups = ["main"] +files = [ + {file = "beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b"}, + {file = "beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195"}, +] + +[package.dependencies] +soupsieve = ">1.2" +typing-extensions = ">=4.0.0" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + [[package]] name = "black" version = "25.1.0" @@ -810,20 +997,38 @@ files = [ {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, ] +[[package]] +name = "boolean-py" +version = "5.0" +description = "Define boolean algebras, create and parse boolean expressions and create custom boolean DSL." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9"}, + {file = "boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95"}, +] + +[package.extras] +dev = ["build", "twine"] +docs = ["Sphinx (>=3.3.1)", "doc8 (>=0.8.1)", "sphinx-rtd-theme (>=0.5.0)", "sphinxcontrib-apidoc (>=0.3.0)"] +linting = ["black", "isort", "pycodestyle"] +testing = ["pytest (>=6,!=7.0.0)", "pytest-xdist (>=2)"] + [[package]] name = "boto3" -version = "1.35.99" +version = "1.35.49" description = "The AWS SDK for Python" optional = false python-versions = ">=3.8" groups = ["main", "dev"] files = [ - {file = "boto3-1.35.99-py3-none-any.whl", hash = "sha256:83e560faaec38a956dfb3d62e05e1703ee50432b45b788c09e25107c5058bd71"}, - {file = "boto3-1.35.99.tar.gz", hash = "sha256:e0abd794a7a591d90558e92e29a9f8837d25ece8e3c120e530526fe27eba5fca"}, + {file = "boto3-1.35.49-py3-none-any.whl", hash = "sha256:b660c649a27a6b47a34f6f858f5bd7c3b0a798a16dec8dda7cbebeee80fd1f60"}, + {file = "boto3-1.35.49.tar.gz", hash = "sha256:ddecb27f5699ca9f97711c52b6c0652c2e63bf6c2bfbc13b819b4f523b4d30ff"}, ] [package.dependencies] -botocore = ">=1.35.99,<1.36.0" +botocore = ">=1.35.49,<1.36.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.10.0,<0.11.0" @@ -853,6 +1058,18 @@ urllib3 = [ [package.extras] crt = ["awscrt (==0.22.0)"] +[[package]] +name = "cached-property" +version = "2.0.1" +description = "A decorator for caching properties in classes." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "cached_property-2.0.1-py3-none-any.whl", hash = "sha256:f617d70ab1100b7bcf6e42228f9ddcb78c676ffa167278d9f730d1c2fba69ccb"}, + {file = "cached_property-2.0.1.tar.gz", hash = "sha256:484d617105e3ee0e4f1f58725e72a8ef9e93deee462222dbd51cd91230897641"}, +] + [[package]] name = "cachetools" version = "5.5.2" @@ -958,6 +1175,18 @@ markers = {dev = "platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = "*" +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + [[package]] name = "cfn-lint" version = "1.34.1" @@ -1099,6 +1328,67 @@ files = [ {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, ] +[[package]] +name = "checkov" +version = "3.2.445" +description = "Infrastructure as code static analysis" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "checkov-3.2.445-py3-none-any.whl", hash = "sha256:c0cd1109cbcf1c764198a7c64b911845b83776587a65e0677880ed67a39d1f73"}, + {file = "checkov-3.2.445.tar.gz", hash = "sha256:78705d34a9c7234bd7076c1970daedd18e823f79e7d83d2998bb84695cc33ebc"}, +] + +[package.dependencies] +aiodns = ">=3.0.0,<4.0.0" +aiohttp = ">=3.8.0,<4.0.0" +aiomultiprocess = ">=0.9.0,<0.10.0" +argcomplete = ">=3.0.0,<4.0.0" +asteval = "1.0.5" +bc-detect-secrets = "1.5.44" +bc-jsonpath-ng = "1.6.1" +bc-python-hcl2 = "0.4.2" +boto3 = "1.35.49" +cachetools = ">=5.2.0,<6.0.0" +charset-normalizer = ">=3.1.0,<4.0.0" +click = ">=8.1.0,<9.0.0" +cloudsplaining = ">=0.7.0,<0.8.0" +colorama = ">=0.4.3,<0.5.0" +configargparse = ">=1.5.3,<2.0.0" +cyclonedx-python-lib = ">=6.0.0,<8.0.0" +docker = ">=6.0.1,<8.0.0" +dockerfile-parse = ">=2.0.0,<3.0.0" +dpath = "2.1.3" +gitpython = ">=3.1.30,<4.0.0" +importlib-metadata = ">=6.0.0,<8.0.0" +jmespath = ">=1.0.0,<2.0.0" +jsonschema = ">=4.17.0,<5.0.0" +junit-xml = ">=1.9,<2.0" +license-expression = ">=30.1.0,<31.0.0" +networkx = "<2.7" +packageurl-python = ">=0.11.1,<0.14.0" +packaging = ">=23.0,<24.0" +prettytable = ">=3.6.0,<4.0.0" +pycep-parser = "0.5.1" +pydantic = ">=2.0.0,<3.0.0" +pyston = {version = "2.3.5", markers = "python_version < \"3.11\" and (sys_platform == \"linux\" or sys_platform == \"darwin\") and platform_machine == \"x86_64\" and implementation_name == \"cpython\""} +pyston-autoload = {version = "2.3.5", markers = "python_version < \"3.11\" and (sys_platform == \"linux\" or sys_platform == \"darwin\") and platform_machine == \"x86_64\" and implementation_name == \"cpython\""} +pyyaml = ">=6.0.0,<7.0.0" +requests = ">=2.28.0,<3.0.0" +rustworkx = ">=0.13.0,<1.0.0" +schema = "<=0.7.5" +spdx-tools = ">=0.8.0,<0.9.0" +tabulate = ">=0.9.0,<0.10.0" +termcolor = ">=1.1.0,<2.4.0" +tqdm = ">=4.65.0,<5.0.0" +typing-extensions = ">=4.5.0,<5.0.0" +urllib3 = "1.26.20" +yarl = ">=1.9.1,<2.0.0" + +[package.extras] +dev = ["GitPython (==3.1.41)", "bandit", "coverage (==7.6.1)", "coverage-badge", "jsonschema", "pytest (<8.0.0)"] + [[package]] name = "click" version = "8.1.8" @@ -1114,6 +1404,27 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} +[[package]] +name = "click-option-group" +version = "0.5.7" +description = "Option groups missing in Click" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "click_option_group-0.5.7-py3-none-any.whl", hash = "sha256:96b9f52f397ef4d916f81929bd6c1f85e89046c7a401a64e72a61ae74ad35c24"}, + {file = "click_option_group-0.5.7.tar.gz", hash = "sha256:8dc780be038712fc12c9fecb3db4fe49e0d0723f9c171d7cda85c20369be693c"}, +] + +[package.dependencies] +click = ">=7.0" + +[package.extras] +dev = ["pre-commit", "pytest"] +docs = ["m2r2", "pallets-sphinx-themes", "sphinx"] +test = ["pytest"] +test-cov = ["pytest", "pytest-cov"] + [[package]] name = "click-plugins" version = "1.1.1" @@ -1132,6 +1443,30 @@ click = ">=4.0" [package.extras] dev = ["coveralls", "pytest (>=3.6)", "pytest-cov", "wheel"] +[[package]] +name = "cloudsplaining" +version = "0.7.0" +description = "AWS IAM Security Assessment tool that identifies violations of least privilege and generates a risk-prioritized HTML report." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "cloudsplaining-0.7.0-py3-none-any.whl", hash = "sha256:8e93c7b1671c8353f520627cdf7917ec543581c9b9936b3d344817bb4747174e"}, + {file = "cloudsplaining-0.7.0.tar.gz", hash = "sha256:2d8a1d1a3261368a39359bb23aa7d6ac9add274728ff24877b710cdfa96d96af"}, +] + +[package.dependencies] +boto3 = "*" +botocore = "*" +cached-property = "*" +click = "*" +click-option-group = "*" +jinja2 = "*" +markdown = "*" +policy-sentry = ">=0.13.0,<0.14" +pyyaml = "*" +schema = "*" + [[package]] name = "colorama" version = "0.4.6" @@ -1144,6 +1479,34 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "configargparse" +version = "1.7.1" +description = "A drop-in replacement for argparse that allows options to also be set via config files and/or environment variables." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "configargparse-1.7.1-py3-none-any.whl", hash = "sha256:8b586a31f9d873abd1ca527ffbe58863c99f36d896e2829779803125e83be4b6"}, + {file = "configargparse-1.7.1.tar.gz", hash = "sha256:79c2ddae836a1e5914b71d58e4b9adbd9f7779d4e6351a637b7d2d9b6c46d3d9"}, +] + +[package.extras] +test = ["PyYAML", "mock", "pytest"] +yaml = ["PyYAML"] + +[[package]] +name = "contextlib2" +version = "21.6.0" +description = "Backports and enhancements for the contextlib module" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "contextlib2-21.6.0-py2.py3-none-any.whl", hash = "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f"}, + {file = "contextlib2-21.6.0.tar.gz", hash = "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869"}, +] + [[package]] name = "coverage" version = "7.6.12" @@ -1277,6 +1640,29 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi (>=2024)", "cryptography-vectors (==44.0.1)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "cyclonedx-python-lib" +version = "7.6.2" +description = "Python library for CycloneDX" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["main"] +files = [ + {file = "cyclonedx_python_lib-7.6.2-py3-none-any.whl", hash = "sha256:c42fab352cc0f7418d1b30def6751d9067ebcf0e8e4be210fc14d6e742a9edcc"}, + {file = "cyclonedx_python_lib-7.6.2.tar.gz", hash = "sha256:31186c5725ac0cfcca433759a407b1424686cdc867b47cc86e6cf83691310903"}, +] + +[package.dependencies] +license-expression = ">=30,<31" +packageurl-python = ">=0.11,<2" +py-serializable = ">=1.1.0,<2.0.0" +sortedcontainers = ">=2.4.0,<3.0.0" + +[package.extras] +json-validation = ["jsonschema[format] (>=4.18,<5.0)"] +validation = ["jsonschema[format] (>=4.18,<5.0)", "lxml (>=4,<6)"] +xml-validation = ["lxml (>=4,<6)"] + [[package]] name = "dash" version = "2.18.2" @@ -1365,6 +1751,30 @@ files = [ {file = "dash_table-5.0.0.tar.gz", hash = "sha256:18624d693d4c8ef2ddec99a6f167593437a7ea0bf153aa20f318c170c5bc7308"}, ] +[[package]] +name = "decorator" +version = "5.2.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"}, + {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"}, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] +files = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] + [[package]] name = "deprecated" version = "1.2.18" @@ -1419,6 +1829,18 @@ files = [ graph = ["objgraph (>=1.7.2)"] profile = ["gprof2dot (>=2022.7.29)"] +[[package]] +name = "distlib" +version = "0.3.9" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, + {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, +] + [[package]] name = "dnspython" version = "2.7.0" @@ -1446,7 +1868,7 @@ version = "7.1.0" description = "A Python library for the Docker Engine API." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0"}, {file = "docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c"}, @@ -1463,6 +1885,18 @@ docs = ["myst-parser (==0.18.0)", "sphinx (==5.1.1)"] ssh = ["paramiko (>=2.4.3)"] websockets = ["websocket-client (>=1.3.0)"] +[[package]] +name = "dockerfile-parse" +version = "2.0.1" +description = "Python library for Dockerfile manipulation" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc"}, + {file = "dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6"}, +] + [[package]] name = "dparse" version = "0.6.4" @@ -1485,6 +1919,18 @@ conda = ["pyyaml"] pipenv = ["pipenv"] poetry = ["poetry"] +[[package]] +name = "dpath" +version = "2.1.3" +description = "Filesystem-like pathing and searching for dictionaries" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "dpath-2.1.3-py3-none-any.whl", hash = "sha256:d9560e03ccd83b3c6f29988b0162ce9b34fd28b9d8dbda46663b20c68d9cdae3"}, + {file = "dpath-2.1.3.tar.gz", hash = "sha256:d1a7a0e6427d0a4156c792c82caf1f0109603f68ace792e36ca4596fd2cb8d9d"}, +] + [[package]] name = "durationpy" version = "0.9" @@ -1755,7 +2201,7 @@ version = "4.0.12" description = "Git Object Database" optional = false python-versions = ">=3.7" -groups = ["docs"] +groups = ["main", "docs"] files = [ {file = "gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf"}, {file = "gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571"}, @@ -1770,7 +2216,7 @@ version = "3.1.44" description = "GitPython is a Python library used to interact with Git repositories" optional = false python-versions = ">=3.7" -groups = ["docs"] +groups = ["main", "docs"] files = [ {file = "GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110"}, {file = "gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269"}, @@ -2032,6 +2478,33 @@ files = [ {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, ] +[[package]] +name = "iamdata" +version = "0.1.202505091" +description = "IAM data for AWS actions, resources, and conditions based on IAM policy documents. Checked for updates daily." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "iamdata-0.1.202505091-py3-none-any.whl", hash = "sha256:006536cb6ee32c0567e04ba7582586596c67b599ab0ed10166efae4153acf120"}, + {file = "iamdata-0.1.202505091.tar.gz", hash = "sha256:b8538dde82282e89c5ca6e98662008e3c50e21252e3a3a532f0c1b7387488036"}, +] + +[[package]] +name = "identify" +version = "2.6.12" +description = "File identification library for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2"}, + {file = "identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6"}, +] + +[package.extras] +license = ["ukkonen"] + [[package]] name = "idna" version = "3.10" @@ -2049,28 +2522,24 @@ all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2 [[package]] name = "importlib-metadata" -version = "8.6.1" +version = "7.2.1" description = "Read metadata from Python packages" optional = false -python-versions = ">=3.9" +python-versions = ">=3.8" groups = ["main", "dev", "docs"] files = [ - {file = "importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e"}, - {file = "importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580"}, + {file = "importlib_metadata-7.2.1-py3-none-any.whl", hash = "sha256:ffef94b0b66046dd8ea2d619b701fe978d9264d38f3998bc4c27ec3b146a87c8"}, + {file = "importlib_metadata-7.2.1.tar.gz", hash = "sha256:509ecb2ab77071db5137c655e24ceb3eee66e7bbc6574165d0d114d9fc4bbe68"}, ] markers = {dev = "python_version < \"3.10\"", docs = "python_version < \"3.10\""} [package.dependencies] -zipp = ">=3.20" +zipp = ">=0.5" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] -cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] +test = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] [[package]] name = "iniconfig" @@ -2268,6 +2737,21 @@ files = [ [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "junit-xml" +version = "1.9" +description = "Creates JUnit XML test result documents that can be read by tools such as Jenkins" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "junit-xml-1.9.tar.gz", hash = "sha256:de16a051990d4e25a3982b2dd9e89d671067548718866416faec14d9de56db9f"}, + {file = "junit_xml-1.9-py2.py3-none-any.whl", hash = "sha256:ec5ca1a55aefdd76d28fcc0b135251d156c7106fa979686a4b48d62b761b4732"}, +] + +[package.dependencies] +six = "*" + [[package]] name = "kubernetes" version = "32.0.1" @@ -2296,6 +2780,24 @@ 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 = "lark" +version = "1.2.2" +description = "a modern parsing library" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "lark-1.2.2-py3-none-any.whl", hash = "sha256:c2276486b02f0f1b90be155f2c8ba4a8e194d42775786db622faccd652d8e80c"}, + {file = "lark-1.2.2.tar.gz", hash = "sha256:ca807d0162cd16cef15a8feecb862d7319e7a09bdb13aef927968e45040fed80"}, +] + +[package.extras] +atomic-cache = ["atomicwrites"] +interegular = ["interegular (>=0.3.1,<0.4.0)"] +nearley = ["js2py"] +regex = ["regex"] + [[package]] name = "lazy-object-proxy" version = "1.11.0" @@ -2320,13 +2822,32 @@ files = [ {file = "lazy_object_proxy-1.11.0.tar.gz", hash = "sha256:18874411864c9fbbbaa47f9fc1dd7aea754c86cfde21278ef427639d1dd78e9c"}, ] +[[package]] +name = "license-expression" +version = "30.4.1" +description = "license-expression is a comprehensive utility library to parse, compare, simplify and normalize license expressions (such as SPDX license expressions) using boolean logic." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "license_expression-30.4.1-py3-none-any.whl", hash = "sha256:679646bc3261a17690494a3e1cada446e5ee342dbd87dcfa4a0c24cc5dce13ee"}, + {file = "license_expression-30.4.1.tar.gz", hash = "sha256:9f02105f9e0fcecba6a85dfbbed7d94ea1c3a70cf23ddbfb5adf3438a6f6fce0"}, +] + +[package.dependencies] +"boolean.py" = ">=4.0" + +[package.extras] +docs = ["Sphinx (>=5.0.2)", "doc8 (>=0.11.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-reredirects (>=0.1.2)", "sphinx-rtd-dark-mode (>=1.3.0)", "sphinx-rtd-theme (>=1.0.0)", "sphinxcontrib-apidoc (>=0.4.0)"] +testing = ["black", "isort", "pytest (>=6,!=7.0.0)", "pytest-xdist (>=2)", "twine"] + [[package]] name = "markdown" version = "3.8" description = "Python implementation of John Gruber's Markdown." optional = false python-versions = ">=3.9" -groups = ["docs"] +groups = ["main", "docs"] files = [ {file = "markdown-3.8-py3-none-any.whl", hash = "sha256:794a929b79c5af141ef5ab0f2f642d0f7b1872981250230e72682346f7cc90dc"}, {file = "markdown-3.8.tar.gz", hash = "sha256:7df81e63f0df5c4b24b7d156eb81e4690595239b7d70937d0409f1b0de319c6f"}, @@ -3097,45 +3618,35 @@ files = [ [[package]] name = "networkx" -version = "3.2.1" +version = "2.6.3" description = "Python package for creating and manipulating graphs and networks" optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version < \"3.10\"" +python-versions = ">=3.7" +groups = ["main", "dev"] files = [ - {file = "networkx-3.2.1-py3-none-any.whl", hash = "sha256:f18c69adc97877c42332c170849c96cefa91881c99a7cb3e95b7c659ebdc1ec2"}, - {file = "networkx-3.2.1.tar.gz", hash = "sha256:9f1bb5cf3409bf324e0a722c20bdb4c20ee39bf1c30ce8ae499c8502b0b5e0c6"}, + {file = "networkx-2.6.3-py3-none-any.whl", hash = "sha256:80b6b89c77d1dfb64a4c7854981b60aeea6360ac02c6d4e4913319e0a313abef"}, + {file = "networkx-2.6.3.tar.gz", hash = "sha256:c0946ed31d71f1b732b5aaa6da5a0388a345019af232ce2f49c766e2d6795c51"}, ] [package.extras] -default = ["matplotlib (>=3.5)", "numpy (>=1.22)", "pandas (>=1.4)", "scipy (>=1.9,!=1.11.0,!=1.11.1)"] -developer = ["changelist (==0.4)", "mypy (>=1.1)", "pre-commit (>=3.2)", "rtoml"] -doc = ["nb2plots (>=0.7)", "nbconvert (<7.9)", "numpydoc (>=1.6)", "pillow (>=9.4)", "pydata-sphinx-theme (>=0.14)", "sphinx (>=7)", "sphinx-gallery (>=0.14)", "texext (>=0.6.7)"] -extra = ["lxml (>=4.6)", "pydot (>=1.4.2)", "pygraphviz (>=1.11)", "sympy (>=1.10)"] -test = ["pytest (>=7.2)", "pytest-cov (>=4.0)"] +default = ["matplotlib (>=3.3)", "numpy (>=1.19)", "pandas (>=1.1)", "scipy (>=1.5,!=1.6.1)"] +developer = ["black (==21.5b1)", "pre-commit (>=2.12)"] +doc = ["nb2plots (>=0.6)", "numpydoc (>=1.1)", "pillow (>=8.2)", "pydata-sphinx-theme (>=0.6,<1.0)", "sphinx (>=4.0,<5.0)", "sphinx-gallery (>=0.9,<1.0)", "texext (>=0.6.6)"] +extra = ["lxml (>=4.5)", "pydot (>=1.4.1)", "pygraphviz (>=1.7)"] +test = ["codecov (>=2.1)", "pytest (>=6.2)", "pytest-cov (>=2.12)"] [[package]] -name = "networkx" -version = "3.4.2" -description = "Python package for creating and manipulating graphs and networks" +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" optional = false -python-versions = ">=3.10" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev"] -markers = "python_version >= \"3.10\"" files = [ - {file = "networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f"}, - {file = "networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1"}, + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] -[package.extras] -default = ["matplotlib (>=3.7)", "numpy (>=1.24)", "pandas (>=2.0)", "scipy (>=1.10,!=1.11.0,!=1.11.1)"] -developer = ["changelist (==0.5)", "mypy (>=1.1)", "pre-commit (>=3.2)", "rtoml"] -doc = ["intersphinx-registry", "myst-nb (>=1.1)", "numpydoc (>=1.8.0)", "pillow (>=9.4)", "pydata-sphinx-theme (>=0.15)", "sphinx (>=7.3)", "sphinx-gallery (>=0.16)", "texext (>=0.6.7)"] -example = ["cairocffi (>=1.7)", "contextily (>=1.6)", "igraph (>=0.11)", "momepy (>=0.7.2)", "osmnx (>=1.9)", "scikit-learn (>=1.5)", "seaborn (>=0.13)"] -extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.10)"] -test = ["pytest (>=7.2)", "pytest-cov (>=4.0)"] - [[package]] name = "numpy" version = "2.0.2" @@ -3292,16 +3803,116 @@ files = [ deprecated = ">=1.2.6" opentelemetry-api = "1.32.1" +[[package]] +name = "orjson" +version = "3.10.18" +description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "orjson-3.10.18-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a45e5d68066b408e4bc383b6e4ef05e717c65219a9e1390abc6155a520cac402"}, + {file = "orjson-3.10.18-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be3b9b143e8b9db05368b13b04c84d37544ec85bb97237b3a923f076265ec89c"}, + {file = "orjson-3.10.18-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9b0aa09745e2c9b3bf779b096fa71d1cc2d801a604ef6dd79c8b1bfef52b2f92"}, + {file = "orjson-3.10.18-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53a245c104d2792e65c8d225158f2b8262749ffe64bc7755b00024757d957a13"}, + {file = "orjson-3.10.18-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9495ab2611b7f8a0a8a505bcb0f0cbdb5469caafe17b0e404c3c746f9900469"}, + {file = "orjson-3.10.18-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73be1cbcebadeabdbc468f82b087df435843c809cd079a565fb16f0f3b23238f"}, + {file = "orjson-3.10.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe8936ee2679e38903df158037a2f1c108129dee218975122e37847fb1d4ac68"}, + {file = "orjson-3.10.18-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7115fcbc8525c74e4c2b608129bef740198e9a120ae46184dac7683191042056"}, + {file = "orjson-3.10.18-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:771474ad34c66bc4d1c01f645f150048030694ea5b2709b87d3bda273ffe505d"}, + {file = "orjson-3.10.18-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7c14047dbbea52886dd87169f21939af5d55143dad22d10db6a7514f058156a8"}, + {file = "orjson-3.10.18-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:641481b73baec8db14fdf58f8967e52dc8bda1f2aba3aa5f5c1b07ed6df50b7f"}, + {file = "orjson-3.10.18-cp310-cp310-win32.whl", hash = "sha256:607eb3ae0909d47280c1fc657c4284c34b785bae371d007595633f4b1a2bbe06"}, + {file = "orjson-3.10.18-cp310-cp310-win_amd64.whl", hash = "sha256:8770432524ce0eca50b7efc2a9a5f486ee0113a5fbb4231526d414e6254eba92"}, + {file = "orjson-3.10.18-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e0a183ac3b8e40471e8d843105da6fbe7c070faab023be3b08188ee3f85719b8"}, + {file = "orjson-3.10.18-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5ef7c164d9174362f85238d0cd4afdeeb89d9e523e4651add6a5d458d6f7d42d"}, + {file = "orjson-3.10.18-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd14c5d99cdc7bf93f22b12ec3b294931518aa019e2a147e8aa2f31fd3240f7"}, + {file = "orjson-3.10.18-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b672502323b6cd133c4af6b79e3bea36bad2d16bca6c1f645903fce83909a7a"}, + {file = "orjson-3.10.18-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51f8c63be6e070ec894c629186b1c0fe798662b8687f3d9fdfa5e401c6bd7679"}, + {file = "orjson-3.10.18-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9478ade5313d724e0495d167083c6f3be0dd2f1c9c8a38db9a9e912cdaf947"}, + {file = "orjson-3.10.18-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:187aefa562300a9d382b4b4eb9694806e5848b0cedf52037bb5c228c61bb66d4"}, + {file = "orjson-3.10.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da552683bc9da222379c7a01779bddd0ad39dd699dd6300abaf43eadee38334"}, + {file = "orjson-3.10.18-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e450885f7b47a0231979d9c49b567ed1c4e9f69240804621be87c40bc9d3cf17"}, + {file = "orjson-3.10.18-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5e3c9cc2ba324187cd06287ca24f65528f16dfc80add48dc99fa6c836bb3137e"}, + {file = "orjson-3.10.18-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:50ce016233ac4bfd843ac5471e232b865271d7d9d44cf9d33773bcd883ce442b"}, + {file = "orjson-3.10.18-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b3ceff74a8f7ffde0b2785ca749fc4e80e4315c0fd887561144059fb1c138aa7"}, + {file = "orjson-3.10.18-cp311-cp311-win32.whl", hash = "sha256:fdba703c722bd868c04702cac4cb8c6b8ff137af2623bc0ddb3b3e6a2c8996c1"}, + {file = "orjson-3.10.18-cp311-cp311-win_amd64.whl", hash = "sha256:c28082933c71ff4bc6ccc82a454a2bffcef6e1d7379756ca567c772e4fb3278a"}, + {file = "orjson-3.10.18-cp311-cp311-win_arm64.whl", hash = "sha256:a6c7c391beaedd3fa63206e5c2b7b554196f14debf1ec9deb54b5d279b1b46f5"}, + {file = "orjson-3.10.18-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:50c15557afb7f6d63bc6d6348e0337a880a04eaa9cd7c9d569bcb4e760a24753"}, + {file = "orjson-3.10.18-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:356b076f1662c9813d5fa56db7d63ccceef4c271b1fb3dd522aca291375fcf17"}, + {file = "orjson-3.10.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:559eb40a70a7494cd5beab2d73657262a74a2c59aff2068fdba8f0424ec5b39d"}, + {file = "orjson-3.10.18-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3c29eb9a81e2fbc6fd7ddcfba3e101ba92eaff455b8d602bf7511088bbc0eae"}, + {file = "orjson-3.10.18-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6612787e5b0756a171c7d81ba245ef63a3533a637c335aa7fcb8e665f4a0966f"}, + {file = "orjson-3.10.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ac6bd7be0dcab5b702c9d43d25e70eb456dfd2e119d512447468f6405b4a69c"}, + {file = "orjson-3.10.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f72f100cee8dde70100406d5c1abba515a7df926d4ed81e20a9730c062fe9ad"}, + {file = "orjson-3.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dca85398d6d093dd41dc0983cbf54ab8e6afd1c547b6b8a311643917fbf4e0c"}, + {file = "orjson-3.10.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22748de2a07fcc8781a70edb887abf801bb6142e6236123ff93d12d92db3d406"}, + {file = "orjson-3.10.18-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3a83c9954a4107b9acd10291b7f12a6b29e35e8d43a414799906ea10e75438e6"}, + {file = "orjson-3.10.18-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:303565c67a6c7b1f194c94632a4a39918e067bd6176a48bec697393865ce4f06"}, + {file = "orjson-3.10.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:86314fdb5053a2f5a5d881f03fca0219bfdf832912aa88d18676a5175c6916b5"}, + {file = "orjson-3.10.18-cp312-cp312-win32.whl", hash = "sha256:187ec33bbec58c76dbd4066340067d9ece6e10067bb0cc074a21ae3300caa84e"}, + {file = "orjson-3.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:f9f94cf6d3f9cd720d641f8399e390e7411487e493962213390d1ae45c7814fc"}, + {file = "orjson-3.10.18-cp312-cp312-win_arm64.whl", hash = "sha256:3d600be83fe4514944500fa8c2a0a77099025ec6482e8087d7659e891f23058a"}, + {file = "orjson-3.10.18-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:69c34b9441b863175cc6a01f2935de994025e773f814412030f269da4f7be147"}, + {file = "orjson-3.10.18-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:1ebeda919725f9dbdb269f59bc94f861afbe2a27dce5608cdba2d92772364d1c"}, + {file = "orjson-3.10.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5adf5f4eed520a4959d29ea80192fa626ab9a20b2ea13f8f6dc58644f6927103"}, + {file = "orjson-3.10.18-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7592bb48a214e18cd670974f289520f12b7aed1fa0b2e2616b8ed9e069e08595"}, + {file = "orjson-3.10.18-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f872bef9f042734110642b7a11937440797ace8c87527de25e0c53558b579ccc"}, + {file = "orjson-3.10.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0315317601149c244cb3ecef246ef5861a64824ccbcb8018d32c66a60a84ffbc"}, + {file = "orjson-3.10.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0da26957e77e9e55a6c2ce2e7182a36a6f6b180ab7189315cb0995ec362e049"}, + {file = "orjson-3.10.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb70d489bc79b7519e5803e2cc4c72343c9dc1154258adf2f8925d0b60da7c58"}, + {file = "orjson-3.10.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9e86a6af31b92299b00736c89caf63816f70a4001e750bda179e15564d7a034"}, + {file = "orjson-3.10.18-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c382a5c0b5931a5fc5405053d36c1ce3fd561694738626c77ae0b1dfc0242ca1"}, + {file = "orjson-3.10.18-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8e4b2ae732431127171b875cb2668f883e1234711d3c147ffd69fe5be51a8012"}, + {file = "orjson-3.10.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d808e34ddb24fc29a4d4041dcfafbae13e129c93509b847b14432717d94b44f"}, + {file = "orjson-3.10.18-cp313-cp313-win32.whl", hash = "sha256:ad8eacbb5d904d5591f27dee4031e2c1db43d559edb8f91778efd642d70e6bea"}, + {file = "orjson-3.10.18-cp313-cp313-win_amd64.whl", hash = "sha256:aed411bcb68bf62e85588f2a7e03a6082cc42e5a2796e06e72a962d7c6310b52"}, + {file = "orjson-3.10.18-cp313-cp313-win_arm64.whl", hash = "sha256:f54c1385a0e6aba2f15a40d703b858bedad36ded0491e55d35d905b2c34a4cc3"}, + {file = "orjson-3.10.18-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c95fae14225edfd699454e84f61c3dd938df6629a00c6ce15e704f57b58433bb"}, + {file = "orjson-3.10.18-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5232d85f177f98e0cefabb48b5e7f60cff6f3f0365f9c60631fecd73849b2a82"}, + {file = "orjson-3.10.18-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2783e121cafedf0d85c148c248a20470018b4ffd34494a68e125e7d5857655d1"}, + {file = "orjson-3.10.18-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e54ee3722caf3db09c91f442441e78f916046aa58d16b93af8a91500b7bbf273"}, + {file = "orjson-3.10.18-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2daf7e5379b61380808c24f6fc182b7719301739e4271c3ec88f2984a2d61f89"}, + {file = "orjson-3.10.18-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f39b371af3add20b25338f4b29a8d6e79a8c7ed0e9dd49e008228a065d07781"}, + {file = "orjson-3.10.18-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b819ed34c01d88c6bec290e6842966f8e9ff84b7694632e88341363440d4cc0"}, + {file = "orjson-3.10.18-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2f6c57debaef0b1aa13092822cbd3698a1fb0209a9ea013a969f4efa36bdea57"}, + {file = "orjson-3.10.18-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:755b6d61ffdb1ffa1e768330190132e21343757c9aa2308c67257cc81a1a6f5a"}, + {file = "orjson-3.10.18-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ce8d0a875a85b4c8579eab5ac535fb4b2a50937267482be402627ca7e7570ee3"}, + {file = "orjson-3.10.18-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57b5d0673cbd26781bebc2bf86f99dd19bd5a9cb55f71cc4f66419f6b50f3d77"}, + {file = "orjson-3.10.18-cp39-cp39-win32.whl", hash = "sha256:951775d8b49d1d16ca8818b1f20c4965cae9157e7b562a2ae34d3967b8f21c8e"}, + {file = "orjson-3.10.18-cp39-cp39-win_amd64.whl", hash = "sha256:fdd9d68f83f0bc4406610b1ac68bdcded8c5ee58605cc69e643a06f4d075f429"}, + {file = "orjson-3.10.18.tar.gz", hash = "sha256:e8da3947d92123eda795b68228cafe2724815621fe35e8e320a9e9593a4bcd53"}, +] + +[[package]] +name = "packageurl-python" +version = "0.13.4" +description = "A purl aka. Package URL parser and builder" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "packageurl-python-0.13.4.tar.gz", hash = "sha256:6eb5e995009cc73387095e0b507ab65df51357d25ddc5fce3d3545ad6dcbbee8"}, + {file = "packageurl_python-0.13.4-py3-none-any.whl", hash = "sha256:62aa13d60a0082ff115784fefdfe73a12f310e455365cca7c6d362161067f35f"}, +] + +[package.extras] +build = ["setuptools", "wheel"] +lint = ["black", "isort", "mypy"] +sqlalchemy = ["sqlalchemy (>=2.0.0)"] +test = ["pytest"] + [[package]] name = "packaging" -version = "25.0" +version = "23.2" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" groups = ["main", "dev", "docs"] files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, ] [[package]] @@ -3604,12 +4215,69 @@ version = "3.11" description = "Python Lex & Yacc" optional = false python-versions = "*" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce"}, {file = "ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3"}, ] +[[package]] +name = "policy-sentry" +version = "0.13.2" +description = "Generate locked-down AWS IAM Policies" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "policy_sentry-0.13.2-py3-none-any.whl", hash = "sha256:e82c3bc1783606449399c4221f67d05f6b08d8a184ba2fee87d04541d7282b86"}, + {file = "policy_sentry-0.13.2.tar.gz", hash = "sha256:db2b39f92989077f83fc4dd1d064e3ff20b69cfed82168ebdc060e7dce292e77"}, +] + +[package.dependencies] +beautifulsoup4 = "*" +click = "*" +orjson = "*" +PyYAML = "*" +requests = "*" +schema = "*" + +[[package]] +name = "pre-commit" +version = "4.2.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd"}, + {file = "pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "prettytable" +version = "3.16.0" +description = "A simple Python library for easily displaying tabular data in a visually appealing ASCII table format" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "prettytable-3.16.0-py3-none-any.whl", hash = "sha256:b5eccfabb82222f5aa46b798ff02a8452cf530a352c31bddfa29be41242863aa"}, + {file = "prettytable-3.16.0.tar.gz", hash = "sha256:3c64b31719d961bf69c9a7e03d0c1e477320906a98da63952bc6698d6164ff57"}, +] + +[package.dependencies] +wcwidth = "*" + +[package.extras] +tests = ["pytest", "pytest-cov", "pytest-lazy-fixtures"] + [[package]] name = "propcache" version = "0.3.1" @@ -3738,21 +4406,21 @@ testing = ["google-api-core (>=1.31.5)"] [[package]] name = "protobuf" -version = "6.30.2" +version = "6.31.1" description = "" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "protobuf-6.30.2-cp310-abi3-win32.whl", hash = "sha256:b12ef7df7b9329886e66404bef5e9ce6a26b54069d7f7436a0853ccdeb91c103"}, - {file = "protobuf-6.30.2-cp310-abi3-win_amd64.whl", hash = "sha256:7653c99774f73fe6b9301b87da52af0e69783a2e371e8b599b3e9cb4da4b12b9"}, - {file = "protobuf-6.30.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:0eb523c550a66a09a0c20f86dd554afbf4d32b02af34ae53d93268c1f73bc65b"}, - {file = "protobuf-6.30.2-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:50f32cc9fd9cb09c783ebc275611b4f19dfdfb68d1ee55d2f0c7fa040df96815"}, - {file = "protobuf-6.30.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4f6c687ae8efae6cf6093389a596548214467778146b7245e886f35e1485315d"}, - {file = "protobuf-6.30.2-cp39-cp39-win32.whl", hash = "sha256:524afedc03b31b15586ca7f64d877a98b184f007180ce25183d1a5cb230ee72b"}, - {file = "protobuf-6.30.2-cp39-cp39-win_amd64.whl", hash = "sha256:acec579c39c88bd8fbbacab1b8052c793efe83a0a5bd99db4a31423a25c0a0e2"}, - {file = "protobuf-6.30.2-py3-none-any.whl", hash = "sha256:ae86b030e69a98e08c77beab574cbcb9fff6d031d57209f574a5aea1445f4b51"}, - {file = "protobuf-6.30.2.tar.gz", hash = "sha256:35c859ae076d8c56054c25b59e5e59638d86545ed6e2b6efac6be0b6ea3ba048"}, + {file = "protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9"}, + {file = "protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447"}, + {file = "protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402"}, + {file = "protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39"}, + {file = "protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6"}, + {file = "protobuf-6.31.1-cp39-cp39-win32.whl", hash = "sha256:0414e3aa5a5f3ff423828e1e6a6e907d6c65c1d5b7e6e975793d5590bdeecc16"}, + {file = "protobuf-6.31.1-cp39-cp39-win_amd64.whl", hash = "sha256:8764cf4587791e7564051b35524b72844f845ad0bb011704c3736cce762d8fe9"}, + {file = "protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e"}, + {file = "protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a"}, ] [[package]] @@ -3786,21 +4454,36 @@ files = [ test = ["enum34 ; python_version <= \"3.4\"", "ipaddress ; python_version < \"3.0\"", "mock ; python_version < \"3.0\"", "pywin32 ; sys_platform == \"win32\"", "wmi ; sys_platform == \"win32\""] [[package]] -name = "py-ocsf-models" -version = "0.3.1" -description = "This is a Python implementation of the OCSF models. The models are used to represent the data of the OCSF Schema defined in https://schema.ocsf.io/." +name = "py-iam-expand" +version = "0.1.0" +description = "This is a Python package to expand and deobfuscate IAM policies." optional = false -python-versions = "<3.13,>3.9.1" +python-versions = "<3.14,>3.9.1" groups = ["main"] files = [ - {file = "py_ocsf_models-0.3.1-py3-none-any.whl", hash = "sha256:e722d567a7f3e5190fdd053c2e75a69cf33fab6f5c0a4b7de678768ba340ae3a"}, - {file = "py_ocsf_models-0.3.1.tar.gz", hash = "sha256:60defd2cc86e8882f42dc9c6dacca6dc16d6bc05f9477c2a3486a0d4b5882b94"}, + {file = "py_iam_expand-0.1.0-py3-none-any.whl", hash = "sha256:b845ce7b50ac895b02b4f338e09c62a68ea51849794f76e189b02009bd388510"}, + {file = "py_iam_expand-0.1.0.tar.gz", hash = "sha256:5a2884dc267ac59a02c3a80fefc0b34c309dac681baa0f87c436067c6cf53a96"}, +] + +[package.dependencies] +iamdata = ">=0.1.202504091" + +[[package]] +name = "py-ocsf-models" +version = "0.5.0" +description = "This is a Python implementation of the OCSF models. The models are used to represent the data of the OCSF Schema defined in https://schema.ocsf.io/." +optional = false +python-versions = "<3.14,>3.9.1" +groups = ["main"] +files = [ + {file = "py_ocsf_models-0.5.0-py3-none-any.whl", hash = "sha256:7933253f56782c04c412d976796db429577810b951fe4195351794500b5962d8"}, + {file = "py_ocsf_models-0.5.0.tar.gz", hash = "sha256:bf05e955809d1ec3ab1007e4a4b2a8a0afa74b6e744ea8ffbf386e46b3af0a76"}, ] [package.dependencies] cryptography = "44.0.1" email-validator = "2.2.0" -pydantic = "1.10.21" +pydantic = ">=2.9.2,<3.0.0" [[package]] name = "py-partiql-parser" @@ -3817,6 +4500,21 @@ files = [ [package.extras] dev = ["black (==22.6.0)", "flake8", "mypy", "pytest"] +[[package]] +name = "py-serializable" +version = "1.1.2" +description = "Library for serializing and deserializing Python Objects to and from JSON and XML." +optional = false +python-versions = "<4.0,>=3.8" +groups = ["main"] +files = [ + {file = "py_serializable-1.1.2-py3-none-any.whl", hash = "sha256:801be61b0a1ba64c3861f7c624f1de5cfbbabf8b458acc9cdda91e8f7e5effa1"}, + {file = "py_serializable-1.1.2.tar.gz", hash = "sha256:89af30bc319047d4aa0d8708af412f6ce73835e18bacf1a080028bb9e2f42bdb"}, +] + +[package.dependencies] +defusedxml = ">=0.7.1,<0.8.0" + [[package]] name = "pyasn1" version = "0.6.1" @@ -3844,6 +4542,115 @@ files = [ [package.dependencies] pyasn1 = ">=0.6.1,<0.7.0" +[[package]] +name = "pycares" +version = "4.9.0" +description = "Python interface for c-ares" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pycares-4.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b8bd9a3ee6e9bc990e1933dc7e7e2f44d4184f49a90fa444297ac12ab6c0c84"}, + {file = "pycares-4.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:417a5c20861f35977240ad4961479a6778125bcac21eb2ad1c3aad47e2ff7fab"}, + {file = "pycares-4.9.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab290faa4ea53ce53e3ceea1b3a42822daffce2d260005533293a52525076750"}, + {file = "pycares-4.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b1df81193084c9717734e4615e8c5074b9852478c9007d1a8bb242f7f580e67"}, + {file = "pycares-4.9.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20c7a6af0c2ccd17cc5a70d76e299a90e7ebd6c4d8a3d7fff5ae533339f61431"}, + {file = "pycares-4.9.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:370f41442a5b034aebdb2719b04ee04d3e805454a20d3f64f688c1c49f9137c3"}, + {file = "pycares-4.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:340e4a3bbfd14d73c01ec0793a321b8a4a93f64c508225883291078b7ee17ac8"}, + {file = "pycares-4.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f0ec94785856ea4f5556aa18f4c027361ba4b26cb36c4ad97d2105ef4eec68ba"}, + {file = "pycares-4.9.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd6b7e23a4a9e2039b5d67dfa0499d2d5f114667dc13fb5d7d03eed230c7ac4f"}, + {file = "pycares-4.9.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:490c978b0be9d35a253a5e31dd598f6d66b453625f0eb7dc2d81b22b8c3bb3f4"}, + {file = "pycares-4.9.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e433faaf07f44e44f1a1b839fee847480fe3db9431509dafc9f16d618d491d0f"}, + {file = "pycares-4.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cf6d8851a06b79d10089962c9dadcb34dad00bf027af000f7102297a54aaff2e"}, + {file = "pycares-4.9.0-cp310-cp310-win32.whl", hash = "sha256:4f803e7d66ac7d8342998b8b07393788991353a46b05bbaad0b253d6f3484ea8"}, + {file = "pycares-4.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e17bd32267e3870855de3baed7d0efa6337344d68f44853fd9195c919f39400"}, + {file = "pycares-4.9.0-cp310-cp310-win_arm64.whl", hash = "sha256:6b74f75d8e430f9bb11a1cc99b2e328eed74b17d8d4b476de09126f38d419eb9"}, + {file = "pycares-4.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:16a97ee83ec60d35c7f716f117719932c27d428b1bb56b242ba1c4aa55521747"}, + {file = "pycares-4.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:78748521423a211ce699a50c27cc5c19e98b7db610ccea98daad652ace373990"}, + {file = "pycares-4.9.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8818b2c7a57d9d6d41e8b64d9ff87992b8ea2522fc0799686725228bc3cff6c5"}, + {file = "pycares-4.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96df8990f16013ca5194d6ece19dddb4ef9cd7c3efaab9f196ec3ccd44b40f8d"}, + {file = "pycares-4.9.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:61af86fd58b8326e723b0d20fb96b56acaec2261c3a7c9a1c29d0a79659d613a"}, + {file = "pycares-4.9.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec72edb276bda559813cc807bc47b423d409ffab2402417a5381077e9c2c6be1"}, + {file = "pycares-4.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832fb122c7376c76cab62f8862fa5e398b9575fb7c9ff6bc9811086441ee64ca"}, + {file = "pycares-4.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdcfaef24f771a471671470ccfd676c0366ab6b0616fd8217b8f356c40a02b83"}, + {file = "pycares-4.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:52cb056d06ff55d78a8665b97ae948abaaba2ca200ca59b10346d4526bce1e7d"}, + {file = "pycares-4.9.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:54985ed3f2e8a87315269f24cb73441622857a7830adfc3a27c675a94c3261c1"}, + {file = "pycares-4.9.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:08048e223615d4aef3dac81fe0ea18fb18d6fc97881f1eb5be95bb1379969b8d"}, + {file = "pycares-4.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cc60037421ce05a409484287b2cd428e1363cca73c999b5f119936bb8f255208"}, + {file = "pycares-4.9.0-cp311-cp311-win32.whl", hash = "sha256:62b86895b60cfb91befb3086caa0792b53f949231c6c0c3053c7dfee3f1386ab"}, + {file = "pycares-4.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:7046b3c80954beaabf2db52b09c3d6fe85f6c4646af973e61be79d1c51589932"}, + {file = "pycares-4.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:fcbda3fdf44e94d3962ca74e6ba3dc18c0d7029106f030d61c04c0876f319403"}, + {file = "pycares-4.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d68ca2da1001aeccdc81c4a2fb1f1f6cfdafd3d00e44e7c1ed93e3e05437f666"}, + {file = "pycares-4.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4f0c8fa5a384d79551a27eafa39eed29529e66ba8fa795ee432ab88d050432a3"}, + {file = "pycares-4.9.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0eb8c428cf3b9c6ff9c641ba50ab6357b4480cd737498733e6169b0ac8a1a89b"}, + {file = "pycares-4.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6845bd4a43abf6dab7fedbf024ef458ac3750a25b25076ea9913e5ac5fec4548"}, + {file = "pycares-4.9.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e28f4acc3b97e46610cf164665ebf914f709daea6ced0ca4358ce55bc1c3d6b"}, + {file = "pycares-4.9.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9464a39861840ce35a79352c34d653a9db44f9333af7c9feddb97998d3e00c07"}, + {file = "pycares-4.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0611c1bd46d1fc6bdd9305b8850eb84c77df485769f72c574ed7b8389dfbee2"}, + {file = "pycares-4.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d4fb5a38a51d03b75ac4320357e632c2e72e03fdeb13263ee333a40621415fdc"}, + {file = "pycares-4.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:df5edae05fb3e1370ab7639e67e8891fdaa9026cb10f05dbd57893713f7a9cfe"}, + {file = "pycares-4.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:397123ea53d261007bb0aa7e767ef238778f45026db40bed8196436da2cc73de"}, + {file = "pycares-4.9.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bb0d874d0b131b29894fd8a0f842be91ac21d50f90ec04cff4bb3f598464b523"}, + {file = "pycares-4.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:497cc03a61ec1585eb17d2cb086a29a6a67d24babf1e9be519b47222916a3b06"}, + {file = "pycares-4.9.0-cp312-cp312-win32.whl", hash = "sha256:b46e46313fdb5e82da15478652aac0fd15e1c9f33e08153bad845aa4007d6f84"}, + {file = "pycares-4.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:12547a06445777091605a7581da15a0da158058beb8a05a3ebbf7301fd1f58d4"}, + {file = "pycares-4.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:f1e10bf1e8eb80b08e5c828627dba1ebc4acd54803bd0a27d92b9063b6aa99d8"}, + {file = "pycares-4.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:574d815112a95ab09d75d0a9dc7dea737c06985e3125cf31c32ba6a3ed6ca006"}, + {file = "pycares-4.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50e5ab06361d59625a27a7ad93d27e067dc7c9f6aa529a07d691eb17f3b43605"}, + {file = "pycares-4.9.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:785f5fd11ff40237d9bc8afa441551bb449e2812c74334d1d10859569e07515c"}, + {file = "pycares-4.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e194a500e403eba89b91fb863c917495c5b3dfcd1ce0ee8dc3a6f99a1360e2fc"}, + {file = "pycares-4.9.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:112dd49cdec4e6150a8d95b197e8b6b7b4468a3170b30738ed9b248cb2240c04"}, + {file = "pycares-4.9.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94aa3c2f3eb0aa69160137134775501f06c901188e722aac63d2a210d4084f99"}, + {file = "pycares-4.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b510d71255cf5a92ccc2643a553548fcb0623d6ed11c8c633b421d99d7fa4167"}, + {file = "pycares-4.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5c6aa30b1492b8130f7832bf95178642c710ce6b7ba610c2b17377f77177e3cd"}, + {file = "pycares-4.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e5767988e044faffe2aff6a76aa08df99a8b6ef2641be8b00ea16334ce5dea93"}, + {file = "pycares-4.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b9928a942820a82daa3207509eaba9e0fa9660756ac56667ec2e062815331fcb"}, + {file = "pycares-4.9.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:556c854174da76d544714cdfab10745ed5d4b99eec5899f7b13988cd26ff4763"}, + {file = "pycares-4.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d42e2202ca9aa9a0a9a6e43a4a4408bbe0311aaa44800fa27b8fd7f82b20152a"}, + {file = "pycares-4.9.0-cp313-cp313-win32.whl", hash = "sha256:cce8ef72c9ed4982c84114e6148a4e42e989d745de7862a0ad8b3f1cdc05def2"}, + {file = "pycares-4.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:318cdf24f826f1d2f0c5a988730bd597e1683296628c8f1be1a5b96643c284fe"}, + {file = "pycares-4.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:faa9de8e647ed06757a2c117b70a7645a755561def814da6aca0d766cf71a402"}, + {file = "pycares-4.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8310d27d68fa25be9781ce04d330f4860634a2ac34dd9265774b5f404679b41f"}, + {file = "pycares-4.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:99cf98452d3285307eec123049f2c9c50b109e06751b0727c6acefb6da30c6a0"}, + {file = "pycares-4.9.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ffd6e8c8250655504602b076f106653e085e6b1e15318013442558101aa4777"}, + {file = "pycares-4.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4065858d8c812159c9a55601fda73760d9e5e3300f7868d9e546eab1084f36c"}, + {file = "pycares-4.9.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91ee6818113faf9013945c2b54bcd6b123d0ac192ae3099cf4288cedaf2dbb25"}, + {file = "pycares-4.9.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:21f0602059ec11857ab7ad608c7ec8bc6f7a302c04559ec06d33e82f040585f8"}, + {file = "pycares-4.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e22e5b46ed9b12183091da56e4a5a20813b5436c4f13135d7a1c20a84027ca8a"}, + {file = "pycares-4.9.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9eded8649867bfd7aea7589c5755eae4d37686272f6ed7a995da40890d02de71"}, + {file = "pycares-4.9.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f71d31cbbe066657a2536c98aad850724a9ab7b1cd2624f491832ae9667ea8e7"}, + {file = "pycares-4.9.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2b30945982ab4741f097efc5b0853051afc3c11df26996ed53a700c7575175af"}, + {file = "pycares-4.9.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:54a8f1f067d64810426491d33033f5353b54f35e5339126440ad4e6afbf3f149"}, + {file = "pycares-4.9.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:41556a269a192349e92eee953f62eddd867e9eddb27f444b261e2c1c4a4a9eff"}, + {file = "pycares-4.9.0-cp39-cp39-win32.whl", hash = "sha256:524d6c14eaa167ed098a4fe54856d1248fa20c296cdd6976f9c1b838ba32d014"}, + {file = "pycares-4.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:15f930c733d36aa487b4ad60413013bd811281b5ea4ca620070fa38505d84df4"}, + {file = "pycares-4.9.0-cp39-cp39-win_arm64.whl", hash = "sha256:79b7addb2a41267d46650ac0d9c4f3b3233b036f186b85606f7586881dfb4b69"}, + {file = "pycares-4.9.0.tar.gz", hash = "sha256:8ee484ddb23dbec4d88d14ed5b6d592c1960d2e93c385d5e52b6fad564d82395"}, +] + +[package.dependencies] +cffi = ">=1.5.0" + +[package.extras] +idna = ["idna (>=2.1)"] + +[[package]] +name = "pycep-parser" +version = "0.5.1" +description = "A Python based Bicep parser" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["main"] +files = [ + {file = "pycep_parser-0.5.1-py3-none-any.whl", hash = "sha256:8c3f99c0dc1301193b1bcbe0a44c6b2763f6d2daf24964ca48dcdfbb73087fa0"}, + {file = "pycep_parser-0.5.1.tar.gz", hash = "sha256:683bb001077c09f98408285b1b6ba10cfb3941610966c45d0638a0e1a5e1d2a4"}, +] + +[package.dependencies] +lark = ">=1.1.2" +regex = ">=2022.1.18" +typing-extensions = ">=3.10.0" + [[package]] name = "pycodestyle" version = "2.12.1" @@ -3871,70 +4678,137 @@ markers = {dev = "platform_python_implementation != \"PyPy\""} [[package]] name = "pydantic" -version = "1.10.21" -description = "Data validation and settings management using python type hints" +version = "2.11.7" +description = "Data validation using Python type hints" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "pydantic-1.10.21-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:245e486e0fec53ec2366df9cf1cba36e0bbf066af7cd9c974bbbd9ba10e1e586"}, - {file = "pydantic-1.10.21-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6c54f8d4c151c1de784c5b93dfbb872067e3414619e10e21e695f7bb84d1d1fd"}, - {file = "pydantic-1.10.21-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b64708009cfabd9c2211295144ff455ec7ceb4c4fb45a07a804309598f36187"}, - {file = "pydantic-1.10.21-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a148410fa0e971ba333358d11a6dea7b48e063de127c2b09ece9d1c1137dde4"}, - {file = "pydantic-1.10.21-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:36ceadef055af06e7756eb4b871cdc9e5a27bdc06a45c820cd94b443de019bbf"}, - {file = "pydantic-1.10.21-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c0501e1d12df6ab1211b8cad52d2f7b2cd81f8e8e776d39aa5e71e2998d0379f"}, - {file = "pydantic-1.10.21-cp310-cp310-win_amd64.whl", hash = "sha256:c261127c275d7bce50b26b26c7d8427dcb5c4803e840e913f8d9df3f99dca55f"}, - {file = "pydantic-1.10.21-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8b6350b68566bb6b164fb06a3772e878887f3c857c46c0c534788081cb48adf4"}, - {file = "pydantic-1.10.21-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:935b19fdcde236f4fbf691959fa5c3e2b6951fff132964e869e57c70f2ad1ba3"}, - {file = "pydantic-1.10.21-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b6a04efdcd25486b27f24c1648d5adc1633ad8b4506d0e96e5367f075ed2e0b"}, - {file = "pydantic-1.10.21-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1ba253eb5af8d89864073e6ce8e6c8dec5f49920cff61f38f5c3383e38b1c9f"}, - {file = "pydantic-1.10.21-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:57f0101e6c97b411f287a0b7cf5ebc4e5d3b18254bf926f45a11615d29475793"}, - {file = "pydantic-1.10.21-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e85834f0370d737c77a386ce505c21b06bfe7086c1c568b70e15a568d9670d"}, - {file = "pydantic-1.10.21-cp311-cp311-win_amd64.whl", hash = "sha256:6a497bc66b3374b7d105763d1d3de76d949287bf28969bff4656206ab8a53aa9"}, - {file = "pydantic-1.10.21-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ed4a5f13cf160d64aa331ab9017af81f3481cd9fd0e49f1d707b57fe1b9f3ae"}, - {file = "pydantic-1.10.21-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3b7693bb6ed3fbe250e222f9415abb73111bb09b73ab90d2d4d53f6390e0ccc1"}, - {file = "pydantic-1.10.21-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:185d5f1dff1fead51766da9b2de4f3dc3b8fca39e59383c273f34a6ae254e3e2"}, - {file = "pydantic-1.10.21-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38e6d35cf7cd1727822c79e324fa0677e1a08c88a34f56695101f5ad4d5e20e5"}, - {file = "pydantic-1.10.21-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1d7c332685eafacb64a1a7645b409a166eb7537f23142d26895746f628a3149b"}, - {file = "pydantic-1.10.21-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c9b782db6f993a36092480eeaab8ba0609f786041b01f39c7c52252bda6d85f"}, - {file = "pydantic-1.10.21-cp312-cp312-win_amd64.whl", hash = "sha256:7ce64d23d4e71d9698492479505674c5c5b92cda02b07c91dfc13633b2eef805"}, - {file = "pydantic-1.10.21-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0067935d35044950be781933ab91b9a708eaff124bf860fa2f70aeb1c4be7212"}, - {file = "pydantic-1.10.21-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5e8148c2ce4894ce7e5a4925d9d3fdce429fb0e821b5a8783573f3611933a251"}, - {file = "pydantic-1.10.21-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4973232c98b9b44c78b1233693e5e1938add5af18042f031737e1214455f9b8"}, - {file = "pydantic-1.10.21-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:662bf5ce3c9b1cef32a32a2f4debe00d2f4839fefbebe1d6956e681122a9c839"}, - {file = "pydantic-1.10.21-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98737c3ab5a2f8a85f2326eebcd214510f898881a290a7939a45ec294743c875"}, - {file = "pydantic-1.10.21-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0bb58bbe65a43483d49f66b6c8474424d551a3fbe8a7796c42da314bac712738"}, - {file = "pydantic-1.10.21-cp313-cp313-win_amd64.whl", hash = "sha256:e622314542fb48542c09c7bd1ac51d71c5632dd3c92dc82ede6da233f55f4848"}, - {file = "pydantic-1.10.21-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d356aa5b18ef5a24d8081f5c5beb67c0a2a6ff2a953ee38d65a2aa96526b274f"}, - {file = "pydantic-1.10.21-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08caa8c0468172d27c669abfe9e7d96a8b1655ec0833753e117061febaaadef5"}, - {file = "pydantic-1.10.21-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c677aa39ec737fec932feb68e4a2abe142682f2885558402602cd9746a1c92e8"}, - {file = "pydantic-1.10.21-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:79577cc045d3442c4e845df53df9f9202546e2ba54954c057d253fc17cd16cb1"}, - {file = "pydantic-1.10.21-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:b6b73ab347284719f818acb14f7cd80696c6fdf1bd34feee1955d7a72d2e64ce"}, - {file = "pydantic-1.10.21-cp37-cp37m-win_amd64.whl", hash = "sha256:46cffa24891b06269e12f7e1ec50b73f0c9ab4ce71c2caa4ccf1fb36845e1ff7"}, - {file = "pydantic-1.10.21-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:298d6f765e3c9825dfa78f24c1efd29af91c3ab1b763e1fd26ae4d9e1749e5c8"}, - {file = "pydantic-1.10.21-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f2f4a2305f15eff68f874766d982114ac89468f1c2c0b97640e719cf1a078374"}, - {file = "pydantic-1.10.21-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35b263b60c519354afb3a60107d20470dd5250b3ce54c08753f6975c406d949b"}, - {file = "pydantic-1.10.21-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e23a97a6c2f2db88995496db9387cd1727acdacc85835ba8619dce826c0b11a6"}, - {file = "pydantic-1.10.21-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:3c96fed246ccc1acb2df032ff642459e4ae18b315ecbab4d95c95cfa292e8517"}, - {file = "pydantic-1.10.21-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b92893ebefc0151474f682e7debb6ab38552ce56a90e39a8834734c81f37c8a9"}, - {file = "pydantic-1.10.21-cp38-cp38-win_amd64.whl", hash = "sha256:b8460bc256bf0de821839aea6794bb38a4c0fbd48f949ea51093f6edce0be459"}, - {file = "pydantic-1.10.21-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5d387940f0f1a0adb3c44481aa379122d06df8486cc8f652a7b3b0caf08435f7"}, - {file = "pydantic-1.10.21-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:266ecfc384861d7b0b9c214788ddff75a2ea123aa756bcca6b2a1175edeca0fe"}, - {file = "pydantic-1.10.21-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61da798c05a06a362a2f8c5e3ff0341743e2818d0f530eaac0d6898f1b187f1f"}, - {file = "pydantic-1.10.21-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a621742da75ce272d64ea57bd7651ee2a115fa67c0f11d66d9dcfc18c2f1b106"}, - {file = "pydantic-1.10.21-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9e3e4000cd54ef455694b8be9111ea20f66a686fc155feda1ecacf2322b115da"}, - {file = "pydantic-1.10.21-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f198c8206640f4c0ef5a76b779241efb1380a300d88b1bce9bfe95a6362e674d"}, - {file = "pydantic-1.10.21-cp39-cp39-win_amd64.whl", hash = "sha256:e7f0cda108b36a30c8fc882e4fc5b7eec8ef584aa43aa43694c6a7b274fb2b56"}, - {file = "pydantic-1.10.21-py3-none-any.whl", hash = "sha256:db70c920cba9d05c69ad4a9e7f8e9e83011abb2c6490e561de9ae24aee44925c"}, - {file = "pydantic-1.10.21.tar.gz", hash = "sha256:64b48e2b609a6c22178a56c408ee1215a7206077ecb8a193e2fda31858b2362a"}, + {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, + {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, ] [package.dependencies] -typing-extensions = ">=4.2.0" +annotated-types = ">=0.6.0" +pydantic-core = "2.33.2" +typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" [package.extras] -dotenv = ["python-dotenv (>=0.10.4)"] -email = ["email-validator (>=1.0.3)"] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pyflakes" @@ -4096,6 +4970,61 @@ files = [ [package.extras] diagrams = ["jinja2", "railroad-diagrams"] +[[package]] +name = "pyston" +version = "2.3.5" +description = "A JIT for Python" +optional = false +python-versions = "*" +groups = ["main"] +markers = "python_version <= \"3.10\" and (sys_platform == \"linux\" or sys_platform == \"darwin\") and platform_machine == \"x86_64\" and implementation_name == \"cpython\"" +files = [ + {file = "pyston-2.3.5-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:deb9dac7f8f67d1b2dc709300e1d8fda51bbc1375957509f58e1dc4459324ea7"}, + {file = "pyston-2.3.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d33602480ff742a45e21413377123bcead27c5ea11b06efaf0260ccb60633da3"}, + {file = "pyston-2.3.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b93ddaed1e62b8bd261e531f3356590014822f7451619000c6b9efe699dd148f"}, + {file = "pyston-2.3.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30caaee3b58d92817efa2cd4f32c24289dd5f4ddf9b5b4ec5b62ed564230ca8a"}, + {file = "pyston-2.3.5-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:5ea981d286de250467e48fd8d80d461acd1e4f27cd10775478206b273045c58d"}, + {file = "pyston-2.3.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44211f95ba99f4d6bd5fc5e27aba834644ba0277554fc52f9a98672720c3ff17"}, + {file = "pyston-2.3.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3805cac00fde2791408d09a988b32911009dcd86a8215a17d9a85dd83fe1c662"}, + {file = "pyston-2.3.5-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:cab90f5bf4646c6de85c25763762dcfa94d209de955173b3f83e3c108d39028f"}, + {file = "pyston-2.3.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c2bddc7ea4755476ec9b75af94a63346ff6d2f34225dff73bb48c8a9f38795da"}, + {file = "pyston-2.3.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf36e6bdb84417b3291052e9156e8dfb03c3ec4879973bf7a47253fef24506d7"}, + {file = "pyston-2.3.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:539ad38ecd392cf60586122db2af45c22fc01fd83dc466ef05e3de7cfb79adb2"}, + {file = "pyston-2.3.5-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:5872e66a4583d56d9555caf6fa1959a33437593a75bf22c982e535d9e742dd38"}, + {file = "pyston-2.3.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a64edbcbf9494ee0e0c230544929d274a2705798abf0e298d82317dbfa5449e9"}, + {file = "pyston-2.3.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb1f88d0594edc40b7d7fc4880e0aef33a69c97b4af0b14c91e8b5f4847ac618"}, + {file = "pyston-2.3.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f983b89f0f79ee527f2cadf167bc8c72b3e1c40574f71f12717a4cb13c75ed47"}, +] + +[[package]] +name = "pyston-autoload" +version = "2.3.5" +description = "Automatically loads and enables pyston" +optional = false +python-versions = "*" +groups = ["main"] +markers = "python_version <= \"3.10\" and (sys_platform == \"linux\" or sys_platform == \"darwin\") and platform_machine == \"x86_64\" and implementation_name == \"cpython\"" +files = [ + {file = "pyston_autoload-2.3.5-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:50c5d2e2855a542f9e427601ed1cc94aa1ff82937c001e5765f4db67af8a309a"}, + {file = "pyston_autoload-2.3.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2efd392dffb78f782ccb40775086f7c53c7ad85fb469f6dddfc29141da37df26"}, + {file = "pyston_autoload-2.3.5-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:d4c9f5624087b66713e5e674e055a77492ac1c19c40dfc306053c5eb7d970a98"}, + {file = "pyston_autoload-2.3.5-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:14d09effb82c436b2b090cf3293e8279e14af065ed5383ab06f72d5481f89cfd"}, + {file = "pyston_autoload-2.3.5-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:fbedb3013c93d52959c543bc9493572401d8f8e928bc265af6fdf6a2fb0258e8"}, + {file = "pyston_autoload-2.3.5-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:4321d6d12ac04613d8f3cac4ff2c07d01a2c6736dc0ecf587808c75f755173df"}, + {file = "pyston_autoload-2.3.5-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:adf3fa86cfaf9968df7c22260bf63f27139a268dfa5fa01d23a0ae3e5ede92db"}, + {file = "pyston_autoload-2.3.5-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:940734109bd38beca8b0211c540492b2888bada4e332415906ba768002cb45b1"}, + {file = "pyston_autoload-2.3.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:26e7769005b4b33b48e51d822a3e3f81e5c2ce530634c0e614e7a939016a2171"}, + {file = "pyston_autoload-2.3.5-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:d9eed4629ae2c798dff581b30a5044e369d2a0d7a8d19754dc95f48cd532fb97"}, + {file = "pyston_autoload-2.3.5-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:9ce3e3bdfbbb7b5900600cc6d1dcd68dfca145a61d10250263e1a4537ab4ff58"}, + {file = "pyston_autoload-2.3.5-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:4fd03b7cd2b439edda14ce7dd6e97158d0f75ff21f270a0c3792cd8341dcf0fa"}, + {file = "pyston_autoload-2.3.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cd6b85b50d3a86caec0db5382550abefe94fd1341dea8014cc7ba6d69daf6c86"}, + {file = "pyston_autoload-2.3.5-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f2c191a1cbcee2ed70d65510dd540edb5d5d2b3288b74be89be401456ae747d1"}, + {file = "pyston_autoload-2.3.5-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:7533445844ec987c2c10983485a0758dbbbfe5bdc98264a6ff4a70cdf6d1df74"}, +] + +[package.dependencies] +pyston = "2.3.5" + [[package]] name = "pytest" version = "8.3.5" @@ -4227,7 +5156,7 @@ version = "310" description = "Python for Window Extensions" optional = false python-versions = "*" -groups = ["dev"] +groups = ["main", "dev"] markers = "sys_platform == \"win32\"" files = [ {file = "pywin32-310-cp310-cp310-win32.whl", hash = "sha256:6dd97011efc8bf51d6793a82292419eba2c71cf8e7250cfac03bba284454abc1"}, @@ -4326,6 +5255,29 @@ files = [ [package.dependencies] pyyaml = "*" +[[package]] +name = "rdflib" +version = "7.1.4" +description = "RDFLib is a Python library for working with RDF, a simple yet powerful language for representing information." +optional = false +python-versions = "<4.0.0,>=3.8.1" +groups = ["main"] +files = [ + {file = "rdflib-7.1.4-py3-none-any.whl", hash = "sha256:72f4adb1990fa5241abd22ddaf36d7cafa5d91d9ff2ba13f3086d339b213d997"}, + {file = "rdflib-7.1.4.tar.gz", hash = "sha256:fed46e24f26a788e2ab8e445f7077f00edcf95abb73bcef4b86cefa8b62dd174"}, +] + +[package.dependencies] +isodate = {version = ">=0.7.2,<1.0.0", markers = "python_version < \"3.11\""} +pyparsing = ">=2.1.0,<4" + +[package.extras] +berkeleydb = ["berkeleydb (>=18.1.0,<19.0.0)"] +html = ["html5rdf (>=1.2,<2)"] +lxml = ["lxml (>=4.3,<6.0)"] +networkx = ["networkx (>=2,<4)"] +orjson = ["orjson (>=3.9.14,<4)"] + [[package]] name = "referencing" version = "0.36.2" @@ -4349,7 +5301,7 @@ version = "2024.11.6" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.8" -groups = ["dev", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, @@ -4472,19 +5424,19 @@ shaping = ["uharfbuzz"] [[package]] name = "requests" -version = "2.32.3" +version = "2.32.4" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" groups = ["main", "dev", "docs"] files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, ] [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" +charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<3" @@ -4806,6 +5758,35 @@ files = [ {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"}, ] +[[package]] +name = "rustworkx" +version = "0.16.0" +description = "A python graph library implemented in Rust" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "rustworkx-0.16.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:476a6c67b0142acd941691943750cc6737a48372304489969c2b62d30aaf4c27"}, + {file = "rustworkx-0.16.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bef2ef42870f806af93979b457e240f6dfa4f867ca33965c620f3a804409ed3a"}, + {file = "rustworkx-0.16.0-cp39-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0db3a73bf68b3e66c08322a2fc95d3aa663d037d9b4e49c3509da4898d3529cc"}, + {file = "rustworkx-0.16.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f12a13d7486234fa2a84746d5e41f436bf9df43548043e7a232f48804ff8c61"}, + {file = "rustworkx-0.16.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89efd5c3a4653ddacc55ca39f28b261d43deec7d678f8f8fc6b76b5087f1dfea"}, + {file = "rustworkx-0.16.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec0c12aac8c54910ace20ac6ada4b890cd39f95f69100514715f8ad7af9041e4"}, + {file = "rustworkx-0.16.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d650e39fc1a1534335f7517358ebfc3478bb235428463cfcd7c5750d50377b33"}, + {file = "rustworkx-0.16.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:293180b83509ee9bff4c3af7ccc1024f6528d61b65d0cb7320bd31924f10cb71"}, + {file = "rustworkx-0.16.0-cp39-abi3-win32.whl", hash = "sha256:040c4368729cf502f756a3b0ff5f1c6915fc389f74dcc6afc6c3833688c97c01"}, + {file = "rustworkx-0.16.0-cp39-abi3-win_amd64.whl", hash = "sha256:905df608843c32fa45ac023687769fe13056edf7584474c801d5c50705d76e9b"}, + {file = "rustworkx-0.16.0.tar.gz", hash = "sha256:9f0dcb83f38d5ca2c3a683eb9b6951c8aec3262fbfe5141946a7ee5ba37e0bb6"}, +] + +[package.dependencies] +numpy = ">=1.16.0,<3" + +[package.extras] +all = ["matplotlib (>=3.0)", "pillow (>=5.4)"] +graphviz = ["pillow (>=5.4)"] +mpl = ["matplotlib (>=3.0)"] + [[package]] name = "s3transfer" version = "0.10.4" @@ -4881,16 +5862,35 @@ typing-extensions = ">=4.7.1" [[package]] name = "schema" -version = "0.7.7" +version = "0.7.5" description = "Simple data validation library" optional = false python-versions = "*" groups = ["main"] files = [ - {file = "schema-0.7.7-py2.py3-none-any.whl", hash = "sha256:5d976a5b50f36e74e2157b47097b60002bd4d42e65425fcc9c9befadb4255dde"}, - {file = "schema-0.7.7.tar.gz", hash = "sha256:7da553abd2958a19dc2547c388cde53398b39196175a9be59ea1caf5ab0a1807"}, + {file = "schema-0.7.5-py2.py3-none-any.whl", hash = "sha256:f3ffdeeada09ec34bf40d7d79996d9f7175db93b7a5065de0faa7f41083c1e6c"}, + {file = "schema-0.7.5.tar.gz", hash = "sha256:f06717112c61895cabc4707752b88716e8420a8819d71404501e114f91043197"}, ] +[package.dependencies] +contextlib2 = ">=0.5.5" + +[[package]] +name = "semantic-version" +version = "2.10.0" +description = "A library implementing the 'SemVer' scheme." +optional = false +python-versions = ">=2.7" +groups = ["main"] +files = [ + {file = "semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177"}, + {file = "semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c"}, +] + +[package.extras] +dev = ["Django (>=1.11)", "check-manifest", "colorama (<=0.4.1) ; python_version == \"3.4\"", "coverage", "flake8", "nose2", "readme-renderer (<25.0) ; python_version == \"3.4\"", "tox", "wheel", "zest.releaser[recommended]"] +doc = ["Sphinx", "sphinx-rtd-theme"] + [[package]] name = "setuptools" version = "79.0.0" @@ -4976,7 +5976,7 @@ version = "5.0.2" description = "A pure Python implementation of a sliding window memory map manager" optional = false python-versions = ">=3.7" -groups = ["docs"] +groups = ["main", "docs"] files = [ {file = "smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e"}, {file = "smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5"}, @@ -4994,6 +5994,59 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, +] + +[[package]] +name = "soupsieve" +version = "2.7" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4"}, + {file = "soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a"}, +] + +[[package]] +name = "spdx-tools" +version = "0.8.3" +description = "SPDX parser and tools." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "spdx-tools-0.8.3.tar.gz", hash = "sha256:68b8f9ce2893b5216bd90b2e63f1c821c2884e4ebc4fd295ebbf1fa8b8a94b93"}, + {file = "spdx_tools-0.8.3-py3-none-any.whl", hash = "sha256:638fd9bd8be61901316eb6d063574e16d5403a1870073ec4d9241426a997501a"}, +] + +[package.dependencies] +beartype = "*" +click = "*" +license-expression = "*" +ply = "*" +pyyaml = "*" +rdflib = "*" +semantic-version = "*" +uritools = "*" +xmltodict = "*" + +[package.extras] +code-style = ["black", "flake8", "isort"] +development = ["black", "flake8", "isort", "networkx", "pytest"] +graph-generation = ["networkx", "pygraphviz"] +test = ["pyshacl", "pytest", "tzdata"] + [[package]] name = "std-uritemplate" version = "2.0.3" @@ -5054,6 +6107,21 @@ files = [ [package.extras] widechars = ["wcwidth"] +[[package]] +name = "termcolor" +version = "2.3.0" +description = "ANSI color formatting for output in terminal" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "termcolor-2.3.0-py3-none-any.whl", hash = "sha256:3afb05607b89aed0ffe25202399ee0867ad4d3cb4180d98aaf8eefa6a5f7d475"}, + {file = "termcolor-2.3.0.tar.gz", hash = "sha256:b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78bf7075fd95420fd9a5a"}, +] + +[package.extras] +tests = ["pytest", "pytest-cov"] + [[package]] name = "tldextract" version = "5.3.0" @@ -5131,6 +6199,28 @@ files = [ {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, ] +[[package]] +name = "tqdm" +version = "4.67.1" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, + {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] +discord = ["requests"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + [[package]] name = "typer" version = "0.15.2" @@ -5161,6 +6251,21 @@ files = [ {file = "typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef"}, ] +[[package]] +name = "typing-inspection" +version = "0.4.1" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, + {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + [[package]] name = "tzdata" version = "2025.2" @@ -5191,6 +6296,18 @@ tzdata = {version = "*", markers = "platform_system == \"Windows\""} [package.extras] devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] +[[package]] +name = "unidiff" +version = "0.7.5" +description = "Unified diff parsing/metadata extraction library." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "unidiff-0.7.5-py2.py3-none-any.whl", hash = "sha256:c93bf2265cc1ba2a520e415ab05da587370bc2a3ae9e0414329f54f0c2fc09e8"}, + {file = "unidiff-0.7.5.tar.gz", hash = "sha256:2e5f0162052248946b9f0970a40e9e124236bf86c82b70821143a6fc1dea2574"}, +] + [[package]] name = "uritemplate" version = "4.1.1" @@ -5203,6 +6320,18 @@ files = [ {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, ] +[[package]] +name = "uritools" +version = "5.0.0" +description = "URI parsing, classification and composition" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "uritools-5.0.0-py3-none-any.whl", hash = "sha256:cead3a49ba8fbca3f91857343849d506d8639718f4a2e51b62e87393b493bd6f"}, + {file = "uritools-5.0.0.tar.gz", hash = "sha256:68180cad154062bd5b5d9ffcdd464f8de6934414b25462ae807b00b8df9345de"}, +] + [[package]] name = "urllib3" version = "1.26.20" @@ -5210,7 +6339,6 @@ description = "HTTP library with thread-safe connection pooling, file post, and optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" groups = ["main", "dev", "docs"] -markers = "python_version < \"3.10\"" files = [ {file = "urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e"}, {file = "urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32"}, @@ -5222,23 +6350,25 @@ secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; py socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] -name = "urllib3" -version = "2.4.0" -description = "HTTP library with thread-safe connection pooling, file post, and more." +name = "virtualenv" +version = "20.31.2" +description = "Virtual Python Environment builder" optional = false -python-versions = ">=3.9" -groups = ["main", "dev", "docs"] -markers = "python_version >= \"3.10\"" +python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"}, - {file = "urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466"}, + {file = "virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11"}, + {file = "virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af"}, ] +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] [[package]] name = "vulture" @@ -5298,6 +6428,18 @@ files = [ [package.extras] watchmedo = ["PyYAML (>=3.10)"] +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + [[package]] name = "websocket-client" version = "1.8.0" @@ -5440,7 +6582,7 @@ version = "0.14.2" description = "Makes working with XML feel like you are working with JSON" optional = false python-versions = ">=3.6" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "xmltodict-0.14.2-py2.py3-none-any.whl", hash = "sha256:20cc7d723ed729276e808f26fb6b3599f786cbc37e06c65e192ba77c40f20aac"}, {file = "xmltodict-0.14.2.tar.gz", hash = "sha256:201e7c28bb210e374999d1dde6382923ab0ed1a8a5faeece48ab525b7810a553"}, @@ -5589,4 +6731,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">3.9.1,<3.13" -content-hash = "c2c48285391fc3a3f721c96211b6c9b0eff54f1b948a9d3bef5749d4d3298c41" +content-hash = "c442552635c8e904d1c7a50f4787c8e90ec90787960ee1867f2235a7aa2205f0" diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 4543136c8f..03652c8a61 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -5,6 +5,8 @@ All notable changes to the **Prowler SDK** are documented in this file. ## [v5.8.0] (Prowler UNRELEASED) ### Added +- Add `storage_geo_redundant_enabled` check for Azure provider. [(#7980)](https://github.com/prowler-cloud/prowler/pull/7980) +- Add `storage_cross_tenant_replication_disabled` check for Azure provider. [(#7977)](https://github.com/prowler-cloud/prowler/pull/7977) - 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) - Weight for Prowler ThreatScore scoring [(#7795)](https://github.com/prowler-cloud/prowler/pull/7795) @@ -26,18 +28,59 @@ All notable changes to the **Prowler SDK** are documented in this file. - `storage_account_key_access_disabled` check for Azure provider [(#7974)](https://github.com/prowler-cloud/prowler/pull/7974) - `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) +- Avoid bypassing IAM check using wildcards [(#7708)](https://github.com/prowler-cloud/prowler/pull/7708) +- `storage_blob_versioning_is_enabled` new check for Azure provider [(#7927)](https://github.com/prowler-cloud/prowler/pull/7927) +- New method to authenticate in AppInsights in check `app_function_application_insights_enabled` [(#7763)](https://github.com/prowler-cloud/prowler/pull/7763) +- ISO 27001 2022 for M365 provider. [(#7985)](https://github.com/prowler-cloud/prowler/pull/7985) +- `codebuild_project_uses_allowed_github_organizations` check for AWS provider [(#7595)](https://github.com/prowler-cloud/prowler/pull/7595) +- IaC provider [(#7852)](https://github.com/prowler-cloud/prowler/pull/7852) +- Azure Databricks service integration for Azure provider, including the `databricks_workspace_vnet_injection_enabled` check [(#8008)](https://github.com/prowler-cloud/prowler/pull/8008) +- Azure Databricks check `databricks_workspace_cmk_encryption_enabled` to ensure workspaces use customer-managed keys (CMK) for encryption at rest [(#8017)](https://github.com/prowler-cloud/prowler/pull/8017) +- Add `storage_account_default_to_entra_authorization_enabled` check for Azure provider. [(#7981)](https://github.com/prowler-cloud/prowler/pull/7981) +- Improve overview page from Prowler Dashboard [(#8118)](https://github.com/prowler-cloud/prowler/pull/8118) +- `keyvault_ensure_public_network_access_disabled` check for Azure provider. [(#8072)](https://github.com/prowler-cloud/prowler/pull/8072) +- New check `monitor_alert_service_health_exists` for Azure provider [(#8067)](https://github.com/prowler-cloud/prowler/pull/8067) +- Replace `Domain.Read.All` with `Directory.Read.All` in Azure and M365 docs [(#8075)](https://github.com/prowler-cloud/prowler/pull/8075) +- Refactor IaC provider to use Checkov as Python library [(#8093)](https://github.com/prowler-cloud/prowler/pull/8093) ### Fixed -- Github provider to `usage` section of `prowler -h`: [(#7906)](https://github.com/prowler-cloud/prowler/pull/7906) +- Consolidate Azure Storage file service properties to the account level, improving the accuracy of the `storage_ensure_file_shares_soft_delete_is_enabled` check [(#8087)](https://github.com/prowler-cloud/prowler/pull/8087) + +### Changed +- Reworked `S3.test_connection` to match the AwsProvider logic [(#8088)](https://github.com/prowler-cloud/prowler/pull/8088) + +### Removed +- OCSF version number references to point always to the latest [(#8064)](https://github.com/prowler-cloud/prowler/pull/8064) --- -## [v5.7.5] (Prowler v5.7.5) + +## [v5.7.6] (Prowler UNRELEASED) ### Fixed +- `organizations_scp_check_deny_regions` check to pass when SCP policies have no statements [(#8091)](https://github.com/prowler-cloud/prowler/pull/8091) +- Fix logic in VPC and ELBv2 checks [(#8077)](https://github.com/prowler-cloud/prowler/pull/8077) +- Retrieve correctly ECS Container insights settings [(#8097)](https://github.com/prowler-cloud/prowler/pull/8097) +- Fix correct handling for different accounts-dates in prowler dashboard compliance page [(#8108)](https://github.com/prowler-cloud/prowler/pull/8108) +- Handling of `block-project-ssh-keys` in GCP check `compute_instance_block_project_wide_ssh_keys_disabled` [(#8115)](https://github.com/prowler-cloud/prowler/pull/8115) +- Handle empty name in Azure Defender and GCP checks [(#8120)](https://github.com/prowler-cloud/prowler/pull/8120) + +--- + +## [v5.7.5] (Prowler 5.7.5) + +### Fixed +- Use unified timestamp for all requirements [(#8059)](https://github.com/prowler-cloud/prowler/pull/8059) +- Add EKS to service without subservices. [(#7959)](https://github.com/prowler-cloud/prowler/pull/7959) - `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) - Typo in PCI 4.0 for K8S provider [(#7971)](https://github.com/prowler-cloud/prowler/pull/7971) - AWS root credentials checks always verify if root credentials are enabled [(#7967)](https://github.com/prowler-cloud/prowler/pull/7967) +- Github provider to `usage` section of `prowler -h`: [(#7906)](https://github.com/prowler-cloud/prowler/pull/7906) +- `network_flow_log_more_than_90_days` check to pass when retention policy is 0 days [(#7975)](https://github.com/prowler-cloud/prowler/pull/7975) +- Update SDK Azure call for ftps_state in the App Service [(#7923)](https://github.com/prowler-cloud/prowler/pull/7923) +- Validate ResourceType in CheckMetadata [(#8035)](https://github.com/prowler-cloud/prowler/pull/8035) +- Missing ResourceType values in check's metadata [(#8028)](https://github.com/prowler-cloud/prowler/pull/8028) +- Avoid user requests in setup_identity app context and user auth log enhancement [(#8043)](https://github.com/prowler-cloud/prowler/pull/8043) --- diff --git a/prowler/__main__.py b/prowler/__main__.py index e22d7b7fa6..8dec8188de 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -64,6 +64,7 @@ from prowler.lib.outputs.compliance.iso27001.iso27001_gcp import GCPISO27001 from prowler.lib.outputs.compliance.iso27001.iso27001_kubernetes import ( KubernetesISO27001, ) +from prowler.lib.outputs.compliance.iso27001.iso27001_m365 import M365ISO27001 from prowler.lib.outputs.compliance.iso27001.iso27001_nhn import NHNISO27001 from prowler.lib.outputs.compliance.kisa_ismsp.kisa_ismsp_aws import AWSKISAISMSP from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_aws import AWSMitreAttack @@ -98,6 +99,7 @@ from prowler.providers.common.provider import Provider from prowler.providers.common.quick_inventory import run_provider_quick_inventory from prowler.providers.gcp.models import GCPOutputOptions from prowler.providers.github.models import GithubOutputOptions +from prowler.providers.iac.models import IACOutputOptions from prowler.providers.kubernetes.models import KubernetesOutputOptions from prowler.providers.m365.models import M365OutputOptions from prowler.providers.nhn.models import NHNOutputOptions @@ -175,11 +177,13 @@ def prowler(): # Load compliance frameworks logger.debug("Loading compliance frameworks from .json files") - bulk_compliance_frameworks = Compliance.get_bulk(provider) - # Complete checks metadata with the compliance framework specification - bulk_checks_metadata = update_checks_metadata_with_compliance( - bulk_compliance_frameworks, bulk_checks_metadata - ) + # Skip compliance frameworks for IAC provider + if provider != "iac": + bulk_compliance_frameworks = Compliance.get_bulk(provider) + # Complete checks metadata with the compliance framework specification + bulk_checks_metadata = update_checks_metadata_with_compliance( + bulk_compliance_frameworks, bulk_checks_metadata + ) # Update checks metadata if the --custom-checks-metadata-file is present custom_checks_metadata = None @@ -231,39 +235,45 @@ def prowler(): if not args.only_logs: global_provider.print_credentials() - # Import custom checks from folder - if checks_folder: - custom_checks = parse_checks_from_folder(global_provider, checks_folder) - # Workaround to be able to execute custom checks alongside all checks if nothing is explicitly set - if ( - not checks_file - and not checks - and not services - and not severities - and not compliance_framework - and not categories - ): - checks_to_execute.update(custom_checks) + # Skip service and check loading for IAC provider + if provider != "iac": + # Import custom checks from folder + if checks_folder: + custom_checks = parse_checks_from_folder(global_provider, checks_folder) + # Workaround to be able to execute custom checks alongside all checks if nothing is explicitly set + if ( + not checks_file + and not checks + and not services + and not severities + and not compliance_framework + and not categories + ): + checks_to_execute.update(custom_checks) - # Exclude checks if -e/--excluded-checks - if excluded_checks: - checks_to_execute = exclude_checks_to_run(checks_to_execute, excluded_checks) + # Exclude checks if -e/--excluded-checks + if excluded_checks: + checks_to_execute = exclude_checks_to_run( + checks_to_execute, excluded_checks + ) - # Exclude services if --excluded-services - if excluded_services: - checks_to_execute = exclude_services_to_run( - checks_to_execute, excluded_services, provider + # Exclude services if --excluded-services + if excluded_services: + checks_to_execute = exclude_services_to_run( + checks_to_execute, excluded_services, provider + ) + + # Once the provider is set and we have the eventual checks based on the resource identifier, + # it is time to check what Prowler's checks are going to be executed + checks_from_resources = ( + global_provider.get_checks_to_execute_by_audit_resources() ) + # Intersect checks from resources with checks to execute so we only run the checks that apply to the resources with the specified ARNs or tags + if getattr(args, "resource_arn", None) or getattr(args, "resource_tag", None): + checks_to_execute = checks_to_execute.intersection(checks_from_resources) - # Once the provider is set and we have the eventual checks based on the resource identifier, - # it is time to check what Prowler's checks are going to be executed - checks_from_resources = global_provider.get_checks_to_execute_by_audit_resources() - # Intersect checks from resources with checks to execute so we only run the checks that apply to the resources with the specified ARNs or tags - if getattr(args, "resource_arn", None) or getattr(args, "resource_tag", None): - checks_to_execute = checks_to_execute.intersection(checks_from_resources) - - # Sort final check list - checks_to_execute = sorted(checks_to_execute) + # Sort final check list + checks_to_execute = sorted(checks_to_execute) # Setup Output Options if provider == "aws": @@ -294,6 +304,8 @@ def prowler(): output_options = NHNOutputOptions( args, bulk_checks_metadata, global_provider.identity ) + elif provider == "iac": + output_options = IACOutputOptions(args, bulk_checks_metadata) # Run the quick inventory for the provider if available if hasattr(args, "quick_inventory") and args.quick_inventory: @@ -303,7 +315,10 @@ def prowler(): # Execute checks findings = [] - if len(checks_to_execute): + if provider == "iac": + # For IAC provider, run the scan directly + findings = global_provider.run() + elif len(checks_to_execute): findings = execute_checks( checks_to_execute, global_provider, @@ -747,6 +762,19 @@ def prowler(): ) generated_outputs["compliance"].append(prowler_threatscore) prowler_threatscore.batch_write_data_to_file() + elif compliance_name.startswith("iso27001_"): + # Generate ISO27001 Finding Object + filename = ( + f"{output_options.output_directory}/compliance/" + f"{output_options.output_filename}_{compliance_name}.csv" + ) + iso27001 = M365ISO27001( + findings=finding_outputs, + compliance=bulk_compliance_frameworks[compliance_name], + file_path=filename, + ) + generated_outputs["compliance"].append(iso27001) + iso27001.batch_write_data_to_file() else: filename = ( f"{output_options.output_directory}/compliance/" diff --git a/prowler/compliance/iac/__init__.py b/prowler/compliance/iac/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/compliance/kubernetes/cis_1.10_kubernetes.json b/prowler/compliance/kubernetes/cis_1.10_kubernetes.json index 379504a127..449de51208 100644 --- a/prowler/compliance/kubernetes/cis_1.10_kubernetes.json +++ b/prowler/compliance/kubernetes/cis_1.10_kubernetes.json @@ -12,7 +12,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the API server pod specification file has permissions of `600` or more restrictive.", "RationaleStatement": "The API server pod specification file controls various parameters that set the behavior of the API server. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -33,7 +33,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the API server pod specification file ownership is set to `root:root`.", "RationaleStatement": "The API server pod specification file controls various parameters that set the behavior of the API server. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -54,7 +54,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.", "RationaleStatement": "The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -75,7 +75,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the controller manager pod specification file ownership is set to `root:root`.", "RationaleStatement": "The controller manager pod specification file controls various parameters that set the behavior of various components of the master node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -96,7 +96,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the scheduler pod specification file has permissions of `600` or more restrictive.", "RationaleStatement": "The scheduler pod specification file controls various parameters that set the behavior of the Scheduler service in the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -117,7 +117,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the scheduler pod specification file ownership is set to `root:root`.", "RationaleStatement": "The scheduler pod specification file controls various parameters that set the behavior of the `kube-scheduler` service in the master node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -138,7 +138,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `/etc/kubernetes/manifests/etcd.yaml` file has permissions of `600` or more restrictive.", "RationaleStatement": "The etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` controls various parameters that set the behavior of the `etcd` service in the master node. etcd is a highly-available key-value store which Kubernetes uses for persistent storage of all of its REST API object. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -159,7 +159,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `/etc/kubernetes/manifests/etcd.yaml` file ownership is set to `root:root`.", "RationaleStatement": "The etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` controls various parameters that set the behavior of the `etcd` service in the master node. etcd is a highly-available key-value store which Kubernetes uses for persistent storage of all of its REST API object. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -180,7 +180,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the Container Network Interface files have permissions of `600` or more restrictive.", "RationaleStatement": "Container Network Interface provides various networking options for overlay networking. You should consult their documentation and restrict their respective file permissions to maintain the integrity of those files. Those files should be writable by only the administrators on the system.", @@ -201,7 +201,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the Container Network Interface files have ownership set to `root:root`.", "RationaleStatement": "Container Network Interface provides various networking options for overlay networking. You should consult their documentation and restrict their respective file permissions to maintain the integrity of those files. Those files should be owned by `root:root`.", @@ -222,7 +222,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the etcd data directory has permissions of `700` or more restrictive.", "RationaleStatement": "etcd is a highly-available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. This data directory should be protected from any unauthorized reads or writes. It should not be readable or writable by any group members or the world.", @@ -243,7 +243,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the etcd data directory ownership is set to `etcd:etcd`.", "RationaleStatement": "etcd is a highly-available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. This data directory should be protected from any unauthorized reads or writes. It should be owned by `etcd:etcd`.", @@ -264,7 +264,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `admin.conf` file (and `super-admin.conf` file, where it exists) have permissions of `600`.", "RationaleStatement": "As part of initial cluster setup, default kubeconfig files are created to be used by the administrator of the cluster. These files contain private keys and certificates which allow for privileged access to the cluster. You should restrict their file permissions to maintain the integrity and confidentiality of the file(s). The file(s) should be readable and writable by only the administrators on the system.", @@ -285,7 +285,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `admin.conf` (and `super-admin.conf` file, where it exists) file ownership is set to `root:root`.", "RationaleStatement": "As part of initial cluster setup, default kubeconfig files are created to be used by the administrator of the cluster. These files contain private keys and certificates which allow for privileged access to the cluster. You should set their file ownership to maintain the integrity and confidentiality of the file. The file(s) should be owned by root:root.", @@ -306,7 +306,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `scheduler.conf` file has permissions of `600` or more restrictive.", "RationaleStatement": "The `scheduler.conf` file is the kubeconfig file for the Scheduler. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -327,7 +327,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `scheduler.conf` file ownership is set to `root:root`.", "RationaleStatement": "The `scheduler.conf` file is the kubeconfig file for the Scheduler. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -348,7 +348,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `controller-manager.conf` file has permissions of 600 or more restrictive.", "RationaleStatement": "The `controller-manager.conf` file is the kubeconfig file for the Controller Manager. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -369,7 +369,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `controller-manager.conf` file ownership is set to `root:root`.", "RationaleStatement": "The `controller-manager.conf` file is the kubeconfig file for the Controller Manager. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -390,7 +390,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the Kubernetes PKI directory and file ownership is set to `root:root`.", "RationaleStatement": "Kubernetes makes use of a number of certificates as part of its operation. You should set the ownership of the directory containing the PKI information and all files in that directory to maintain their integrity. The directory and files should be owned by `root:root`.", @@ -411,7 +411,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that Kubernetes PKI certificate files have permissions of `600` or more restrictive.", "RationaleStatement": "Kubernetes makes use of a number of certificate files as part of the operation of its components. The permissions on these files should be set to `600` or more restrictive to protect their integrity and confidentiality.", @@ -432,7 +432,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that Kubernetes PKI key files have permissions of `600`.", "RationaleStatement": "Kubernetes makes use of a number of key files as part of the operation of its components. The permissions on these files should be set to `600` to protect their integrity and confidentiality.", @@ -455,7 +455,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Disable anonymous requests to the API server.", "RationaleStatement": "When enabled, requests that are not rejected by other configured authentication methods are treated as anonymous requests. These requests are then served by the API server. You should rely on authentication to authorize access and disallow anonymous requests.If you are using RBAC authorization, it is generally considered reasonable to allow anonymous access to the API Server for health checks and discovery purposes, and hence this recommendation is not scored. However, you should consider whether anonymous discovery is an acceptable risk for your purposes.", @@ -478,7 +478,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not use token based authentication.", "RationaleStatement": "The token-based authentication utilizes static tokens to authenticate requests to the apiserver. The tokens are stored in clear-text in a file on the apiserver, and cannot be revoked or rotated without restarting the apiserver. Hence, do not use static token-based authentication.", @@ -501,7 +501,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "This admission controller rejects all net-new usage of the Service field externalIPs.", "RationaleStatement": "Most users do not need the ability to set the `externalIPs` field for a `Service` at all, and cluster admins should consider disabling this functionality by enabling the `DenyServiceExternalIPs` admission controller. Clusters that do need to allow this functionality should consider using some custom policy to manage its usage.", @@ -524,7 +524,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable certificate based kubelet authentication.", "RationaleStatement": "The apiserver, by default, does not authenticate itself to the kubelet's HTTPS endpoints. The requests from the apiserver are treated anonymously. You should set up certificate-based kubelet authentication to ensure that the apiserver authenticates itself to kubelets when submitting requests.", @@ -547,7 +547,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Verify kubelet's certificate before establishing connection.", "RationaleStatement": "The connections from the apiserver to the kubelet are used for fetching logs for pods, attaching (through kubectl) to running pods, and using the kubelet’s port-forwarding functionality. These connections terminate at the kubelet’s HTTPS endpoint. By default, the apiserver does not verify the kubelet’s serving certificate, which makes the connection subject to man-in-the-middle attacks, and unsafe to run over untrusted and/or public networks.", @@ -570,7 +570,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not always authorize all requests.", "RationaleStatement": "The API Server, can be configured to allow all requests. This mode should not be used on any production cluster.", @@ -593,7 +593,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Restrict kubelet nodes to reading only objects associated with them.", "RationaleStatement": "The `Node` authorization mode only allows kubelets to read `Secret`, `ConfigMap`, `PersistentVolume`, and `PersistentVolumeClaim` objects associated with their nodes.", @@ -616,7 +616,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Turn on Role Based Access Control.", "RationaleStatement": "Role Based Access Control (RBAC) allows fine-grained control over the operations that different entities can perform on different objects in the cluster. It is recommended to use the RBAC authorization mode.", @@ -639,7 +639,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Limit the rate at which the API server accepts requests.", "RationaleStatement": "Using `EventRateLimit` admission control enforces a limit on the number of events that the API Server will accept in a given time slice. A misbehaving workload could overwhelm and DoS the API Server, making it unavailable. This particularly applies to a multi-tenant cluster, where there might be a small percentage of misbehaving tenants which could have a significant impact on the performance of the cluster overall. Hence, it is recommended to limit the rate of events that the API server will accept.", @@ -662,7 +662,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not allow all requests.", "RationaleStatement": "Setting admission control plugin `AlwaysAdmit` allows all requests and do not filter any requests.The `AlwaysAdmit` admission controller was deprecated in Kubernetes v1.13. Its behavior was equivalent to turning off all admission controllers.", @@ -685,7 +685,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Always pull images.", "RationaleStatement": "Setting admission control policy to `AlwaysPullImages` forces every new pod to pull the required images every time. In a multi-tenant cluster users can be assured that their private images can only be used by those who have the credentials to pull them. Without this admission control policy, once an image has been pulled to a node, any pod from any user can use it simply by knowing the image’s name, without any authorization check against the image ownership. When this plug-in is enabled, images are always pulled prior to starting containers, which means valid credentials are required.", @@ -708,7 +708,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Automated", "Description": "Automate service accounts management.", "RationaleStatement": "When you create a pod, if you do not specify a service account, it is automatically assigned the `default` service account in the same namespace. You should create your own service account and let the API server manage its security tokens.", @@ -731,7 +731,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Automated", "Description": "Reject creating objects in a namespace that is undergoing termination.", "RationaleStatement": "Setting admission control policy to `NamespaceLifecycle` ensures that objects cannot be created in non-existent namespaces, and that namespaces undergoing termination are not used for creating the new objects. This is recommended to enforce the integrity of the namespace termination process and also for the availability of the newer objects.", @@ -754,7 +754,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Automated", "Description": "Limit the `Node` and `Pod` objects that a kubelet could modify.", "RationaleStatement": "Using the `NodeRestriction` plug-in ensures that the kubelet is restricted to the `Node` and `Pod` objects that it could modify as defined. Such kubelets will only be allowed to modify their own `Node` API object, and only modify `Pod` API objects that are bound to their node.", @@ -777,7 +777,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Disable profiling, if not needed.", "RationaleStatement": "Profiling allows for the identification of specific performance bottlenecks. It generates a significant amount of program data that could potentially be exploited to uncover system and program details. If you are not experiencing any bottlenecks and do not need the profiler for troubleshooting purposes, it is recommended to turn it off to reduce the potential attack surface.", @@ -800,7 +800,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable auditing on the Kubernetes API Server and set the desired audit log path.", "RationaleStatement": "Auditing the Kubernetes API Server provides a security-relevant chronological set of records documenting the sequence of activities that have affected system by individual users, administrators or other components of the system. Even though currently, Kubernetes provides only basic audit capabilities, it should be enabled. You can enable it by setting an appropriate audit log path.", @@ -823,7 +823,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Retain the logs for at least 30 days or as appropriate.", "RationaleStatement": "Retaining logs for at least 30 days ensures that you can go back in time and investigate or correlate any events. Set your audit log retention period to 30 days or as per your business requirements.", @@ -846,7 +846,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Retain 10 or an appropriate number of old log files.", "RationaleStatement": "Kubernetes automatically rotates the log files. Retaining old log files ensures that you would have sufficient log data available for carrying out any investigation or correlation. For example, if you have set file size of 100 MB and the number of old log files to keep as 10, you would approximate have 1 GB of log data that you could potentially use for your analysis.", @@ -869,7 +869,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Rotate log files on reaching 100 MB or as appropriate.", "RationaleStatement": "Kubernetes automatically rotates the log files. Retaining old log files ensures that you would have sufficient log data available for carrying out any investigation or correlation. If you have set file size of 100 MB and the number of old log files to keep as 10, you would approximate have 1 GB of log data that you could potentially use for your analysis.", @@ -892,7 +892,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Set global request timeout for API server requests as appropriate.", "RationaleStatement": "Setting global request timeout allows extending the API server request timeout limit to a duration appropriate to the user's connection speed. By default, it is set to 60 seconds which might be problematic on slower connections making cluster resources inaccessible once the data volume for requests exceeds what can be transmitted in 60 seconds. But, setting this timeout limit to be too large can exhaust the API server resources making it prone to Denial-of-Service attack. Hence, it is recommended to set this limit as appropriate and change the default limit of 60 seconds only if needed.", @@ -915,7 +915,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Validate service account before validating token.", "RationaleStatement": "If `--service-account-lookup` is not enabled, the apiserver only verifies that the authentication token is valid, and does not validate that the service account token mentioned in the request is actually present in etcd. This allows using a service account token even after the corresponding service account is deleted. This is an example of time of check to time of use security issue.", @@ -938,7 +938,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Explicitly set a service account public key file for service accounts on the apiserver.", "RationaleStatement": "By default, if no `--service-account-key-file` is specified to the apiserver, it uses the private key from the TLS serving certificate to verify service account tokens. To ensure that the keys for service account tokens could be rotated as needed, a separate public/private key pair should be used for signing service account tokens. Hence, the public key should be specified to the apiserver with `--service-account-key-file`.", @@ -961,7 +961,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "etcd should be configured to make use of TLS encryption for client connections.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be protected by client authentication. This requires the API server to identify itself to the etcd server using a client certificate and key.", @@ -984,7 +984,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Setup TLS connection on the API server.", "RationaleStatement": "API server communication contains sensitive parameters that should remain encrypted in transit. Configure the API server to serve only HTTPS traffic.", @@ -1007,7 +1007,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Setup TLS connection on the API server.", "RationaleStatement": "API server communication contains sensitive parameters that should remain encrypted in transit. Configure the API server to serve only HTTPS traffic. If `--client-ca-file` argument is set, any request presenting a client certificate signed by one of the authorities in the `client-ca-file` is authenticated with an identity corresponding to the CommonName of the client certificate.", @@ -1030,7 +1030,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "etcd should be configured to make use of TLS encryption for client connections.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be protected by client authentication. This requires the API server to identify itself to the etcd server using a SSL Certificate Authority file.", @@ -1053,7 +1053,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Encrypt etcd key-value store.", "RationaleStatement": "etcd is a highly available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be encrypted at rest to avoid any disclosures.", @@ -1074,7 +1074,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Where `etcd` encryption is used, appropriate providers should be configured.", "RationaleStatement": "Where `etcd` encryption is used, it is important to ensure that the appropriate set of encryption providers is used. Currently, the `aescbc`, `kms` and `secretbox` are likely to be appropriate options.", @@ -1097,7 +1097,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the API server is configured to only use strong cryptographic ciphers.", "RationaleStatement": "TLS ciphers have had a number of known vulnerabilities and weaknesses, which can reduce the protection provided by them. By default Kubernetes supports a number of TLS ciphersuites including some that have security concerns, weakening the protection provided.", @@ -1120,7 +1120,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Activate garbage collector on pod termination, as appropriate.", "RationaleStatement": "Garbage collection is important to ensure sufficient resource availability and avoiding degraded performance and availability. In the worst case, the system might crash or just be unusable for a long period of time. The current setting for garbage collection is 12,500 terminated pods which might be too high for your system to sustain. Based on your system resources and tests, choose an appropriate threshold value to activate garbage collection.", @@ -1143,7 +1143,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Disable profiling, if not needed.", "RationaleStatement": "Profiling allows for the identification of specific performance bottlenecks. It generates a significant amount of program data that could potentially be exploited to uncover system and program details. If you are not experiencing any bottlenecks and do not need the profiler for troubleshooting purposes, it is recommended to turn it off to reduce the potential attack surface.", @@ -1166,7 +1166,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Use individual service account credentials for each controller.", "RationaleStatement": "The controller manager creates a service account per controller in the `kube-system` namespace, generates a credential for it, and builds a dedicated API client with that service account credential for each controller loop to use. Setting the `--use-service-account-credentials` to `true` runs each control loop within the controller manager using a separate service account credential. When used in combination with RBAC, this ensures that the control loops run with the minimum permissions required to perform their intended tasks.", @@ -1189,7 +1189,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Explicitly set a service account private key file for service accounts on the controller manager.", "RationaleStatement": "To ensure that keys for service account tokens can be rotated as needed, a separate public/private key pair should be used for signing service account tokens. The private key should be specified to the controller manager with `--service-account-private-key-file` as appropriate.", @@ -1212,7 +1212,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Allow pods to verify the API server's serving certificate before establishing connections.", "RationaleStatement": "Processes running within pods that need to contact the API server must verify the API server's serving certificate. Failing to do so could be a subject to man-in-the-middle attacks.Providing the root certificate for the API server's serving certificate to the controller manager with the `--root-ca-file` argument allows the controller manager to inject the trusted bundle into pods so that they can verify TLS connections to the API server.", @@ -1235,7 +1235,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable kubelet server certificate rotation on controller-manager.", "RationaleStatement": "`RotateKubeletServerCertificate` causes the kubelet to both request a serving certificate after bootstrapping its client credentials and rotate the certificate as its existing credentials expire. This automated periodic rotation ensures that the there are no downtimes due to expired certificates and thus addressing availability in the CIA security triad.Note: This recommendation only applies if you let kubelets get their certificates from the API server. In case your kubelet certificates come from an outside authority/tool (e.g. Vault) then you need to take care of rotation yourself.", @@ -1258,7 +1258,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not bind the Controller Manager service to non-loopback insecure addresses.", "RationaleStatement": "The Controller Manager API service which runs on port 10252/TCP by default is used for health and metrics information and is available without authentication or encryption. As such it should only be bound to a localhost interface, to minimize the cluster's attack surface", @@ -1281,7 +1281,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.4 Scheduler", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Disable profiling, if not needed.", "RationaleStatement": "Profiling allows for the identification of specific performance bottlenecks. It generates a significant amount of program data that could potentially be exploited to uncover system and program details. If you are not experiencing any bottlenecks and do not need the profiler for troubleshooting purposes, it is recommended to turn it off to reduce the potential attack surface.", @@ -1304,7 +1304,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.4 Scheduler", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not bind the scheduler service to non-loopback insecure addresses.", "RationaleStatement": "The Scheduler API service which runs on port 10251/TCP by default is used for health and metrics information and is available without authentication or encryption. As such it should only be bound to a localhost interface, to minimize the cluster's attack surface", @@ -1326,7 +1326,7 @@ "Attributes": [ { "Section": "2 etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Configure TLS encryption for the etcd service.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be encrypted in transit.", @@ -1348,7 +1348,7 @@ "Attributes": [ { "Section": "2 etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable client authentication on etcd service.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should not be available to unauthenticated clients. You should enable the client authentication via valid certificates to secure the access to the etcd service.", @@ -1370,7 +1370,7 @@ "Attributes": [ { "Section": "2 etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not use self-signed certificates for TLS.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should not be available to unauthenticated clients. You should enable the client authentication via valid certificates to secure the access to the etcd service.", @@ -1392,7 +1392,7 @@ "Attributes": [ { "Section": "2 etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "etcd should be configured to make use of TLS encryption for peer connections.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be encrypted in transit and also amongst peers in the etcd clusters.", @@ -1414,7 +1414,7 @@ "Attributes": [ { "Section": "2 etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "etcd should be configured for peer authentication.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be accessible only by authenticated etcd peers in the etcd cluster.", @@ -1436,7 +1436,7 @@ "Attributes": [ { "Section": "2 etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not use automatically generated self-signed certificates for TLS connections between peers.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be accessible only by authenticated etcd peers in the etcd cluster. Hence, do not use self-signed certificates for authentication.", @@ -1458,7 +1458,7 @@ "Attributes": [ { "Section": "2 etcd", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Use a different certificate authority for etcd from the one used for Kubernetes.", "RationaleStatement": "etcd is a highly available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. Its access should be restricted to specifically designated clients and peers only. Authentication to etcd is based on whether the certificate presented was issued by a trusted certificate authority. There is no checking of certificate attributes such as common name or subject alternative name. As such, if any attackers were able to gain access to any certificate issued by the trusted certificate authority, they would be able to gain full access to the etcd database.", @@ -1479,7 +1479,7 @@ { "Section": "3 Control Plane Configuration", "SubSection": "3.1 Authentication and Authorization", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Kubernetes provides the option to use client certificates for user authentication. However as there is no way to revoke these certificates when a user leaves an organization or loses their credential, they are not suitable for this purpose.It is not possible to fully disable client certificate use within a cluster as it is used for component to component authentication.", "RationaleStatement": "With any authentication mechanism the ability to revoke credentials if they are compromised or no longer required, is a key control. Kubernetes client certificate authentication does not allow for this due to a lack of support for certificate revocation.", @@ -1500,7 +1500,7 @@ { "Section": "3 Control Plane Configuration", "SubSection": "3.1 Authentication and Authorization", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Kubernetes provides service account tokens which are intended for use by workloads running in the Kubernetes cluster, for authentication to the API server.These tokens are not designed for use by end-users and do not provide for features such as revocation or expiry, making them insecure. A newer version of the feature (Bound service account token volumes) does introduce expiry but still does not allow for specific revocation.", "RationaleStatement": "With any authentication mechanism the ability to revoke credentials if they are compromised or no longer required, is a key control. Service account token authentication does not allow for this due to the use of JWT tokens as an underlying technology.", @@ -1521,7 +1521,7 @@ { "Section": "3 Control Plane Configuration", "SubSection": "3.1 Authentication and Authorization", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Kubernetes provides bootstrap tokens which are intended for use by new nodes joining the clusterThese tokens are not designed for use by end-users they are specifically designed for the purpose of bootstrapping new nodes and not for general authentication", "RationaleStatement": "Bootstrap tokens are not intended for use as a general authentication mechanism and impose constraints on user and group naming that do not facilitate good RBAC design. They also cannot be used with MFA resulting in a weak authentication mechanism being available.", @@ -1542,7 +1542,7 @@ { "Section": "3 Control Plane Configuration", "SubSection": "3.2 Logging", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Kubernetes can audit the details of requests made to the API server. The `--audit-policy-file` flag must be set for this logging to be enabled.", "RationaleStatement": "Logging is an important detective control for all systems, to detect potential unauthorised access.", @@ -1563,7 +1563,7 @@ { "Section": "3 Control Plane Configuration", "SubSection": "3.2 Logging", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Ensure that the audit policy created for the cluster covers key security concerns.", "RationaleStatement": "Security audit logs should cover access and modification of key resources in the cluster, to enable them to form an effective part of a security environment.", @@ -1586,7 +1586,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `kubelet` service file has permissions of `600` or more restrictive.", "RationaleStatement": "The `kubelet` service file controls various parameters that set the behavior of the `kubelet` service in the worker node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -1609,7 +1609,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `kubelet` service file ownership is set to `root:root`.", "RationaleStatement": "The `kubelet` service file controls various parameters that set the behavior of the `kubelet` service in the worker node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -1630,7 +1630,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "If `kube-proxy` is running, and if it is using a file-based kubeconfig file, ensure that the proxy kubeconfig file has permissions of `600` or more restrictive.", "RationaleStatement": "The `kube-proxy` kubeconfig file controls various parameters of the `kube-proxy` service in the worker node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.It is possible to run `kube-proxy` with the kubeconfig parameters configured as a Kubernetes ConfigMap instead of a file. In this case, there is no proxy kubeconfig file.", @@ -1651,7 +1651,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "If `kube-proxy` is running, ensure that the file ownership of its kubeconfig file is set to `root:root`.", "RationaleStatement": "The kubeconfig file for `kube-proxy` controls various parameters for the `kube-proxy` service in the worker node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -1674,7 +1674,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `kubelet.conf` file has permissions of `600` or more restrictive.", "RationaleStatement": "The `kubelet.conf` file is the kubeconfig file for the node, and controls various parameters that set the behavior and identity of the worker node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -1697,7 +1697,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `kubelet.conf` file ownership is set to `root:root`.", "RationaleStatement": "The `kubelet.conf` file is the kubeconfig file for the node, and controls various parameters that set the behavior and identity of the worker node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -1718,7 +1718,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the certificate authorities file has permissions of `600` or more restrictive.", "RationaleStatement": "The certificate authorities file controls the authorities used to validate API requests. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -1739,7 +1739,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the certificate authorities file ownership is set to `root:root`.", "RationaleStatement": "The certificate authorities file controls the authorities used to validate API requests. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -1762,7 +1762,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that if the kubelet refers to a configuration file with the `--config` argument, that file has permissions of 600 or more restrictive.", "RationaleStatement": "The kubelet reads various parameters, including security settings, from a config file specified by the `--config` argument. If this file is specified you should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -1785,7 +1785,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that if the kubelet refers to a configuration file with the `--config` argument, that file is owned by root:root.", "RationaleStatement": "The kubelet reads various parameters, including security settings, from a config file specified by the `--config` argument. If this file is specified you should restrict its file permissions to maintain the integrity of the file. The file should be owned by root:root.", @@ -1808,7 +1808,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Disable anonymous requests to the Kubelet server.", "RationaleStatement": "When enabled, requests that are not rejected by other configured authentication methods are treated as anonymous requests. These requests are then served by the Kubelet server. You should rely on authentication to authorize access and disallow anonymous requests.", @@ -1831,7 +1831,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not allow all requests. Enable explicit authorization.", "RationaleStatement": "Kubelets, by default, allow all authenticated requests (even anonymous ones) without needing explicit authorization checks from the apiserver. You should restrict this behavior and only allow explicitly authorized requests.", @@ -1854,7 +1854,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable Kubelet authentication using certificates.", "RationaleStatement": "The connections from the apiserver to the kubelet are used for fetching logs for pods, attaching (through kubectl) to running pods, and using the kubelet’s port-forwarding functionality. These connections terminate at the kubelet’s HTTPS endpoint. By default, the apiserver does not verify the kubelet’s serving certificate, which makes the connection subject to man-in-the-middle attacks, and unsafe to run over untrusted and/or public networks. Enabling Kubelet certificate authentication ensures that the apiserver could authenticate the Kubelet before submitting any requests.", @@ -1877,7 +1877,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Disable the read-only port.", "RationaleStatement": "The Kubelet process provides a read-only API in addition to the main Kubelet API. Unauthenticated access is provided to this read-only API which could possibly retrieve potentially sensitive information about the cluster.", @@ -1900,7 +1900,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not disable timeouts on streaming connections.", "RationaleStatement": "Setting idle timeouts ensures that you are protected against Denial-of-Service attacks, inactive connections and running out of ephemeral ports. **Note:** By default, `--streaming-connection-idle-timeout` is set to 4 hours which might be too high for your environment. Setting this as appropriate would additionally ensure that such streaming connections are timed out after serving legitimate use cases.", @@ -1923,7 +1923,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Allow Kubelet to manage iptables.", "RationaleStatement": "Kubelets can automatically manage the required changes to iptables based on how you choose your networking options for the pods. It is recommended to let kubelets manage the changes to iptables. This ensures that the iptables configuration remains in sync with pods networking configuration. Manually configuring iptables with dynamic pod network configuration changes might hamper the communication between pods/containers and to the outside world. You might have iptables rules too restrictive or too open.", @@ -1944,7 +1944,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not override node hostnames.", "RationaleStatement": "Overriding hostnames could potentially break TLS setup between the kubelet and the apiserver. Additionally, with overridden hostnames, it becomes increasingly difficult to associate logs with a particular node and process them for security analytics. Hence, you should setup your kubelet nodes with resolvable FQDNs and avoid overriding the hostnames with IPs.", @@ -1967,7 +1967,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 2 - Worker Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Security relevant information should be captured. The eventRecordQPS on the Kubelet configuration can be used to limit the rate at which events are gathered and sets the maximum event creations per second. Setting this too low could result in relevant events not being logged, however the unlimited setting of `0` could result in a denial of service on the kubelet.", "RationaleStatement": "It is important to capture all events and not restrict event creation. Events are an important source of security information and analytics that ensure that your environment is consistently monitored using the event data.", @@ -1990,7 +1990,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Setup TLS connection on the Kubelets.", "RationaleStatement": "The connections from the apiserver to the kubelet are used for fetching logs for pods, attaching (through kubectl) to running pods, and using the kubelet’s port-forwarding functionality. These connections terminate at the kubelet’s HTTPS endpoint. By default, the apiserver does not verify the kubelet’s serving certificate, which makes the connection subject to man-in-the-middle attacks, and unsafe to run over untrusted and/or public networks.", @@ -2013,7 +2013,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable kubelet client certificate rotation.", "RationaleStatement": "The `--rotate-certificates` setting causes the kubelet to rotate its client certificates by creating new CSRs as its existing credentials expire. This automated periodic rotation ensures that the there is no downtime due to expired certificates and thus addressing availability in the CIA security triad.**Note:** This recommendation only applies if you let kubelets get their certificates from the API server. In case your kubelet certificates come from an outside authority/tool (e.g. Vault) then you need to take care of rotation yourself.**Note:** This feature also require the `RotateKubeletClientCertificate` feature gate to be enabled (which is the default since Kubernetes v1.7)", @@ -2034,7 +2034,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Enable kubelet server certificate rotation.", "RationaleStatement": "`RotateKubeletServerCertificate` causes the kubelet to both request a serving certificate after bootstrapping its client credentials and rotate the certificate as its existing credentials expire. This automated periodic rotation ensures that the there are no downtimes due to expired certificates and thus addressing availability in the CIA security triad.Note: This recommendation only applies if you let kubelets get their certificates from the API server. In case your kubelet certificates come from an outside authority/tool (e.g. Vault) then you need to take care of rotation yourself.", @@ -2057,7 +2057,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the Kubelet is configured to only use strong cryptographic ciphers.", "RationaleStatement": "TLS ciphers have had a number of known vulnerabilities and weaknesses, which can reduce the protection provided by them. By default Kubernetes supports a number of TLS ciphersuites including some that have security concerns, weakening the protection provided.", @@ -2078,7 +2078,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the Kubelet sets limits on the number of PIDs that can be created by pods running on the node.", "RationaleStatement": "By default pods running in a cluster can consume any number of PIDs, potentially exhausting the resources available on the node. Setting an appropriate limit reduces the risk of a denial of service attack on cluster nodes.", @@ -2099,7 +2099,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.3 kube-proxy", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not bind the kube-proxy metrics port to non-loopback addresses.", "RationaleStatement": "kube-proxy has two APIs which provided access to information about the service and can be bound to network ports. The metrics API service includes endpoints (`/metrics` and `/configz`) which disclose information about the configuration and operation of kube-proxy. These endpoints should not be exposed to untrusted networks as they do not support encryption or authentication to restrict access to the data they provide.", @@ -2122,7 +2122,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "The RBAC role `cluster-admin` provides wide-ranging powers over the environment and should be used only where and when needed.", "RationaleStatement": "Kubernetes provides a set of default roles where RBAC is used. Some of these roles such as `cluster-admin` provide wide-ranging privileges which should only be applied where absolutely necessary. Roles such as `cluster-admin` allow super-user access to perform any action on any resource. When used in a `ClusterRoleBinding`, it gives full control over every resource in the cluster and in all namespaces. When used in a `RoleBinding`, it gives full control over every resource in the rolebinding's namespace, including the namespace itself.", @@ -2145,7 +2145,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "The Kubernetes API stores secrets, which may be service account tokens for the Kubernetes API or credentials used by workloads in the cluster. Access to these secrets should be restricted to the smallest possible group of users to reduce the risk of privilege escalation.", "RationaleStatement": "Inappropriate access to secrets stored within the Kubernetes cluster can allow for an attacker to gain additional access to the Kubernetes cluster or external resources whose credentials are stored as secrets.", @@ -2168,7 +2168,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Kubernetes Roles and ClusterRoles provide access to resources based on sets of objects and actions that can be taken on those objects. It is possible to set either of these to be the wildcard \"*\" which matches all items. Use of wildcards is not optimal from a security perspective as it may allow for inadvertent access to be granted when new resources are added to the Kubernetes API either as CRDs or in later versions of the product.", "RationaleStatement": "The principle of least privilege recommends that users are provided only the access required for their role and nothing more. The use of wildcard rights grants is likely to provide excessive rights to the Kubernetes API.", @@ -2191,7 +2191,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "The ability to create pods in a namespace can provide a number of opportunities for privilege escalation, such as assigning privileged service accounts to these pods or mounting hostPaths with access to sensitive data (unless Pod Security Policies are implemented to restrict this access)As such, access to create new pods should be restricted to the smallest possible group of users.", "RationaleStatement": "The ability to create pods in a cluster opens up possibilities for privilege escalation and should be restricted, where possible.", @@ -2212,7 +2212,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "The `default` service account should not be used to ensure that rights granted to applications can be more easily audited and reviewed.", "RationaleStatement": "Kubernetes provides a `default` service account which is used by cluster workloads where no specific service account is assigned to the pod.Where access to the Kubernetes API from a pod is required, a specific service account should be created for that pod, and rights granted to that service account.The default service account should be configured such that it does not provide a service account token and does not have any explicit rights assignments.", @@ -2233,7 +2233,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Service accounts tokens should not be mounted in pods except where the workload running in the pod explicitly needs to communicate with the API server", "RationaleStatement": "Mounting service account tokens inside pods can provide an avenue for privilege escalation attacks where an attacker is able to compromise a single pod in the cluster.Avoiding mounting these tokens removes this attack avenue.", @@ -2254,7 +2254,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "The special group `system:masters` should not be used to grant permissions to any user or service account, except where strictly necessary (e.g. bootstrapping access prior to RBAC being fully available)", "RationaleStatement": "The `system:masters` group has unrestricted access to the Kubernetes API hard-coded into the API server source code. An authenticated user who is a member of this group cannot have their access reduced, even if all bindings and cluster role bindings which mention it, are removed.When combined with client certificate authentication, use of this group can allow for irrevocable cluster-admin level credentials to exist for a cluster.", @@ -2275,7 +2275,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Cluster roles and roles with the impersonate, bind or escalate permissions should not be granted unless strictly required. Each of these permissions allow a particular subject to escalate their privileges beyond those explicitly granted by cluster administrators", "RationaleStatement": "The impersonate privilege allows a subject to impersonate other users gaining their rights to the cluster. The bind privilege allows the subject to add a binding to a cluster role or role which escalates their effective permissions in the cluster. The escalate privilege allows a subject to modify cluster roles to which they are bound, increasing their rights to that level.Each of these permissions has the potential to allow for privilege escalation to cluster-admin level.", @@ -2298,7 +2298,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "The ability to create persistent volumes in a cluster can provide an opportunity for privilege escalation, via the creation of `hostPath` volumes. As persistent volumes are not covered by Pod Security Admission, a user with access to create persistent volumes may be able to get access to sensitive files from the underlying host even where restrictive Pod Security Admission policies are in place.", "RationaleStatement": "The ability to create persistent volumes in a cluster opens up possibilities for privilege escalation and should be restricted, where possible.", @@ -2321,7 +2321,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Users with access to the `Proxy` sub-resource of `Node` objects automatically have permissions to use the Kubelet API, which may allow for privilege escalation or bypass cluster security controls such as audit logs.The Kubelet provides an API which includes rights to execute commands in any container running on the node. Access to this API is covered by permissions to the main Kubernetes API via the `node` object. The proxy sub-resource specifically allows wide ranging access to the Kubelet API.Direct access to the Kubelet API bypasses controls like audit logging (there is no audit log of Kubelet API access) and admission control.", "RationaleStatement": "The ability to use the `proxy` sub-resource of `node` objects opens up possibilities for privilege escalation and should be restricted, where possible.", @@ -2344,7 +2344,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Users with access to the update the `approval` sub-resource of `certificateaigningrequests` objects can approve new client certificates for the Kubernetes API effectively allowing them to create new high-privileged user accounts.This can allow for privilege escalation to full cluster administrator, depending on users configured in the cluster", "RationaleStatement": "The ability to update certificate signing requests should be limited.", @@ -2367,7 +2367,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Users with rights to create/modify/delete `validatingwebhookconfigurations` or `mutatingwebhookconfigurations` can control webhooks that can read any object admitted to the cluster, and in the case of mutating webhooks, also mutate admitted objects. This could allow for privilege escalation or disruption of the operation of the cluster.", "RationaleStatement": "The ability to manage webhook configuration should be limited", @@ -2390,7 +2390,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Users with rights to create new service account tokens at a cluster level, can create long-lived privileged credentials in the cluster. This could allow for privilege escalation and persistent access to the cluster, even if the users account has been revoked.", "RationaleStatement": "The ability to create service account tokens should be limited.", @@ -2411,7 +2411,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Ensure that the cluster has at least one active policy control mechanism in place", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Every Kubernetes cluster should have at least one policy control mechanism in place to enforce the other requirements in this section. This could be the in-built Pod Security Admission controller, or a third party policy control system.", "RationaleStatement": "Without an active policy control mechanism, it is not possible to limit the use of containers with access to underlying cluster nodes, via mechanisms like privileged containers, or the use of hostPath volume mounts.", @@ -2434,7 +2434,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Ensure that the cluster has at least one active policy control mechanism in place", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers to be run with the `securityContext.privileged` flag set to `true`.", "RationaleStatement": "Privileged containers have access to all Linux Kernel capabilities and devices. A container running with full privileges can do almost everything that the host can do. This flag exists to allow special use-cases, like manipulating the network stack and accessing devices. There should be at least one admission control policy defined which does not permit privileged containers. If you need to run privileged containers, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2457,7 +2457,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Ensure that the cluster has at least one active policy control mechanism in place", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers to be run with the `hostPID` flag set to true.", "RationaleStatement": "A container running in the host's PID namespace can inspect processes running outside the container. If the container also has access to ptrace capabilities this can be used to escalate privileges outside of the container.There should be at least one admission control policy defined which does not permit containers to share the host PID namespace.If you need to run containers which require hostPID, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2480,7 +2480,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Ensure that the cluster has at least one active policy control mechanism in place", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers to be run with the `hostIPC` flag set to true.", "RationaleStatement": "A container running in the host's IPC namespace can use IPC to interact with processes outside the container.There should be at least one admission control policy defined which does not permit containers to share the host IPC namespace.If you need to run containers which require hostIPC, this should be definited in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2503,7 +2503,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Ensure that the cluster has at least one active policy control mechanism in place", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers to be run with the `hostNetwork` flag set to true.", "RationaleStatement": "A container running in the host's network namespace could access the local loopback device, and could access network traffic to and from other pods.There should be at least one admission control policy defined which does not permit containers to share the host network namespace.If you need to run containers which require access to the host's network namesapces, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2526,7 +2526,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Ensure that the cluster has at least one active policy control mechanism in place", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers to be run with the `allowPrivilegeEscalation` flag set to true. Allowing this right can lead to a process running a container getting more rights than it started with.It's important to note that these rights are still constrained by the overall container sandbox, and this setting does not relate to the use of privileged containers.", "RationaleStatement": "A container running with the `allowPrivilegeEscalation` flag set to `true` may have processes that can gain more privileges than their parent.There should be at least one admission control policy defined which does not permit containers to allow privilege escalation. The option exists (and is defaulted to true) to permit setuid binaries to run. If you have need to run containers which use setuid binaries or require privilege escalation, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2549,7 +2549,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Ensure that the cluster has at least one active policy control mechanism in place", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers to be run as the root user.", "RationaleStatement": "Containers may run as any Linux user. Containers which run as the root user, whilst constrained by Container Runtime security features still have a escalated likelihood of container breakout.Ideally, all containers should run as a defined non-UID 0 user.There should be at least one admission control policy defined which does not permit root containers.If you need to run root containers, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2572,7 +2572,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Ensure that the cluster has at least one active policy control mechanism in place", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers with the potentially dangerous NET_RAW capability.", "RationaleStatement": "Containers run with a default set of capabilities as assigned by the Container Runtime. By default this can include potentially dangerous capabilities. With Docker as the container runtime the NET_RAW capability is enabled which may be misused by malicious containers.Ideally, all containers should drop this capability.There should be at least one admission control policy defined which does not permit containers with the NET_RAW capability.If you need to run containers with this capability, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2595,7 +2595,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Ensure that the cluster has at least one active policy control mechanism in place", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers with capabilities assigned beyond the default set.", "RationaleStatement": "Containers run with a default set of capabilities as assigned by the Container Runtime. Capabilities outside this set can be added to containers which could expose them to risks of container breakout attacks.There should be at least one policy defined which prevents containers with capabilities beyond the default set from launching.If you need to run containers with additional capabilities, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2618,7 +2618,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Ensure that the cluster has at least one active policy control mechanism in place", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers with capabilities", "RationaleStatement": "Containers run with a default set of capabilities as assigned by the Container Runtime. Capabilities are parts of the rights generally granted on a Linux system to the root user.In many cases applications running in containers do not require any capabilities to operate, so from the perspective of the principal of least privilege use of capabilities should be minimized.", @@ -2641,7 +2641,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Ensure that the cluster has at least one active policy control mechanism in place", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit Windows containers to be run with the `hostProcess` flag set to true.", "RationaleStatement": "A Windows container making use of the `hostProcess` flag can interact with the underlying Windows cluster node. As per the Kubernetes documentation, this provides \"privileged access\" to the Windows node.Where Windows containers are used inside a Kubernetes cluster, there should be at least one admission control policy which does not permit `hostProcess` Windows containers.If you need to run Windows containers which require `hostProcess`, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2662,7 +2662,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Ensure that the cluster has at least one active policy control mechanism in place", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally admit containers which make use of `hostPath` volumes.", "RationaleStatement": "A container which mounts a `hostPath` volume as part of its specification will have access to the filesystem of the underlying cluster node. The use of `hostPath` volumes may allow containers access to privileged areas of the node filesystem.There should be at least one admission control policy defined which does not permit containers to mount `hostPath` volumes.If you need to run containers which require `hostPath` volumes, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2685,7 +2685,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Ensure that the cluster has at least one active policy control mechanism in place", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers which require the use of HostPorts.", "RationaleStatement": "Host ports connect containers directly to the host's network. This can bypass controls such as network policy.There should be at least one admission control policy defined which does not permit containers which require the use of HostPorts.If you need to run containers which require HostPorts, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2706,7 +2706,7 @@ { "Section": "5 Policies", "SubSection": "5.3 Network Policies and CNI", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "There are a variety of CNI plugins available for Kubernetes. If the CNI in use does not support Network Policies it may not be possible to effectively restrict traffic in the cluster.", "RationaleStatement": "Kubernetes network policies are enforced by the CNI plugin in use. As such it is important to ensure that the CNI plugin supports both Ingress and Egress network policies.", @@ -2727,7 +2727,7 @@ { "Section": "5 Policies", "SubSection": "5.3 Network Policies and CNI", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Use network policies to isolate traffic in your cluster network.", "RationaleStatement": "Running different applications on the same Kubernetes cluster creates a risk of one compromised application attacking a neighboring application. Network segmentation is important to ensure that containers can communicate only with those they are supposed to. A network policy is a specification of how selections of pods are allowed to communicate with each other and other network endpoints. Network Policies are namespace scoped. When a network policy is introduced to a given namespace, all traffic not allowed by the policy is denied. However, if there are no network policies in a namespace all traffic will be allowed into and out of the pods in that namespace.", @@ -2750,7 +2750,7 @@ { "Section": "5 Policies", "SubSection": "5.4 Secrets Management", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Kubernetes supports mounting secrets as data volumes or as environment variables. Minimize the use of environment variable secrets.", "RationaleStatement": "It is reasonably common for application code to log out its environment (particularly in the event of an error). This will include any secret values passed in as environment variables, so secrets can easily be exposed to any user or entity who has access to the logs.", @@ -2771,7 +2771,7 @@ { "Section": "5 Policies", "SubSection": "5.4 Secrets Management", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Consider the use of an external secrets storage and management system, instead of using Kubernetes Secrets directly, if you have more complex secret management needs. Ensure the solution requires authentication to access secrets, has auditing of access to and use of secrets, and encrypts secrets. Some solutions also make it easier to rotate secrets.", "RationaleStatement": "Kubernetes supports secrets as first-class objects, but care needs to be taken to ensure that access to secrets is carefully limited. Using an external secrets provider can ease the management of access to secrets, especially where secrests are used across both Kubernetes and non-Kubernetes environments.", @@ -2792,7 +2792,7 @@ { "Section": "5 Policies", "SubSection": "5.5 Extensible Admission Control", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Configure Image Provenance for your deployment.", "RationaleStatement": "Kubernetes supports plugging in provenance rules to accept or reject the images in your deployments. You could configure such rules to ensure that only approved images are deployed in the cluster.", @@ -2813,7 +2813,7 @@ { "Section": "5 Policies", "SubSection": "5.7 General Policies", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Use namespaces to isolate your Kubernetes objects.", "RationaleStatement": "Limiting the scope of user permissions can reduce the impact of mistakes or malicious activities. A Kubernetes namespace allows you to partition created resources into logically named groups. Resources created in one namespace can be hidden from other namespaces. By default, each resource created by a user in Kubernetes cluster runs in a default namespace, called `default`. You can create additional namespaces and attach resources and users to them. You can use Kubernetes Authorization plugins to create policies that segregate access to namespace resources between different users.", @@ -2836,7 +2836,7 @@ { "Section": "5 Policies", "SubSection": "5.7 General Policies", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Enable `docker/default` seccomp profile in your pod definitions.", "RationaleStatement": "Seccomp (secure computing mode) is used to restrict the set of system calls applications can make, allowing cluster administrators greater control over the security of workloads running in the cluster. Kubernetes disables seccomp profiles by default for historical reasons. You should enable it to ensure that the workloads have restricted actions available within the container.", @@ -2857,7 +2857,7 @@ { "Section": "5 Policies", "SubSection": "5.7 General Policies", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Apply Security Context to Your Pods and Containers", "RationaleStatement": "A security context defines the operating system security settings (uid, gid, capabilities, SELinux role, etc..) applied to a container. When designing your containers and pods, make sure that you configure the security context for your pods, containers, and volumes. A security context is a property defined in the deployment yaml. It controls the security parameters that will be assigned to the pod/container/volume. There are two levels of security context: pod level security context, and container level security context.", @@ -2878,7 +2878,7 @@ { "Section": "5 Policies", "SubSection": "5.7 General Policies", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Kubernetes provides a default namespace, where objects are placed if no namespace is specified for them. Placing objects in this namespace makes application of RBAC and other controls more difficult.", "RationaleStatement": "Resources in a Kubernetes cluster should be segregated by namespace, to allow for security controls to be applied at that level and to make it easier to manage resources.", diff --git a/prowler/compliance/kubernetes/cis_1.11_kubernetes.json b/prowler/compliance/kubernetes/cis_1.11_kubernetes.json index d091e42bae..832533624e 100644 --- a/prowler/compliance/kubernetes/cis_1.11_kubernetes.json +++ b/prowler/compliance/kubernetes/cis_1.11_kubernetes.json @@ -12,7 +12,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the API server pod specification file has permissions of `600` or more restrictive.", "RationaleStatement": "The API server pod specification file controls various parameters that set the behavior of the API server. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -33,7 +33,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the API server pod specification file ownership is set to `root:root`.", "RationaleStatement": "The API server pod specification file controls various parameters that set the behavior of the API server. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -54,7 +54,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.", "RationaleStatement": "The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -75,7 +75,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the controller manager pod specification file ownership is set to `root:root`.", "RationaleStatement": "The controller manager pod specification file controls various parameters that set the behavior of various components of the master node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -96,7 +96,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the scheduler pod specification file has permissions of `600` or more restrictive.", "RationaleStatement": "The scheduler pod specification file controls various parameters that set the behavior of the Scheduler service in the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -117,7 +117,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the scheduler pod specification file ownership is set to `root:root`.", "RationaleStatement": "The scheduler pod specification file controls various parameters that set the behavior of the `kube-scheduler` service in the master node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -138,7 +138,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `/etc/kubernetes/manifests/etcd.yaml` file has permissions of `600` or more restrictive.", "RationaleStatement": "The etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` controls various parameters that set the behavior of the `etcd` service in the master node. etcd is a highly-available key-value store which Kubernetes uses for persistent storage of all of its REST API object. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -159,7 +159,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `/etc/kubernetes/manifests/etcd.yaml` file ownership is set to `root:root`.", "RationaleStatement": "The etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` controls various parameters that set the behavior of the `etcd` service in the master node. etcd is a highly-available key-value store which Kubernetes uses for persistent storage of all of its REST API object. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -180,7 +180,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the Container Network Interface files have permissions of `600` or more restrictive.", "RationaleStatement": "Container Network Interface provides various networking options for overlay networking. You should consult their documentation and restrict their respective file permissions to maintain the integrity of those files. Those files should be writable by only the administrators on the system.", @@ -201,7 +201,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the Container Network Interface files have ownership set to `root:root`.", "RationaleStatement": "Container Network Interface provides various networking options for overlay networking. You should consult their documentation and restrict their respective file permissions to maintain the integrity of those files. Those files should be owned by `root:root`.", @@ -222,7 +222,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the etcd data directory has permissions of `700` or more restrictive.", "RationaleStatement": "etcd is a highly-available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. This data directory should be protected from any unauthorized reads or writes. It should not be readable or writable by any group members or the world.", @@ -243,7 +243,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the etcd data directory ownership is set to `etcd:etcd`.", "RationaleStatement": "etcd is a highly-available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. This data directory should be protected from any unauthorized reads or writes. It should be owned by `etcd:etcd`.", @@ -264,7 +264,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `admin.conf` file (and `super-admin.conf` file, where it exists) have permissions of `600`.", "RationaleStatement": "As part of initial cluster setup, default kubeconfig files are created to be used by the administrator of the cluster. These files contain private keys and certificates which allow for privileged access to the cluster. You should restrict their file permissions to maintain the integrity and confidentiality of the file(s). The file(s) should be readable and writable by only the administrators on the system.", @@ -285,7 +285,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `admin.conf` (and `super-admin.conf` file, where it exists) file ownership is set to `root:root`.", "RationaleStatement": "As part of initial cluster setup, default kubeconfig files are created to be used by the administrator of the cluster. These files contain private keys and certificates which allow for privileged access to the cluster. You should set their file ownership to maintain the integrity and confidentiality of the file. The file(s) should be owned by `root:root`.", @@ -306,7 +306,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `scheduler.conf` file has permissions of `600` or more restrictive.", "RationaleStatement": "The `scheduler.conf` file is the kubeconfig file for the Scheduler. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -327,7 +327,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `scheduler.conf` file ownership is set to `root:root`.", "RationaleStatement": "The `scheduler.conf` file is the kubeconfig file for the Scheduler. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -348,7 +348,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `controller-manager.conf` file has permissions of 600 or more restrictive.", "RationaleStatement": "The `controller-manager.conf` file is the kubeconfig file for the Controller Manager. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -369,7 +369,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `controller-manager.conf` file ownership is set to `root:root`.", "RationaleStatement": "The `controller-manager.conf` file is the kubeconfig file for the Controller Manager. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -390,7 +390,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the Kubernetes PKI directory and file ownership is set to `root:root`.", "RationaleStatement": "Kubernetes makes use of a number of certificates as part of its operation. You should set the ownership of the directory containing the PKI information and all files in that directory to maintain their integrity. The directory and files should be owned by `root:root`.", @@ -411,7 +411,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that Kubernetes PKI certificate files have permissions of `644` or more restrictive.", "RationaleStatement": "Kubernetes makes use of a number of certificate files as part of the operation of its components. The permissions on these files should be set to `644` or more restrictive to protect their integrity and confidentiality.", @@ -432,7 +432,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that Kubernetes PKI key files have permissions of `600`.", "RationaleStatement": "Kubernetes makes use of a number of key files as part of the operation of its components. The permissions on these files should be set to `600` to protect their integrity and confidentiality.", @@ -455,7 +455,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Disable anonymous requests to the API server.", "RationaleStatement": "When enabled, requests that are not rejected by other configured authentication methods are treated as anonymous requests. These requests are then served by the API server. You should rely on authentication to authorize access and disallow anonymous requests. If you are using RBAC authorization, it is generally considered reasonable to allow anonymous access to the API Server for health checks and discovery purposes, and hence this recommendation is not scored. However, you should consider whether anonymous discovery is an acceptable risk for your purposes.", @@ -478,7 +478,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not use token based authentication.", "RationaleStatement": "The token-based authentication utilizes static tokens to authenticate requests to the apiserver. The tokens are stored in clear-text in a file on the apiserver, and cannot be revoked or rotated without restarting the apiserver. Hence, do not use static token-based authentication.", @@ -501,7 +501,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "This admission controller rejects all net-new usage of the Service field externalIPs.", "RationaleStatement": "Most users do not need the ability to set the `externalIPs` field for a `Service` at all, and cluster admins should consider disabling this functionality by enabling the `DenyServiceExternalIPs` admission controller. Clusters that do need to allow this functionality should consider using some custom policy to manage its usage.", @@ -524,7 +524,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable certificate based kubelet authentication.", "RationaleStatement": "The apiserver, by default, does not authenticate itself to the kubelet's HTTPS endpoints. The requests from the apiserver are treated anonymously. You should set up certificate-based kubelet authentication to ensure that the apiserver authenticates itself to kubelets when submitting requests.", @@ -547,7 +547,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Verify kubelet's certificate before establishing connection.", "RationaleStatement": "The connections from the apiserver to the kubelet are used for fetching logs for pods, attaching (through kubectl) to running pods, and using the kubelet’s port-forwarding functionality. These connections terminate at the kubelet’s HTTPS endpoint. By default, the apiserver does not verify the kubelet’s serving certificate, which makes the connection subject to man-in-the-middle attacks, and unsafe to run over untrusted and/or public networks.", @@ -570,7 +570,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not always authorize all requests.", "RationaleStatement": "The API Server, can be configured to allow all requests. This mode should not be used on any production cluster.", @@ -593,7 +593,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Restrict kubelet nodes to reading only objects associated with them.", "RationaleStatement": "The `Node` authorization mode only allows kubelets to read `Secret`, `ConfigMap`, `PersistentVolume`, and `PersistentVolumeClaim` objects associated with their nodes.", @@ -616,7 +616,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Turn on Role Based Access Control.", "RationaleStatement": "Role Based Access Control (RBAC) allows fine-grained control over the operations that different entities can perform on different objects in the cluster. It is recommended to use the RBAC authorization mode.", @@ -639,7 +639,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Limit the rate at which the API server accepts requests.", "RationaleStatement": "Using `EventRateLimit` admission control enforces a limit on the number of events that the API Server will accept in a given time slice. A misbehaving workload could overwhelm and DoS the API Server, making it unavailable. This particularly applies to a multi-tenant cluster, where there might be a small percentage of misbehaving tenants which could have a significant impact on the performance of the cluster overall. Hence, it is recommended to limit the rate of events that the API server will accept.", @@ -662,7 +662,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not allow all requests.", "RationaleStatement": "Setting admission control plugin `AlwaysAdmit` allows all requests and do not filter any requests. The `AlwaysAdmit` admission controller was deprecated in Kubernetes v1.13. Its behavior was equivalent to turning off all admission controllers.", @@ -685,7 +685,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Always pull images.", "RationaleStatement": "Setting admission control policy to `AlwaysPullImages` forces every new pod to pull the required images every time. In a multi-tenant cluster users can be assured that their private images can only be used by those who have the credentials to pull them. Without this admission control policy, once an image has been pulled to a node, any pod from any user can use it simply by knowing the image’s name, without any authorization check against the image ownership. When this plug-in is enabled, images are always pulled prior to starting containers, which means valid credentials are required.", @@ -708,7 +708,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Automated", "Description": "Automate service accounts management.", "RationaleStatement": "When you create a pod, if you do not specify a service account, it is automatically assigned the `default` service account in the same namespace. You should create your own service account and let the API server manage its security tokens.", @@ -731,7 +731,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Automated", "Description": "Reject creating objects in a namespace that is undergoing termination.", "RationaleStatement": "Setting admission control policy to `NamespaceLifecycle` ensures that objects cannot be created in non-existent namespaces, and that namespaces undergoing termination are not used for creating the new objects. This is recommended to enforce the integrity of the namespace termination process and also for the availability of the newer objects.", @@ -754,7 +754,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Automated", "Description": "Limit the `Node` and `Pod` objects that a kubelet could modify.", "RationaleStatement": "Using the `NodeRestriction` plug-in ensures that the kubelet is restricted to the `Node` and `Pod` objects that it could modify as defined. Such kubelets will only be allowed to modify their own `Node` API object, and only modify `Pod` API objects that are bound to their node.", @@ -777,7 +777,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Disable profiling, if not needed.", "RationaleStatement": "Profiling allows for the identification of specific performance bottlenecks. It generates a significant amount of program data that could potentially be exploited to uncover system and program details. If you are not experiencing any bottlenecks and do not need the profiler for troubleshooting purposes, it is recommended to turn it off to reduce the potential attack surface.", @@ -800,7 +800,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable auditing on the Kubernetes API Server and set the desired audit log path.", "RationaleStatement": "Auditing the Kubernetes API Server provides a security-relevant chronological set of records documenting the sequence of activities that have affected system by individual users, administrators or other components of the system. Even though currently, Kubernetes provides only basic audit capabilities, it should be enabled. You can enable it by setting an appropriate audit log path.", @@ -823,7 +823,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Retain the logs for at least 30 days or as appropriate.", "RationaleStatement": "Retaining logs for at least 30 days ensures that you can go back in time and investigate or correlate any events. Set your audit log retention period to 30 days or as per your business requirements.", @@ -846,7 +846,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Retain 10 or an appropriate number of old log files.", "RationaleStatement": "Kubernetes automatically rotates the log files. Retaining old log files ensures that you would have sufficient log data available for carrying out any investigation or correlation. For example, if you have set file size of 100 MB and the number of old log files to keep as 10, you would approximate have 1 GB of log data that you could potentially use for your analysis.", @@ -869,7 +869,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Rotate log files on reaching 100 MB or as appropriate.", "RationaleStatement": "Kubernetes automatically rotates the log files. Retaining old log files ensures that you would have sufficient log data available for carrying out any investigation or correlation. If you have set file size of 100 MB and the number of old log files to keep as 10, you would approximate have 1 GB of log data that you could potentially use for your analysis.", @@ -892,7 +892,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Set global request timeout for API server requests as appropriate.", "RationaleStatement": "Setting global request timeout allows extending the API server request timeout limit to a duration appropriate to the user's connection speed. By default, it is set to 60 seconds which might be problematic on slower connections making cluster resources inaccessible once the data volume for requests exceeds what can be transmitted in 60 seconds. But, setting this timeout limit to be too large can exhaust the API server resources making it prone to Denial-of-Service attack. Hence, it is recommended to set this limit as appropriate and change the default limit of 60 seconds only if needed.", @@ -915,7 +915,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Validate service account before validating token.", "RationaleStatement": "If `--service-account-lookup` is not enabled, the apiserver only verifies that the authentication token is valid, and does not validate that the service account token mentioned in the request is actually present in etcd. This allows using a service account token even after the corresponding service account is deleted. This is an example of time of check to time of use security issue.", @@ -938,7 +938,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Explicitly set a service account public key file for service accounts on the apiserver.", "RationaleStatement": "By default, if no `--service-account-key-file` is specified to the apiserver, it uses the private key from the TLS serving certificate to verify service account tokens. To ensure that the keys for service account tokens could be rotated as needed, a separate public/private key pair should be used for signing service account tokens. Hence, the public key should be specified to the apiserver with `--service-account-key-file`.", @@ -961,7 +961,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "etcd should be configured to make use of TLS encryption for client connections.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be protected by client authentication. This requires the API server to identify itself to the etcd server using a client certificate and key.", @@ -984,7 +984,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Setup TLS connection on the API server.", "RationaleStatement": "API server communication contains sensitive parameters that should remain encrypted in transit. Configure the API server to serve only HTTPS traffic.", @@ -1007,7 +1007,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Setup TLS connection on the API server.", "RationaleStatement": "API server communication contains sensitive parameters that should remain encrypted in transit. Configure the API server to serve only HTTPS traffic. If `--client-ca-file` argument is set, any request presenting a client certificate signed by one of the authorities in the `client-ca-file` is authenticated with an identity corresponding to the CommonName of the client certificate.", @@ -1030,7 +1030,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "etcd should be configured to make use of TLS encryption for client connections.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be protected by client authentication. This requires the API server to identify itself to the etcd server using a SSL Certificate Authority file.", @@ -1053,7 +1053,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Encrypt etcd key-value store.", "RationaleStatement": "etcd is a highly available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be encrypted at rest to avoid any disclosures.", @@ -1074,7 +1074,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Where `etcd` encryption is used, appropriate providers should be configured.", "RationaleStatement": "Where `etcd` encryption is used, it is important to ensure that the appropriate set of encryption providers is used. Currently, the `aescbc`, `kms`, and `secretbox` are likely to be appropriate options.", @@ -1097,7 +1097,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the API server is configured to only use strong cryptographic ciphers.", "RationaleStatement": "TLS ciphers have had a number of known vulnerabilities and weaknesses, which can reduce the protection provided by them. By default Kubernetes supports a number of TLS cipher suites including some that have security concerns, weakening the protection provided.", @@ -1118,7 +1118,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "By default Kubernetes extends service account token lifetimes to one year to aid in transition from the legacy token settings.", "RationaleStatement": "This default setting is not ideal for security as it ignores other settings related to maximum token lifetime and means that a lost or stolen credential could be valid for an extended period of time.", @@ -1141,7 +1141,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Activate garbage collector on pod termination, as appropriate.", "RationaleStatement": "Garbage collection is important to ensure sufficient resource availability and avoiding degraded performance and availability. In the worst case, the system might crash or just be unusable for a long period of time. The current setting for garbage collection is 12,500 terminated pods which might be too high for your system to sustain. Based on your system resources and tests, choose an appropriate threshold value to activate garbage collection.", @@ -1164,7 +1164,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Disable profiling, if not needed.", "RationaleStatement": "Profiling allows for the identification of specific performance bottlenecks. It generates a significant amount of program data that could potentially be exploited to uncover system and program details. If you are not experiencing any bottlenecks and do not need the profiler for troubleshooting purposes, it is recommended to turn it off to reduce the potential attack surface.", @@ -1187,7 +1187,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Use individual service account credentials for each controller.", "RationaleStatement": "The controller manager creates a service account per controller in the `kube-system` namespace, generates a credential for it, and builds a dedicated API client with that service account credential for each controller loop to use. Setting the `--use-service-account-credentials` to `true` runs each control loop within the controller manager using a separate service account credential. When used in combination with RBAC, this ensures that the control loops run with the minimum permissions required to perform their intended tasks.", @@ -1210,7 +1210,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Explicitly set a service account private key file for service accounts on the controller manager.", "RationaleStatement": "To ensure that keys for service account tokens can be rotated as needed, a separate public/private key pair should be used for signing service account tokens. The private key should be specified to the controller manager with `--service-account-private-key-file` as appropriate.", @@ -1233,7 +1233,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Allow pods to verify the API server's serving certificate before establishing connections.", "RationaleStatement": "Processes running within pods that need to contact the API server must verify the API server's serving certificate. Failing to do so could be a subject to man-in-the-middle attacks. Providing the root certificate for the API server's serving certificate to the controller manager with the `--root-ca-file` argument allows the controller manager to inject the trusted bundle into pods so that they can verify TLS connections to the API server.", @@ -1256,7 +1256,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable kubelet server certificate rotation on controller-manager.", "RationaleStatement": "`RotateKubeletServerCertificate` causes the kubelet to both request a serving certificate after bootstrapping its client credentials and rotate the certificate as its existing credentials expire. This automated periodic rotation ensures that the there are no downtimes due to expired certificates and thus addressing availability in the CIA security triad. Note: This recommendation only applies if you let kubelets get their certificates from the API server. In case your kubelet certificates come from an outside authority/tool (e.g. Vault) then you need to take care of rotation yourself.", @@ -1279,7 +1279,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not bind the Controller Manager service to non-loopback insecure addresses.", "RationaleStatement": "The Controller Manager API service which runs on port 10252/TCP by default is used for health and metrics information and is available without authentication or encryption. As such it should only be bound to a localhost interface, to minimize the cluster's attack surface", @@ -1302,7 +1302,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.4 Scheduler", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Disable profiling, if not needed.", "RationaleStatement": "Profiling allows for the identification of specific performance bottlenecks. It generates a significant amount of program data that could potentially be exploited to uncover system and program details. If you are not experiencing any bottlenecks and do not need the profiler for troubleshooting purposes, it is recommended to turn it off to reduce the potential attack surface.", @@ -1325,7 +1325,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.4 Scheduler", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not bind the scheduler service to non-loopback insecure addresses.", "RationaleStatement": "The Scheduler API service which runs on port 10251/TCP by default is used for health and metrics information and is available without authentication or encryption. As such it should only be bound to a localhost interface, to minimize the cluster's attack surface", @@ -1347,7 +1347,7 @@ "Attributes": [ { "Section": "2 etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Configure TLS encryption for the etcd service.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be encrypted in transit.", @@ -1369,7 +1369,7 @@ "Attributes": [ { "Section": "2 etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Configure TLS encryption for the etcd service.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be encrypted in transit.", @@ -1391,7 +1391,7 @@ "Attributes": [ { "Section": "2 etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable client authentication on etcd service.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should not be available to unauthenticated clients. You should enable the client authentication via valid certificates to secure the access to the etcd service.", @@ -1413,7 +1413,7 @@ "Attributes": [ { "Section": "2 etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not use self-signed certificates for TLS.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should not be available to unauthenticated clients. You should enable the client authentication via valid certificates to secure the access to the etcd service.", @@ -1435,7 +1435,7 @@ "Attributes": [ { "Section": "2 etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "etcd should be configured to make use of TLS encryption for peer connections.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be encrypted in transit and also amongst peers in the etcd clusters.", @@ -1457,7 +1457,7 @@ "Attributes": [ { "Section": "2 etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "etcd should be configured for peer authentication.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be accessible only by authenticated etcd peers in the etcd cluster.", @@ -1479,7 +1479,7 @@ "Attributes": [ { "Section": "2 etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not use automatically generated self-signed certificates for TLS connections between peers.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be accessible only by authenticated etcd peers in the etcd cluster. Hence, do not use self-signed certificates for authentication.", @@ -1501,7 +1501,7 @@ "Attributes": [ { "Section": "2 etcd", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Use a different certificate authority for etcd from the one used for Kubernetes.", "RationaleStatement": "etcd is a highly available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. Its access should be restricted to specifically designated clients and peers only. Authentication to etcd is based on whether the certificate presented was issued by a trusted certificate authority. There is no checking of certificate attributes such as common name or subject alternative name. As such, if any attackers were able to gain access to any certificate issued by the trusted certificate authority, they would be able to gain full access to the etcd database.", @@ -1522,7 +1522,7 @@ { "Section": "3 Control Plane Configuration", "SubSection": "3.1 Authentication and Authorization", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Kubernetes provides the option to use client certificates for user authentication. However as there is no way to revoke these certificates when a user leaves an organization or loses their credential, they are not suitable for this purpose. It is not possible to fully disable client certificate use within a cluster as it is used for component to component authentication.", "RationaleStatement": "With any authentication mechanism the ability to revoke credentials if they are compromised or no longer required, is a key control. Kubernetes client certificate authentication does not allow for this due to a lack of support for certificate revocation.", @@ -1543,7 +1543,7 @@ { "Section": "3 Control Plane Configuration", "SubSection": "3.1 Authentication and Authorization", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Kubernetes provides service account tokens which are intended for use by workloads running in the Kubernetes cluster, for authentication to the API server. These tokens are not designed for use by end-users and do not provide for features such as revocation or expiry, making them insecure. A newer version of the feature (Bound service account token volumes) does introduce expiry but still does not allow for specific revocation.", "RationaleStatement": "With any authentication mechanism the ability to revoke credentials if they are compromised or no longer required, is a key control. Service account token authentication does not allow for this due to the use of JWT tokens as an underlying technology.", @@ -1564,7 +1564,7 @@ { "Section": "3 Control Plane Configuration", "SubSection": "3.1 Authentication and Authorization", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Kubernetes provides bootstrap tokens which are intended for use by new nodes joining the cluster These tokens are not designed for use by end-users they are specifically designed for the purpose of bootstrapping new nodes and not for general authentication", "RationaleStatement": "Bootstrap tokens are not intended for use as a general authentication mechanism and impose constraints on user and group naming that do not facilitate good RBAC design. They also cannot be used with MFA resulting in a weak authentication mechanism being available.", @@ -1585,7 +1585,7 @@ { "Section": "3 Control Plane Configuration", "SubSection": "3.2 Logging", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Kubernetes can audit the details of requests made to the API server. The `--audit-policy-file` flag must be set for this logging to be enabled.", "RationaleStatement": "Logging is an important detective control for all systems, to detect potential unauthorised access.", @@ -1606,7 +1606,7 @@ { "Section": "3 Control Plane Configuration", "SubSection": "3.2 Logging", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Ensure that the audit policy created for the cluster covers key security concerns.", "RationaleStatement": "Security audit logs should cover access and modification of key resources in the cluster, to enable them to form an effective part of a security environment.", @@ -1629,7 +1629,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `kubelet` service file has permissions of `600` or more restrictive.", "RationaleStatement": "The `kubelet` service file controls various parameters that set the behavior of the `kubelet` service in the worker node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -1652,7 +1652,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `kubelet` service file ownership is set to `root:root`.", "RationaleStatement": "The `kubelet` service file controls various parameters that set the behavior of the `kubelet` service in the worker node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -1673,7 +1673,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "If `kube-proxy` is running, and if it is using a file-based kubeconfig file, ensure that the proxy kubeconfig file has permissions of `600` or more restrictive.", "RationaleStatement": "The `kube-proxy` kubeconfig file controls various parameters of the `kube-proxy` service in the worker node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system. It is possible to run `kube-proxy` with the kubeconfig parameters configured as a Kubernetes ConfigMap instead of a file. In this case, there is no proxy kubeconfig file.", @@ -1694,7 +1694,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "If `kube-proxy` is running, ensure that the file ownership of its kubeconfig file is set to `root:root`.", "RationaleStatement": "The kubeconfig file for `kube-proxy` controls various parameters for the `kube-proxy` service in the worker node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -1717,7 +1717,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `kubelet.conf` file has permissions of `600` or more restrictive.", "RationaleStatement": "The `kubelet.conf` file is the kubeconfig file for the node, and controls various parameters that set the behavior and identity of the worker node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -1740,7 +1740,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `kubelet.conf` file ownership is set to `root:root`.", "RationaleStatement": "The `kubelet.conf` file is the kubeconfig file for the node, and controls various parameters that set the behavior and identity of the worker node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -1761,7 +1761,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the certificate authorities file has permissions of `644` or more restrictive.", "RationaleStatement": "The certificate authorities file controls the authorities used to validate API requests. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -1782,7 +1782,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the certificate authorities file ownership is set to `root:root`.", "RationaleStatement": "The certificate authorities file controls the authorities used to validate API requests. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -1805,7 +1805,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that if the kubelet refers to a configuration file with the `--config` argument, that file has permissions of 600 or more restrictive.", "RationaleStatement": "The kubelet reads various parameters, including security settings, from a config file specified by the `--config` argument. If this file is specified you should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -1828,7 +1828,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that if the kubelet refers to a configuration file with the `--config` argument, that file is owned by root:root.", "RationaleStatement": "The kubelet reads various parameters, including security settings, from a config file specified by the `--config` argument. If this file is specified you should restrict its file permissions to maintain the integrity of the file. The file should be owned by root:root.", @@ -1851,7 +1851,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Disable anonymous requests to the Kubelet server.", "RationaleStatement": "When enabled, requests that are not rejected by other configured authentication methods are treated as anonymous requests. These requests are then served by the Kubelet server. You should rely on authentication to authorize access and disallow anonymous requests.", @@ -1874,7 +1874,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not allow all requests. Enable explicit authorization.", "RationaleStatement": "Kubelets, by default, allow all authenticated requests (even anonymous ones) without needing explicit authorization checks from the apiserver. You should restrict this behavior and only allow explicitly authorized requests.", @@ -1897,7 +1897,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable Kubelet authentication using certificates.", "RationaleStatement": "The connections from the apiserver to the kubelet are used for fetching logs for pods, attaching (through kubectl) to running pods, and using the kubelet’s port-forwarding functionality. These connections terminate at the kubelet’s HTTPS endpoint. By default, the apiserver does not verify the kubelet’s serving certificate, which makes the connection subject to man-in-the-middle attacks, and unsafe to run over untrusted and/or public networks. Enabling Kubelet certificate authentication ensures that the apiserver could authenticate the Kubelet before submitting any requests.", @@ -1920,7 +1920,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Disable the read-only port.", "RationaleStatement": "The Kubelet process provides a read-only API in addition to the main Kubelet API. Unauthenticated access is provided to this read-only API which could possibly retrieve potentially sensitive information about the cluster.", @@ -1943,7 +1943,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not disable timeouts on streaming connections.", "RationaleStatement": "Setting idle timeouts ensures that you are protected against Denial-of-Service attacks, inactive connections and running out of ephemeral ports. **Note:** By default, `--streaming-connection-idle-timeout` is set to 4 hours which might be too high for your environment. Setting this as appropriate would additionally ensure that such streaming connections are timed out after serving legitimate use cases.", @@ -1966,7 +1966,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Allow Kubelet to manage iptables.", "RationaleStatement": "Kubelets can automatically manage the required changes to iptables based on how you choose your networking options for the pods. It is recommended to let kubelets manage the changes to iptables. This ensures that the iptables configuration remains in sync with pods networking configuration. Manually configuring iptables with dynamic pod network configuration changes might hamper the communication between pods/containers and to the outside world. You might have iptables rules too restrictive or too open.", @@ -1987,7 +1987,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not override node hostnames.", "RationaleStatement": "Overriding hostnames could potentially break TLS setup between the kubelet and the apiserver. Additionally, with overridden hostnames, it becomes increasingly difficult to associate logs with a particular node and process them for security analytics. Hence, you should setup your kubelet nodes with resolvable FQDNs and avoid overriding the hostnames with IPs.", @@ -2010,7 +2010,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 2 - Worker Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Security relevant information should be captured. The eventRecordQPS on the Kubelet configuration can be used to limit the rate at which events are gathered and sets the maximum event creations per second. Setting this too low could result in relevant events not being logged, however the unlimited setting of `0` could result in a denial of service on the kubelet.", "RationaleStatement": "It is important to capture all events and not restrict event creation. Events are an important source of security information and analytics that ensure that your environment is consistently monitored using the event data.", @@ -2033,7 +2033,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Setup TLS connection on the Kubelets.", "RationaleStatement": "The connections from the apiserver to the kubelet are used for fetching logs for pods, attaching (through kubectl) to running pods, and using the kubelet’s port-forwarding functionality. These connections terminate at the kubelet’s HTTPS endpoint. By default, the apiserver does not verify the kubelet’s serving certificate, which makes the connection subject to man-in-the-middle attacks, and unsafe to run over untrusted and/or public networks.", @@ -2056,7 +2056,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable kubelet client certificate rotation.", "RationaleStatement": "The `--rotate-certificates` setting causes the kubelet to rotate its client certificates by creating new CSRs as its existing credentials expire. This automated periodic rotation ensures that the there is no downtime due to expired certificates and thus addressing availability in the CIA security triad. **Note:** This recommendation only applies if you let kubelets get their certificates from the API server. In case your kubelet certificates come from an outside authority/tool (e.g. Vault) then you need to take care of rotation yourself. **Note:** This feature also require the `RotateKubeletClientCertificate` feature gate to be enabled (which is the default since Kubernetes v1.7)", @@ -2077,7 +2077,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Enable kubelet server certificate rotation.", "RationaleStatement": "`RotateKubeletServerCertificate` causes the kubelet to both request a serving certificate after bootstrapping its client credentials and rotate the certificate as its existing credentials expire. This automated periodic rotation ensures that the there are no downtimes due to expired certificates and thus addressing availability in the CIA security triad. Note: This recommendation only applies if you let kubelets get their certificates from the API server. In case your kubelet certificates come from an outside authority/tool (e.g. Vault) then you need to take care of rotation yourself.", @@ -2100,7 +2100,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the Kubelet is configured to only use strong cryptographic ciphers.", "RationaleStatement": "TLS ciphers have had a number of known vulnerabilities and weaknesses, which can reduce the protection provided by them. By default Kubernetes supports a number of TLS ciphersuites including some that have security concerns, weakening the protection provided.", @@ -2121,7 +2121,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the Kubelet sets limits on the number of PIDs that can be created by pods running on the node.", "RationaleStatement": "By default pods running in a cluster can consume any number of PIDs, potentially exhausting the resources available on the node. Setting an appropriate limit reduces the risk of a denial of service attack on cluster nodes.", @@ -2142,7 +2142,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the Kubelet enforces the use of the RuntimeDefault seccomp profile", "RationaleStatement": "By default, Kubernetes disables the seccomp profile which ships with most container runtimes. Setting this parameter will ensure workloads running on the node are protected by the runtime's seccomp profile.", @@ -2163,7 +2163,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 2 - Worker Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Ensuring that `--IPAddressDeny` is set to Any will facilitate allowlisting of only IP addresses that are explicitly set with the `--IPAddressAllow` parameter which will block unspecified IP addresses from communicating with the **kubelet** component.", "RationaleStatement": "By default, Kubernetes allows any IP address to communicate with the **kubelet** component IP restrictions and IP whitelisting are security best practices and reduce the attack surface of the **kubelet**.", @@ -2184,7 +2184,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.3 kube-proxy", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not bind the kube-proxy metrics port to non-loopback addresses.", "RationaleStatement": "kube-proxy has two APIs which provided access to information about the service and can be bound to network ports. The metrics API service includes endpoints (`/metrics` and `/configz`) which disclose information about the configuration and operation of kube-proxy. These endpoints should not be exposed to untrusted networks as they do not support encryption or authentication to restrict access to the data they provide.", @@ -2207,7 +2207,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "The RBAC role `cluster-admin` provides wide-ranging powers over the environment and should be used only where and when needed.", "RationaleStatement": "Kubernetes provides a set of default roles where RBAC is used. Some of these roles such as `cluster-admin` provide wide-ranging privileges which should only be applied where absolutely necessary. Roles such as `cluster-admin` allow super-user access to perform any action on any resource. When used in a `ClusterRoleBinding`, it gives full control over every resource in the cluster and in all namespaces. When used in a `RoleBinding`, it gives full control over every resource in the rolebinding's namespace, including the namespace itself.", @@ -2230,7 +2230,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "The Kubernetes API stores secrets, which may be service account tokens for the Kubernetes API or credentials used by workloads in the cluster. Access to these secrets should be restricted to the smallest possible group of users to reduce the risk of privilege escalation.", "RationaleStatement": "Inappropriate access to secrets stored within the Kubernetes cluster can allow for an attacker to gain additional access to the Kubernetes cluster or external resources whose credentials are stored as secrets.", @@ -2253,7 +2253,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Kubernetes Roles and ClusterRoles provide access to resources based on sets of objects and actions that can be taken on those objects. It is possible to set either of these to be the wildcard * which matches all items. Use of wildcards is not optimal from a security perspective as it may allow for inadvertent access to be granted when new resources are added to the Kubernetes API either as CRDs or in later versions of the product.", "RationaleStatement": "The principle of least privilege recommends that users are provided only the access required for their role and nothing more. The use of wildcard rights grants is likely to provide excessive rights to the Kubernetes API.", @@ -2276,7 +2276,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "The ability to create pods in a namespace can provide a number of opportunities for privilege escalation, such as assigning privileged service accounts to these pods or mounting hostPaths with access to sensitive data (unless Pod Security Policies are implemented to restrict this access) As such, access to create new pods should be restricted to the smallest possible group of users.", "RationaleStatement": "The ability to create pods in a cluster opens up possibilities for privilege escalation and should be restricted, where possible.", @@ -2297,7 +2297,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "The `default` service account should not be used to ensure that rights granted to applications can be more easily audited and reviewed.", "RationaleStatement": "Kubernetes provides a default service account which is used by cluster workloads where no specific service account is assigned to the pod. Where access to the Kubernetes API from a pod is required, a specific service account should be created for that pod, and rights granted to that service account. The default service account should be configured to ensure that it does not automatically provide a service account token, and it must not have any non-default role bindings or custom role assignments", @@ -2318,7 +2318,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Service accounts tokens should not be mounted in pods except where the workload running in the pod explicitly needs to communicate with the API server", "RationaleStatement": "Mounting service account tokens inside pods can provide an avenue for privilege escalation attacks where an attacker is able to compromise a single pod in the cluster. Avoiding mounting these tokens removes this attack avenue.", @@ -2339,7 +2339,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "The special group `system:masters` should not be used to grant permissions to any user or service account, except where strictly necessary (e.g. bootstrapping access prior to RBAC being fully available)", "RationaleStatement": "The `system:masters` group has unrestricted access to the Kubernetes API hard-coded into the API server source code. An authenticated user who is a member of this group cannot have their access reduced, even if all bindings and cluster role bindings which mention it, are removed. When combined with client certificate authentication, use of this group can allow for irrevocable cluster-admin level credentials to exist for a cluster.", @@ -2360,7 +2360,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Cluster roles and roles with the impersonate, bind or escalate permissions should not be granted unless strictly required. Each of these permissions allow a particular subject to escalate their privileges beyond those explicitly granted by cluster administrators", "RationaleStatement": "The impersonate privilege allows a subject to impersonate other users gaining their rights to the cluster. The bind privilege allows the subject to add a binding to a cluster role or role which escalates their effective permissions in the cluster. The escalate privilege allows a subject to modify cluster roles to which they are bound, increasing their rights to that level. Each of these permissions has the potential to allow for privilege escalation to cluster-admin level.", @@ -2383,7 +2383,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "The ability to create persistent volumes in a cluster can provide an opportunity for privilege escalation, via the creation of `hostPath` volumes. As persistent volumes are not covered by Pod Security Admission, a user with access to create persistent volumes may be able to get access to sensitive files from the underlying host even where restrictive Pod Security Admission policies are in place.", "RationaleStatement": "The ability to create persistent volumes in a cluster opens up possibilities for privilege escalation and should be restricted, where possible.", @@ -2406,7 +2406,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Users with access to the `Proxy` sub-resource of `Node` objects automatically have permissions to use the kubelet API, which may allow for privilege escalation or bypass cluster security controls such as audit logs. The kubelet provides an API which includes rights to execute commands in any container running on the node. Access to this API is covered by permissions to the main Kubernetes API via the `node` object. The proxy sub-resource specifically allows wide ranging access to the kubelet API. Direct access to the kubelet API bypasses controls like audit logging (there is no audit log of kubelet API access) and admission control.", "RationaleStatement": "The ability to use the `proxy` sub-resource of `node` objects opens up possibilities for privilege escalation and should be restricted, where possible.", @@ -2429,7 +2429,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Users with access to the update the `approval` sub-resource of `CertificateSigningRequests` objects can approve new client certificates for the Kubernetes API effectively allowing them to create new high-privileged user accounts. This can allow for privilege escalation to full cluster administrator, depending on users configured in the cluster", "RationaleStatement": "The ability to update certificate signing requests should be limited.", @@ -2452,7 +2452,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Users with rights to create/modify/delete `validatingwebhookconfigurations` or `mutatingwebhookconfigurations` can control webhooks that can read any object admitted to the cluster, and in the case of mutating webhooks, also mutate admitted objects. This could allow for privilege escalation or disruption of the operation of the cluster.", "RationaleStatement": "The ability to manage webhook configuration should be limited", @@ -2475,7 +2475,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Users with rights to create new service account tokens at a cluster level, can create long-lived privileged credentials in the cluster. This could allow for privilege escalation and persistent access to the cluster, even if the users account has been revoked.", "RationaleStatement": "The ability to create service account tokens should be limited.", @@ -2496,7 +2496,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Every Kubernetes cluster should have at least one policy control mechanism in place to enforce the other requirements in this section. This could be the in-built Pod Security Admission controller, or a third party policy control system.", "RationaleStatement": "Without an active policy control mechanism, it is not possible to limit the use of containers with access to underlying cluster nodes, via mechanisms like privileged containers, or the use of hostPath volume mounts.", @@ -2519,7 +2519,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers to be run with the `securityContext.privileged` flag set to `true`.", "RationaleStatement": "Privileged containers have access to all Linux Kernel capabilities and devices. A container running with full privileges can do almost everything that the host can do. This flag exists to allow special use-cases, like manipulating the network stack and accessing devices. There should be at least one admission control policy defined which does not permit privileged containers. If you need to run privileged containers, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2542,7 +2542,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers to be run with the `hostPID` flag set to true.", "RationaleStatement": "A container running in the host's PID namespace can inspect processes running outside the container. If the container also has access to ptrace capabilities this can be used to escalate privileges outside of the container. There should be at least one admission control policy defined which does not permit containers to share the host PID namespace. If you need to run containers which require hostPID, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2565,7 +2565,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers to be run with the `hostIPC` flag set to true.", "RationaleStatement": "A container running in the host's IPC namespace can use IPC to interact with processes outside the container. There should be at least one admission control policy defined which does not permit containers to share the host IPC namespace. If you need to run containers which require hostIPC, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2588,7 +2588,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers to be run with the `hostNetwork` flag set to true.", "RationaleStatement": "A container running in the host's network namespace could access the local loopback device, and could access network traffic to and from other pods. There should be at least one admission control policy defined which does not permit containers to share the host network namespace. If you need to run containers which require access to the host's network namespaces, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2611,7 +2611,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers to be run with the `allowPrivilegeEscalation` flag set to true. Allowing this right can lead to a process running a container getting more rights than it started with. It's important to note that these rights are still constrained by the overall container sandbox, and this setting does not relate to the use of privileged containers.", "RationaleStatement": "A container running with the `allowPrivilegeEscalation` flag set to `true` may have processes that can gain more privileges than their parent. There should be at least one admission control policy defined which does not permit containers to allow privilege escalation. The option exists (and is defaulted to true) to permit setuid binaries to run. If you have need to run containers which use setuid binaries or require privilege escalation, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2634,7 +2634,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers to be run as the root user.", "RationaleStatement": "Containers may run as any Linux user. Containers which run as the root user, whilst constrained by Container Runtime security features still have a escalated likelihood of container breakout. Ideally, all containers should run as a defined non-UID 0 user. There should be at least one admission control policy defined which does not permit root containers. If you need to run root containers, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2657,7 +2657,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers with the potentially dangerous NET_RAW capability.", "RationaleStatement": "Containers run with a default set of capabilities as assigned by the Container Runtime. By default this can include potentially dangerous capabilities. With Docker as the container runtime the NET_RAW capability is enabled which may be misused by malicious containers. Ideally, all containers should drop this capability. There should be at least one admission control policy defined which does not permit containers with the NET_RAW capability. If you need to run containers with this capability, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2680,7 +2680,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers with capabilities assigned beyond the default set.", "RationaleStatement": "Containers run with a default set of capabilities as assigned by the Container Runtime. Capabilities outside this set can be added to containers which could expose them to risks of container breakout attacks. There should be at least one policy defined which prevents containers with capabilities beyond the default set from launching. If you need to run containers with additional capabilities, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2703,7 +2703,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers with capabilities", "RationaleStatement": "Containers run with a default set of capabilities as assigned by the Container Runtime. Capabilities are parts of the rights generally granted on a Linux system to the root user. In many cases applications running in containers do not require any capabilities to operate, so from the perspective of the principal of least privilege use of capabilities should be minimized.", @@ -2726,7 +2726,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit Windows containers to be run with the `hostProcess` flag set to true.", "RationaleStatement": "A Windows container making use of the `hostProcess` flag can interact with the underlying Windows cluster node. As per the Kubernetes documentation, this provides privileged access to the Windows node. Where Windows containers are used inside a Kubernetes cluster, there should be at least one admission control policy which does not permit `hostProcess` Windows containers. If you need to run Windows containers which require `hostProcess`, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2747,7 +2747,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally admit containers which make use of `hostPath` volumes.", "RationaleStatement": "A container which mounts a `hostPath` volume as part of its specification will have access to the filesystem of the underlying cluster node. The use of `hostPath` volumes may allow containers access to privileged areas of the node filesystem. There should be at least one admission control policy defined which does not permit containers to mount `hostPath` volumes. If you need to run containers which require `hostPath` volumes, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2770,7 +2770,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers which require the use of HostPorts.", "RationaleStatement": "Host ports connect containers directly to the host's network. This can bypass controls such as network policy. There should be at least one admission control policy defined which does not permit containers which require the use of HostPorts. If you need to run containers which require HostPorts, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2791,7 +2791,7 @@ { "Section": "5 Policies", "SubSection": "5.3 Network Policies and CNI", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "There are a variety of CNI plugins available for Kubernetes. If the CNI in use does not support Network Policies it may not be possible to effectively restrict traffic in the cluster.", "RationaleStatement": "Kubernetes network policies are enforced by the CNI plugin in use. As such it is important to ensure that the CNI plugin supports both Ingress and Egress network policies.", @@ -2812,7 +2812,7 @@ { "Section": "5 Policies", "SubSection": "5.3 Network Policies and CNI", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Use network policies to isolate traffic in your cluster network.", "RationaleStatement": "Running different applications on the same Kubernetes cluster creates a risk of one compromised application attacking a neighboring application. Network segmentation is important to ensure that containers can communicate only with those they are supposed to. A network policy is a specification of how selections of pods are allowed to communicate with each other and other network endpoints. Network Policies are namespace scoped. When a network policy is introduced to a given namespace, all traffic not allowed by the policy is denied. However, if there are no network policies in a namespace all traffic will be allowed into and out of the pods in that namespace.", @@ -2835,7 +2835,7 @@ { "Section": "5 Policies", "SubSection": "5.4 Secrets Management", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Kubernetes supports mounting secrets as data volumes or as environment variables. Minimize the use of environment variable secrets.", "RationaleStatement": "It is reasonably common for application code to log out its environment (particularly in the event of an error). This will include any secret values passed in as environment variables, so secrets can easily be exposed to any user or entity who has access to the logs.", @@ -2856,7 +2856,7 @@ { "Section": "5 Policies", "SubSection": "5.4 Secrets Management", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Consider the use of an external secrets storage and management system, instead of using Kubernetes Secrets directly, if you have more complex secret management needs. Ensure the solution requires authentication to access secrets, has auditing of access to and use of secrets, and encrypts secrets. Some solutions also make it easier to rotate secrets.", "RationaleStatement": "Kubernetes supports secrets as first-class objects, but care needs to be taken to ensure that access to secrets is carefully limited. Using an external secrets provider can ease the management of access to secrets, especially where secrests are used across both Kubernetes and non-Kubernetes environments.", @@ -2877,7 +2877,7 @@ { "Section": "5 Policies", "SubSection": "5.5 Extensible Admission Control", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Configure Image Provenance for your deployment.", "RationaleStatement": "Kubernetes supports plugging in provenance rules to accept or reject the images in your deployments. You could configure such rules to ensure that only approved images are deployed in the cluster.", @@ -2898,7 +2898,7 @@ { "Section": "5 Policies", "SubSection": "5.6 General Policies", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Use namespaces to isolate your Kubernetes objects.", "RationaleStatement": "Limiting the scope of user permissions can reduce the impact of mistakes or malicious activities. A Kubernetes namespace allows you to partition created resources into logically named groups. Resources created in one namespace can be hidden from other namespaces. By default, each resource created by a user in Kubernetes cluster runs in a default namespace, called `default`. You can create additional namespaces and attach resources and users to them. You can use Kubernetes Authorization plugins to create policies that segregate access to namespace resources between different users.", @@ -2921,7 +2921,7 @@ { "Section": "5 Policies", "SubSection": "5.6 General Policies", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Enable `docker/default` seccomp profile in your pod definitions.", "RationaleStatement": "Seccomp (secure computing mode) is used to restrict the set of system calls applications can make, allowing cluster administrators greater control over the security of workloads running in the cluster. Kubernetes disables seccomp profiles by default for historical reasons. You should enable it to ensure that the workloads have restricted actions available within the container.", @@ -2942,7 +2942,7 @@ { "Section": "5 Policies", "SubSection": "5.6 General Policies", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Apply Security Context to Your Pods and Containers", "RationaleStatement": "A security context defines the operating system security settings (uid, gid, capabilities, SELinux role, etc..) applied to a container. When designing your containers and pods, make sure that you configure the security context for your pods, containers, and volumes. A security context is a property defined in the deployment yaml. It controls the security parameters that will be assigned to the pod/container/volume. There are two levels of security context: pod level security context, and container level security context.", @@ -2963,7 +2963,7 @@ { "Section": "5 Policies", "SubSection": "5.6 General Policies", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Kubernetes provides a default namespace, where objects are placed if no namespace is specified for them. Placing objects in this namespace makes application of RBAC and other controls more difficult.", "RationaleStatement": "Resources in a Kubernetes cluster should be segregated by namespace, to allow for security controls to be applied at that level and to make it easier to manage resources.", diff --git a/prowler/compliance/kubernetes/cis_1.8_kubernetes.json b/prowler/compliance/kubernetes/cis_1.8_kubernetes.json index 88fcfd59b2..280375fc31 100644 --- a/prowler/compliance/kubernetes/cis_1.8_kubernetes.json +++ b/prowler/compliance/kubernetes/cis_1.8_kubernetes.json @@ -12,7 +12,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the API server pod specification file has permissions of `600` or more restrictive.", "RationaleStatement": "The API server pod specification file controls various parameters that set the behavior of the API server. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -33,7 +33,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the API server pod specification file ownership is set to `root:root`.", "RationaleStatement": "The API server pod specification file controls various parameters that set the behavior of the API server. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -54,7 +54,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.", "RationaleStatement": "The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -75,7 +75,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the controller manager pod specification file ownership is set to `root:root`.", "RationaleStatement": "The controller manager pod specification file controls various parameters that set the behavior of various components of the master node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -96,7 +96,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the scheduler pod specification file has permissions of `600` or more restrictive.", "RationaleStatement": "The scheduler pod specification file controls various parameters that set the behavior of the Scheduler service in the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -117,7 +117,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the scheduler pod specification file ownership is set to `root:root`.", "RationaleStatement": "The scheduler pod specification file controls various parameters that set the behavior of the `kube-scheduler` service in the master node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -138,7 +138,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `/etc/kubernetes/manifests/etcd.yaml` file has permissions of `600` or more restrictive.", "RationaleStatement": "The etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` controls various parameters that set the behavior of the `etcd` service in the master node. etcd is a highly-available key-value store which Kubernetes uses for persistent storage of all of its REST API object. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -159,7 +159,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `/etc/kubernetes/manifests/etcd.yaml` file ownership is set to `root:root`.", "RationaleStatement": "The etcd pod specification file `/etc/kubernetes/manifests/etcd.yaml` controls various parameters that set the behavior of the `etcd` service in the master node. etcd is a highly-available key-value store which Kubernetes uses for persistent storage of all of its REST API object. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -180,7 +180,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the Container Network Interface files have permissions of `600` or more restrictive.", "RationaleStatement": "Container Network Interface provides various networking options for overlay networking. You should consult their documentation and restrict their respective file permissions to maintain the integrity of those files. Those files should be writable by only the administrators on the system.", @@ -201,7 +201,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the Container Network Interface files have ownership set to `root:root`.", "RationaleStatement": "Container Network Interface provides various networking options for overlay networking. You should consult their documentation and restrict their respective file permissions to maintain the integrity of those files. Those files should be owned by `root:root`.", @@ -222,7 +222,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the etcd data directory has permissions of `700` or more restrictive.", "RationaleStatement": "etcd is a highly-available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. This data directory should be protected from any unauthorized reads or writes. It should not be readable or writable by any group members or the world.", @@ -243,7 +243,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the etcd data directory ownership is set to `etcd:etcd`.", "RationaleStatement": "etcd is a highly-available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. This data directory should be protected from any unauthorized reads or writes. It should be owned by `etcd:etcd`.", @@ -264,7 +264,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `admin.conf` file has permissions of `600`.", "RationaleStatement": "The `admin.conf` is the administrator kubeconfig file defining various settings for the administration of the cluster. This file contains private key and respective certificate allowed to fully manage the cluster. You should restrict its file permissions to maintain the integrity and confidentiality of the file. The file should be readable and writable by only the administrators on the system.", @@ -285,7 +285,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `admin.conf` file ownership is set to `root:root`.", "RationaleStatement": "The `admin.conf` file contains the admin credentials for the cluster. You should set its file ownership to maintain the integrity and confidentiality of the file. The file should be owned by root:root.", @@ -306,7 +306,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `scheduler.conf` file has permissions of `600` or more restrictive.", "RationaleStatement": "The `scheduler.conf` file is the kubeconfig file for the Scheduler. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -327,7 +327,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `scheduler.conf` file ownership is set to `root:root`.", "RationaleStatement": "The `scheduler.conf` file is the kubeconfig file for the Scheduler. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -348,7 +348,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `controller-manager.conf` file has permissions of 600 or more restrictive.", "RationaleStatement": "The `controller-manager.conf` file is the kubeconfig file for the Controller Manager. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -369,7 +369,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `controller-manager.conf` file ownership is set to `root:root`.", "RationaleStatement": "The `controller-manager.conf` file is the kubeconfig file for the Controller Manager. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -390,7 +390,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the Kubernetes PKI directory and file ownership is set to `root:root`.", "RationaleStatement": "Kubernetes makes use of a number of certificates as part of its operation. You should set the ownership of the directory containing the PKI information and all files in that directory to maintain their integrity. The directory and files should be owned by `root:root`.", @@ -411,7 +411,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that Kubernetes PKI certificate files have permissions of `600` or more restrictive.", "RationaleStatement": "Kubernetes makes use of a number of certificate files as part of the operation of its components. The permissions on these files should be set to `600` or more restrictive to protect their integrity.", @@ -432,7 +432,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.1 Control Plane Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that Kubernetes PKI key files have permissions of `600`.", "RationaleStatement": "Kubernetes makes use of a number of key files as part of the operation of its components. The permissions on these files should be set to `600` to protect their integrity and confidentiality.", @@ -455,7 +455,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Disable anonymous requests to the API server.", "RationaleStatement": "When enabled, requests that are not rejected by other configured authentication methods are treated as anonymous requests. These requests are then served by the API server. You should rely on authentication to authorize access and disallow anonymous requests. If you are using RBAC authorization, it is generally considered reasonable to allow anonymous access to the API Server for health checks and discovery purposes, and hence this recommendation is not scored. However, you should consider whether anonymous discovery is an acceptable risk for your purposes.", @@ -478,7 +478,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not use token based authentication.", "RationaleStatement": "The token-based authentication utilizes static tokens to authenticate requests to the apiserver. The tokens are stored in clear-text in a file on the apiserver, and cannot be revoked or rotated without restarting the apiserver. Hence, do not use static token-based authentication.", @@ -501,7 +501,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "This admission controller rejects all net-new usage of the Service field externalIPs.", "RationaleStatement": "Most users do not need the ability to set the `externalIPs` field for a `Service` at all, and cluster admins should consider disabling this functionality by enabling the `DenyServiceExternalIPs` admission controller. Clusters that do need to allow this functionality should consider using some custom policy to manage its usage.", @@ -524,7 +524,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable certificate based kubelet authentication.", "RationaleStatement": "The apiserver, by default, does not authenticate itself to the kubelet's HTTPS endpoints. The requests from the apiserver are treated anonymously. You should set up certificate-based kubelet authentication to ensure that the apiserver authenticates itself to kubelets when submitting requests.", @@ -547,7 +547,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Verify kubelet's certificate before establishing connection.", "RationaleStatement": "The connections from the apiserver to the kubelet are used for fetching logs for pods, attaching (through kubectl) to running pods, and using the kubelet’s port-forwarding functionality. These connections terminate at the kubelet’s HTTPS endpoint. By default, the apiserver does not verify the kubelet’s serving certificate, which makes the connection subject to man-in-the-middle attacks, and unsafe to run over untrusted and/or public networks.", @@ -570,7 +570,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not always authorize all requests.", "RationaleStatement": "The API Server, can be configured to allow all requests. This mode should not be used on any production cluster.", @@ -593,7 +593,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Restrict kubelet nodes to reading only objects associated with them.", "RationaleStatement": "The `Node` authorization mode only allows kubelets to read `Secret`, `ConfigMap`, `PersistentVolume`, and `PersistentVolumeClaim` objects associated with their nodes.", @@ -616,7 +616,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Turn on Role Based Access Control.", "RationaleStatement": "Role Based Access Control (RBAC) allows fine-grained control over the operations that different entities can perform on different objects in the cluster. It is recommended to use the RBAC authorization mode.", @@ -639,7 +639,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Limit the rate at which the API server accepts requests.", "RationaleStatement": "Using `EventRateLimit` admission control enforces a limit on the number of events that the API Server will accept in a given time slice. A misbehaving workload could overwhelm and DoS the API Server, making it unavailable. This particularly applies to a multi-tenant cluster, where there might be a small percentage of misbehaving tenants which could have a significant impact on the performance of the cluster overall. Hence, it is recommended to limit the rate of events that the API server will accept. Note: This is an Alpha feature in the Kubernetes 1.15 release.", @@ -662,7 +662,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not allow all requests.", "RationaleStatement": "Setting admission control plugin `AlwaysAdmit` allows all requests and do not filter any requests. The `AlwaysAdmit` admission controller was deprecated in Kubernetes v1.13. Its behavior was equivalent to turning off all admission controllers.", @@ -685,7 +685,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Always pull images.", "RationaleStatement": "Setting admission control policy to `AlwaysPullImages` forces every new pod to pull the required images every time. In a multi-tenant cluster users can be assured that their private images can only be used by those who have the credentials to pull them. Without this admission control policy, once an image has been pulled to a node, any pod from any user can use it simply by knowing the image’s name, without any authorization check against the image ownership. When this plug-in is enabled, images are always pulled prior to starting containers, which means valid credentials are required.", @@ -708,7 +708,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "The SecurityContextDeny admission controller can be used to deny pods which make use of some SecurityContext fields which could allow for privilege escalation in the cluster. This should be used where PodSecurityPolicy is not in place within the cluster.", "RationaleStatement": "SecurityContextDeny can be used to provide a layer of security for clusters which do not have PodSecurityPolicies enabled.", @@ -731,7 +731,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Automated", "Description": "Automate service accounts management.", "RationaleStatement": "When you create a pod, if you do not specify a service account, it is automatically assigned the `default` service account in the same namespace. You should create your own service account and let the API server manage its security tokens.", @@ -754,7 +754,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Automated", "Description": "Reject creating objects in a namespace that is undergoing termination.", "RationaleStatement": "Setting admission control policy to `NamespaceLifecycle` ensures that objects cannot be created in non-existent namespaces, and that namespaces undergoing termination are not used for creating the new objects. This is recommended to enforce the integrity of the namespace termination process and also for the availability of the newer objects.", @@ -777,7 +777,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Automated", "Description": "Limit the `Node` and `Pod` objects that a kubelet could modify.", "RationaleStatement": "Using the `NodeRestriction` plug-in ensures that the kubelet is restricted to the `Node` and `Pod` objects that it could modify as defined. Such kubelets will only be allowed to modify their own `Node` API object, and only modify `Pod` API objects that are bound to their node.", @@ -800,7 +800,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Disable profiling, if not needed.", "RationaleStatement": "Profiling allows for the identification of specific performance bottlenecks. It generates a significant amount of program data that could potentially be exploited to uncover system and program details. If you are not experiencing any bottlenecks and do not need the profiler for troubleshooting purposes, it is recommended to turn it off to reduce the potential attack surface.", @@ -823,7 +823,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable auditing on the Kubernetes API Server and set the desired audit log path.", "RationaleStatement": "Auditing the Kubernetes API Server provides a security-relevant chronological set of records documenting the sequence of activities that have affected system by individual users, administrators or other components of the system. Even though currently, Kubernetes provides only basic audit capabilities, it should be enabled. You can enable it by setting an appropriate audit log path.", @@ -846,7 +846,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Retain the logs for at least 30 days or as appropriate.", "RationaleStatement": "Retaining logs for at least 30 days ensures that you can go back in time and investigate or correlate any events. Set your audit log retention period to 30 days or as per your business requirements.", @@ -869,7 +869,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Retain 10 or an appropriate number of old log files.", "RationaleStatement": "Kubernetes automatically rotates the log files. Retaining old log files ensures that you would have sufficient log data available for carrying out any investigation or correlation. For example, if you have set file size of 100 MB and the number of old log files to keep as 10, you would approximate have 1 GB of log data that you could potentially use for your analysis.", @@ -892,7 +892,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Rotate log files on reaching 100 MB or as appropriate.", "RationaleStatement": "Kubernetes automatically rotates the log files. Retaining old log files ensures that you would have sufficient log data available for carrying out any investigation or correlation. If you have set file size of 100 MB and the number of old log files to keep as 10, you would approximate have 1 GB of log data that you could potentially use for your analysis.", @@ -915,7 +915,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Set global request timeout for API server requests as appropriate.", "RationaleStatement": "Setting global request timeout allows extending the API server request timeout limit to a duration appropriate to the user's connection speed. By default, it is set to 60 seconds which might be problematic on slower connections making cluster resources inaccessible once the data volume for requests exceeds what can be transmitted in 60 seconds. But, setting this timeout limit to be too large can exhaust the API server resources making it prone to Denial-of-Service attack. Hence, it is recommended to set this limit as appropriate and change the default limit of 60 seconds only if needed.", @@ -938,7 +938,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Validate service account before validating token.", "RationaleStatement": "If `--service-account-lookup` is not enabled, the apiserver only verifies that the authentication token is valid, and does not validate that the service account token mentioned in the request is actually present in etcd. This allows using a service account token even after the corresponding service account is deleted. This is an example of time of check to time of use security issue.", @@ -961,7 +961,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Explicitly set a service account public key file for service accounts on the apiserver.", "RationaleStatement": "By default, if no `--service-account-key-file` is specified to the apiserver, it uses the private key from the TLS serving certificate to verify service account tokens. To ensure that the keys for service account tokens could be rotated as needed, a separate public/private key pair should be used for signing service account tokens. Hence, the public key should be specified to the apiserver with `--service-account-key-file`.", @@ -984,7 +984,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "etcd should be configured to make use of TLS encryption for client connections.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be protected by client authentication. This requires the API server to identify itself to the etcd server using a client certificate and key.", @@ -1007,7 +1007,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Setup TLS connection on the API server.", "RationaleStatement": "API server communication contains sensitive parameters that should remain encrypted in transit. Configure the API server to serve only HTTPS traffic.", @@ -1030,7 +1030,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Setup TLS connection on the API server.", "RationaleStatement": "API server communication contains sensitive parameters that should remain encrypted in transit. Configure the API server to serve only HTTPS traffic. If `--client-ca-file` argument is set, any request presenting a client certificate signed by one of the authorities in the `client-ca-file` is authenticated with an identity corresponding to the CommonName of the client certificate.", @@ -1053,7 +1053,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "etcd should be configured to make use of TLS encryption for client connections.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be protected by client authentication. This requires the API server to identify itself to the etcd server using a SSL Certificate Authority file.", @@ -1076,7 +1076,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Encrypt etcd key-value store.", "RationaleStatement": "etcd is a highly available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be encrypted at rest to avoid any disclosures.", @@ -1097,7 +1097,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Where `etcd` encryption is used, appropriate providers should be configured.", "RationaleStatement": "Where `etcd` encryption is used, it is important to ensure that the appropriate set of encryption providers is used. Currently, the `aescbc`, `kms` and `secretbox` are likely to be appropriate options.", @@ -1120,7 +1120,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.2 API Server", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the API server is configured to only use strong cryptographic ciphers.", "RationaleStatement": "TLS ciphers have had a number of known vulnerabilities and weaknesses, which can reduce the protection provided by them. By default Kubernetes supports a number of TLS ciphersuites including some that have security concerns, weakening the protection provided.", @@ -1143,7 +1143,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Activate garbage collector on pod termination, as appropriate.", "RationaleStatement": "Garbage collection is important to ensure sufficient resource availability and avoiding degraded performance and availability. In the worst case, the system might crash or just be unusable for a long period of time. The current setting for garbage collection is 12,500 terminated pods which might be too high for your system to sustain. Based on your system resources and tests, choose an appropriate threshold value to activate garbage collection.", @@ -1166,7 +1166,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Disable profiling, if not needed.", "RationaleStatement": "Profiling allows for the identification of specific performance bottlenecks. It generates a significant amount of program data that could potentially be exploited to uncover system and program details. If you are not experiencing any bottlenecks and do not need the profiler for troubleshooting purposes, it is recommended to turn it off to reduce the potential attack surface.", @@ -1189,7 +1189,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Use individual service account credentials for each controller.", "RationaleStatement": "The controller manager creates a service account per controller in the `kube-system` namespace, generates a credential for it, and builds a dedicated API client with that service account credential for each controller loop to use. Setting the `--use-service-account-credentials` to `true` runs each control loop within the controller manager using a separate service account credential. When used in combination with RBAC, this ensures that the control loops run with the minimum permissions required to perform their intended tasks.", @@ -1212,7 +1212,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Explicitly set a service account private key file for service accounts on the controller manager.", "RationaleStatement": "To ensure that keys for service account tokens can be rotated as needed, a separate public/private key pair should be used for signing service account tokens. The private key should be specified to the controller manager with `--service-account-private-key-file` as appropriate.", @@ -1235,7 +1235,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Allow pods to verify the API server's serving certificate before establishing connections.", "RationaleStatement": "Processes running within pods that need to contact the API server must verify the API server's serving certificate. Failing to do so could be a subject to man-in-the-middle attacks. Providing the root certificate for the API server's serving certificate to the controller manager with the `--root-ca-file` argument allows the controller manager to inject the trusted bundle into pods so that they can verify TLS connections to the API server.", @@ -1258,7 +1258,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable kubelet server certificate rotation on controller-manager.", "RationaleStatement": "`RotateKubeletServerCertificate` causes the kubelet to both request a serving certificate after bootstrapping its client credentials and rotate the certificate as its existing credentials expire. This automated periodic rotation ensures that the there are no downtimes due to expired certificates and thus addressing availability in the CIA security triad. Note: This recommendation only applies if you let kubelets get their certificates from the API server. In case your kubelet certificates come from an outside authority/tool (e.g. Vault) then you need to take care of rotation yourself.", @@ -1281,7 +1281,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.3 Controller Manager", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not bind the Controller Manager service to non-loopback insecure addresses.", "RationaleStatement": "The Controller Manager API service which runs on port 10252/TCP by default is used for health and metrics information and is available without authentication or encryption. As such it should only be bound to a localhost interface, to minimize the cluster's attack surface", @@ -1304,7 +1304,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.4 Scheduler", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Disable profiling, if not needed.", "RationaleStatement": "Profiling allows for the identification of specific performance bottlenecks. It generates a significant amount of program data that could potentially be exploited to uncover system and program details. If you are not experiencing any bottlenecks and do not need the profiler for troubleshooting purposes, it is recommended to turn it off to reduce the potential attack surface.", @@ -1327,7 +1327,7 @@ { "Section": "1 Control Plane Components", "SubSection": "1.4 Scheduler", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not bind the scheduler service to non-loopback insecure addresses.", "RationaleStatement": "The Scheduler API service which runs on port 10251/TCP by default is used for health and metrics information and is available without authentication or encryption. As such it should only be bound to a localhost interface, to minimize the cluster's attack surface", @@ -1349,7 +1349,7 @@ "Attributes": [ { "Section": "2 Etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Configure TLS encryption for the etcd service.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be encrypted in transit.", @@ -1371,7 +1371,7 @@ "Attributes": [ { "Section": "2 Etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable client authentication on etcd service.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should not be available to unauthenticated clients. You should enable the client authentication via valid certificates to secure the access to the etcd service.", @@ -1393,7 +1393,7 @@ "Attributes": [ { "Section": "2 Etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not use self-signed certificates for TLS.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should not be available to unauthenticated clients. You should enable the client authentication via valid certificates to secure the access to the etcd service.", @@ -1415,7 +1415,7 @@ "Attributes": [ { "Section": "2 Etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "etcd should be configured to make use of TLS encryption for peer connections.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be encrypted in transit and also amongst peers in the etcd clusters.", @@ -1437,7 +1437,7 @@ "Attributes": [ { "Section": "2 Etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "etcd should be configured for peer authentication.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be accessible only by authenticated etcd peers in the etcd cluster.", @@ -1459,7 +1459,7 @@ "Attributes": [ { "Section": "2 Etcd", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not use automatically generated self-signed certificates for TLS connections between peers.", "RationaleStatement": "etcd is a highly-available key value store used by Kubernetes deployments for persistent storage of all of its REST API objects. These objects are sensitive in nature and should be accessible only by authenticated etcd peers in the etcd cluster. Hence, do not use self-signed certificates for authentication.", @@ -1481,7 +1481,7 @@ "Attributes": [ { "Section": "2 Etcd", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Use a different certificate authority for etcd from the one used for Kubernetes.", "RationaleStatement": "etcd is a highly available key-value store used by Kubernetes deployments for persistent storage of all of its REST API objects. Its access should be restricted to specifically designated clients and peers only. Authentication to etcd is based on whether the certificate presented was issued by a trusted certificate authority. There is no checking of certificate attributes such as common name or subject alternative name. As such, if any attackers were able to gain access to any certificate issued by the trusted certificate authority, they would be able to gain full access to the etcd database.", @@ -1502,7 +1502,7 @@ { "Section": "3 Control Plane Configuration", "SubSection": "3.1 Authentication and Authorization", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Kubernetes provides the option to use client certificates for user authentication. However as there is no way to revoke these certificates when a user leaves an organization or loses their credential, they are not suitable for this purpose. It is not possible to fully disable client certificate use within a cluster as it is used for component to component authentication.", "RationaleStatement": "With any authentication mechanism the ability to revoke credentials if they are compromised or no longer required, is a key control. Kubernetes client certificate authentication does not allow for this due to a lack of support for certificate revocation.", @@ -1523,7 +1523,7 @@ { "Section": "3 Control Plane Configuration", "SubSection": "3.1 Authentication and Authorization", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Kubernetes provides service account tokens which are intended for use by workloads running in the Kubernetes cluster, for authentication to the API server. These tokens are not designed for use by end-users and do not provide for features such as revocation or expiry, making them insecure. A newer version of the feature (Bound service account token volumes) does introduce expiry but still does not allow for specific revocation.", "RationaleStatement": "With any authentication mechanism the ability to revoke credentials if they are compromised or no longer required, is a key control. Service account token authentication does not allow for this due to the use of JWT tokens as an underlying technology.", @@ -1544,7 +1544,7 @@ { "Section": "3 Control Plane Configuration", "SubSection": "3.1 Authentication and Authorization", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Kubernetes provides bootstrap tokens which are intended for use by new nodes joining the cluster These tokens are not designed for use by end-users they are specifically designed for the purpose of bootstrapping new nodes and not for general authentication", "RationaleStatement": "Bootstrap tokens are not intended for use as a general authentication mechanism and impose constraints on user and group naming that do not facilitate good RBAC design. They also cannot be used with MFA resulting in a weak authentication mechanism being available.", @@ -1565,7 +1565,7 @@ { "Section": "3 Control Plane Configuration", "SubSection": "3.2 Logging", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Kubernetes can audit the details of requests made to the API server. The `--audit-policy-file` flag must be set for this logging to be enabled.", "RationaleStatement": "Logging is an important detective control for all systems, to detect potential unauthorised access.", @@ -1586,7 +1586,7 @@ { "Section": "3 Control Plane Configuration", "SubSection": "3.2 Logging", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Ensure that the audit policy created for the cluster covers key security concerns.", "RationaleStatement": "Security audit logs should cover access and modification of key resources in the cluster, to enable them to form an effective part of a security environment.", @@ -1609,7 +1609,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `kubelet` service file has permissions of `600` or more restrictive.", "RationaleStatement": "The `kubelet` service file controls various parameters that set the behavior of the `kubelet` service in the worker node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -1632,7 +1632,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `kubelet` service file ownership is set to `root:root`.", "RationaleStatement": "The `kubelet` service file controls various parameters that set the behavior of the `kubelet` service in the worker node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -1653,7 +1653,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "If `kube-proxy` is running, and if it is using a file-based kubeconfig file, ensure that the proxy kubeconfig file has permissions of `600` or more restrictive.", "RationaleStatement": "The `kube-proxy` kubeconfig file controls various parameters of the `kube-proxy` service in the worker node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system. It is possible to run `kube-proxy` with the kubeconfig parameters configured as a Kubernetes ConfigMap instead of a file. In this case, there is no proxy kubeconfig file.", @@ -1674,7 +1674,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "If `kube-proxy` is running, ensure that the file ownership of its kubeconfig file is set to `root:root`.", "RationaleStatement": "The kubeconfig file for `kube-proxy` controls various parameters for the `kube-proxy` service in the worker node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -1697,7 +1697,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `kubelet.conf` file has permissions of `600` or more restrictive.", "RationaleStatement": "The `kubelet.conf` file is the kubeconfig file for the node, and controls various parameters that set the behavior and identity of the worker node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -1720,7 +1720,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that the `kubelet.conf` file ownership is set to `root:root`.", "RationaleStatement": "The `kubelet.conf` file is the kubeconfig file for the node, and controls various parameters that set the behavior and identity of the worker node. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -1741,7 +1741,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the certificate authorities file has permissions of `600` or more restrictive.", "RationaleStatement": "The certificate authorities file controls the authorities used to validate API requests. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -1762,7 +1762,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the certificate authorities file ownership is set to `root:root`.", "RationaleStatement": "The certificate authorities file controls the authorities used to validate API requests. You should set its file ownership to maintain the integrity of the file. The file should be owned by `root:root`.", @@ -1785,7 +1785,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that if the kubelet refers to a configuration file with the `--config` argument, that file has permissions of 600 or more restrictive.", "RationaleStatement": "The kubelet reads various parameters, including security settings, from a config file specified by the `--config` argument. If this file is specified you should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -1808,7 +1808,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.1 Worker Node Configuration Files", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Ensure that if the kubelet refers to a configuration file with the `--config` argument, that file is owned by root:root.", "RationaleStatement": "The kubelet reads various parameters, including security settings, from a config file specified by the `--config` argument. If this file is specified you should restrict its file permissions to maintain the integrity of the file. The file should be owned by root:root.", @@ -1831,7 +1831,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Disable anonymous requests to the Kubelet server.", "RationaleStatement": "When enabled, requests that are not rejected by other configured authentication methods are treated as anonymous requests. These requests are then served by the Kubelet server. You should rely on authentication to authorize access and disallow anonymous requests.", @@ -1854,7 +1854,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not allow all requests. Enable explicit authorization.", "RationaleStatement": "Kubelets, by default, allow all authenticated requests (even anonymous ones) without needing explicit authorization checks from the apiserver. You should restrict this behavior and only allow explicitly authorized requests.", @@ -1877,7 +1877,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable Kubelet authentication using certificates.", "RationaleStatement": "The connections from the apiserver to the kubelet are used for fetching logs for pods, attaching (through kubectl) to running pods, and using the kubelet’s port-forwarding functionality. These connections terminate at the kubelet’s HTTPS endpoint. By default, the apiserver does not verify the kubelet’s serving certificate, which makes the connection subject to man-in-the-middle attacks, and unsafe to run over untrusted and/or public networks. Enabling Kubelet certificate authentication ensures that the apiserver could authenticate the Kubelet before submitting any requests.", @@ -1900,7 +1900,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Disable the read-only port.", "RationaleStatement": "The Kubelet process provides a read-only API in addition to the main Kubelet API. Unauthenticated access is provided to this read-only API which could possibly retrieve potentially sensitive information about the cluster.", @@ -1923,7 +1923,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not disable timeouts on streaming connections.", "RationaleStatement": "Setting idle timeouts ensures that you are protected against Denial-of-Service attacks, inactive connections and running out of ephemeral ports. **Note:** By default, `--streaming-connection-idle-timeout` is set to 4 hours which might be too high for your environment. Setting this as appropriate would additionally ensure that such streaming connections are timed out after serving legitimate use cases.", @@ -1946,7 +1946,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Allow Kubelet to manage iptables.", "RationaleStatement": "Kubelets can automatically manage the required changes to iptables based on how you choose your networking options for the pods. It is recommended to let kubelets manage the changes to iptables. This ensures that the iptables configuration remains in sync with pods networking configuration. Manually configuring iptables with dynamic pod network configuration changes might hamper the communication between pods/containers and to the outside world. You might have iptables rules too restrictive or too open.", @@ -1967,7 +1967,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not override node hostnames.", "RationaleStatement": "Overriding hostnames could potentially break TLS setup between the kubelet and the apiserver. Additionally, with overridden hostnames, it becomes increasingly difficult to associate logs with a particular node and process them for security analytics. Hence, you should setup your kubelet nodes with resolvable FQDNs and avoid overriding the hostnames with IPs.", @@ -1990,7 +1990,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 2 - Worker Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Security relevant information should be captured. The eventRecordQPS on the Kubelet configuration can be used to limit the rate at which events are gathered and sets the maximum event creations per second. Setting this too low could result in relevant events not being logged, however the unlimited setting of `0` could result in a denial of service on the kubelet.", "RationaleStatement": "It is important to capture all events and not restrict event creation. Events are an important source of security information and analytics that ensure that your environment is consistently monitored using the event data.", @@ -2013,7 +2013,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Setup TLS connection on the Kubelets.", "RationaleStatement": "The connections from the apiserver to the kubelet are used for fetching logs for pods, attaching (through kubectl) to running pods, and using the kubelet’s port-forwarding functionality. These connections terminate at the kubelet’s HTTPS endpoint. By default, the apiserver does not verify the kubelet’s serving certificate, which makes the connection subject to man-in-the-middle attacks, and unsafe to run over untrusted and/or public networks.", @@ -2036,7 +2036,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Enable kubelet client certificate rotation.", "RationaleStatement": "The `--rotate-certificates` setting causes the kubelet to rotate its client certificates by creating new CSRs as its existing credentials expire. This automated periodic rotation ensures that the there is no downtime due to expired certificates and thus addressing availability in the CIA security triad. **Note:** This recommendation only applies if you let kubelets get their certificates from the API server. In case your kubelet certificates come from an outside authority/tool (e.g. Vault) then you need to take care of rotation yourself. **Note:** This feature also require the `RotateKubeletClientCertificate` feature gate to be enabled (which is the default since Kubernetes v1.7)", @@ -2057,7 +2057,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Enable kubelet server certificate rotation.", "RationaleStatement": "`RotateKubeletServerCertificate` causes the kubelet to both request a serving certificate after bootstrapping its client credentials and rotate the certificate as its existing credentials expire. This automated periodic rotation ensures that the there are no downtimes due to expired certificates and thus addressing availability in the CIA security triad. Note: This recommendation only applies if you let kubelets get their certificates from the API server. In case your kubelet certificates come from an outside authority/tool (e.g. Vault) then you need to take care of rotation yourself.", @@ -2080,7 +2080,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the Kubelet is configured to only use strong cryptographic ciphers.", "RationaleStatement": "TLS ciphers have had a number of known vulnerabilities and weaknesses, which can reduce the protection provided by them. By default Kubernetes supports a number of TLS ciphersuites including some that have security concerns, weakening the protection provided.", @@ -2101,7 +2101,7 @@ { "Section": "4 Worker Nodes", "SubSection": "4.2 Kubelet", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Ensure that the Kubelet sets limits on the number of PIDs that can be created by pods running on the node.", "RationaleStatement": "By default pods running in a cluster can consume any number of PIDs, potentially exhausting the resources available on the node. Setting an appropriate limit reduces the risk of a denial of service attack on cluster nodes.", @@ -2124,7 +2124,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "The RBAC role `cluster-admin` provides wide-ranging powers over the environment and should be used only where and when needed.", "RationaleStatement": "Kubernetes provides a set of default roles where RBAC is used. Some of these roles such as `cluster-admin` provide wide-ranging privileges which should only be applied where absolutely necessary. Roles such as `cluster-admin` allow super-user access to perform any action on any resource. When used in a `ClusterRoleBinding`, it gives full control over every resource in the cluster and in all namespaces. When used in a `RoleBinding`, it gives full control over every resource in the rolebinding's namespace, including the namespace itself.", @@ -2147,7 +2147,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "The Kubernetes API stores secrets, which may be service account tokens for the Kubernetes API or credentials used by workloads in the cluster. Access to these secrets should be restricted to the smallest possible group of users to reduce the risk of privilege escalation.", "RationaleStatement": "Inappropriate access to secrets stored within the Kubernetes cluster can allow for an attacker to gain additional access to the Kubernetes cluster or external resources whose credentials are stored as secrets.", @@ -2170,7 +2170,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Worker Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Kubernetes Roles and ClusterRoles provide access to resources based on sets of objects and actions that can be taken on those objects. It is possible to set either of these to be the wildcard \"*\" which matches all items. Use of wildcards is not optimal from a security perspective as it may allow for inadvertent access to be granted when new resources are added to the Kubernetes API either as CRDs or in later versions of the product.", "RationaleStatement": "The principle of least privilege recommends that users are provided only the access required for their role and nothing more. The use of wildcard rights grants is likely to provide excessive rights to the Kubernetes API.", @@ -2193,7 +2193,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "The ability to create pods in a namespace can provide a number of opportunities for privilege escalation, such as assigning privileged service accounts to these pods or mounting hostPaths with access to sensitive data (unless Pod Security Policies are implemented to restrict this access) As such, access to create new pods should be restricted to the smallest possible group of users.", "RationaleStatement": "The ability to create pods in a cluster opens up possibilities for privilege escalation and should be restricted, where possible.", @@ -2214,7 +2214,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "The `default` service account should not be used to ensure that rights granted to applications can be more easily audited and reviewed.", "RationaleStatement": "Kubernetes provides a `default` service account which is used by cluster workloads where no specific service account is assigned to the pod. Where access to the Kubernetes API from a pod is required, a specific service account should be created for that pod, and rights granted to that service account. The default service account should be configured such that it does not provide a service account token and does not have any explicit rights assignments.", @@ -2235,7 +2235,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Service accounts tokens should not be mounted in pods except where the workload running in the pod explicitly needs to communicate with the API server", "RationaleStatement": "Mounting service account tokens inside pods can provide an avenue for privilege escalation attacks where an attacker is able to compromise a single pod in the cluster. Avoiding mounting these tokens removes this attack avenue.", @@ -2256,7 +2256,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "The special group `system:masters` should not be used to grant permissions to any user or service account, except where strictly necessary (e.g. bootstrapping access prior to RBAC being fully available)", "RationaleStatement": "The `system:masters` group has unrestricted access to the Kubernetes API hard-coded into the API server source code. An authenticated user who is a member of this group cannot have their access reduced, even if all bindings and cluster role bindings which mention it, are removed. When combined with client certificate authentication, use of this group can allow for irrevocable cluster-admin level credentials to exist for a cluster.", @@ -2277,7 +2277,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Cluster roles and roles with the impersonate, bind or escalate permissions should not be granted unless strictly required. Each of these permissions allow a particular subject to escalate their privileges beyond those explicitly granted by cluster administrators", "RationaleStatement": "The impersonate privilege allows a subject to impersonate other users gaining their rights to the cluster. The bind privilege allows the subject to add a binding to a cluster role or role which escalates their effective permissions in the cluster. The escalate privilege allows a subject to modify cluster roles to which they are bound, increasing their rights to that level. Each of these permissions has the potential to allow for privilege escalation to cluster-admin level.", @@ -2300,7 +2300,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "The ability to create persistent volumes in a cluster can provide an opportunity for privilege escalation, via the creation of `hostPath` volumes. As persistent volumes are not covered by Pod Security Admission, a user with access to create persistent volumes may be able to get access to sensitive files from the underlying host even where restrictive Pod Security Admission policies are in place.", "RationaleStatement": "The ability to create persistent volumes in a cluster opens up possibilities for privilege escalation and should be restricted, where possible.", @@ -2323,7 +2323,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Users with access to the `Proxy` sub-resource of `Node` objects automatically have permissions to use the Kubelet API, which may allow for privilege escalation or bypass cluster security controls such as audit logs. The Kubelet provides an API which includes rights to execute commands in any container running on the node. Access to this API is covered by permissions to the main Kubernetes API via the `node` object. The proxy sub-resource specifically allows wide ranging access to the Kubelet API. Direct access to the Kubelet API bypasses controls like audit logging (there is no audit log of Kubelet API access) and admission control.", "RationaleStatement": "The ability to use the `proxy` sub-resource of `node` objects opens up possibilities for privilege escalation and should be restricted, where possible.", @@ -2346,7 +2346,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Users with access to the update the `approval` sub-resource of `certificateaigningrequest` objects can approve new client certificates for the Kubernetes API effectively allowing them to create new high-privileged user accounts. This can allow for privilege escalation to full cluster administrator, depending on users configured in the cluster", "RationaleStatement": "The ability to update certificate signing requests should be limited.", @@ -2369,7 +2369,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Users with rights to create/modify/delete `validatingwebhookconfigurations` or `mutatingwebhookconfigurations` can control webhooks that can read any object admitted to the cluster, and in the case of mutating webhooks, also mutate admitted objects. This could allow for privilege escalation or disruption of the operation of the cluster.", "RationaleStatement": "The ability to manage webhook configuration should be limited", @@ -2392,7 +2392,7 @@ { "Section": "5 Policies", "SubSection": "5.1 RBAC and Service Accounts", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Users with rights to create new service account tokens at a cluster level, can create long-lived privileged credentials in the cluster. This could allow for privilege escalation and persistent access to the cluster, even if the users account has been revoked.", "RationaleStatement": "The ability to create service account tokens should be limited.", @@ -2413,7 +2413,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Every Kubernetes cluster should have at least one policy control mechanism in place to enforce the other requirements in this section. This could be the in-built Pod Security Admission controller, or a third party policy control system.", "RationaleStatement": "Without an active policy control mechanism, it is not possible to limit the use of containers with access to underlying cluster nodes, via mechanisms like privileged containers, or the use of hostPath volume mounts.", @@ -2436,7 +2436,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers to be run with the `securityContext.privileged` flag set to `true`.", "RationaleStatement": "Privileged containers have access to all Linux Kernel capabilities and devices. A container running with full privileges can do almost everything that the host can do. This flag exists to allow special use-cases, like manipulating the network stack and accessing devices. There should be at least one admission control policy defined which does not permit privileged containers. If you need to run privileged containers, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2459,7 +2459,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not generally permit containers to be run with the `hostPID` flag set to true.", "RationaleStatement": "A container running in the host's PID namespace can inspect processes running outside the container. If the container also has access to ptrace capabilities this can be used to escalate privileges outside of the container. There should be at least one admission control policy defined which does not permit containers to share the host PID namespace. If you need to run containers which require hostPID, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2482,7 +2482,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not generally permit containers to be run with the `hostIPC` flag set to true.", "RationaleStatement": "A container running in the host's IPC namespace can use IPC to interact with processes outside the container. There should be at least one admission control policy defined which does not permit containers to share the host IPC namespace. If you need to run containers which require hostIPC, this should be definited in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2505,7 +2505,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not generally permit containers to be run with the `hostNetwork` flag set to true.", "RationaleStatement": "A container running in the host's network namespace could access the local loopback device, and could access network traffic to and from other pods. There should be at least one admission control policy defined which does not permit containers to share the host network namespace. If you need to run containers which require access to the host's network namesapces, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2528,7 +2528,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not generally permit containers to be run with the `allowPrivilegeEscalation` flag set to true. Allowing this right can lead to a process running a container getting more rights than it started with. It's important to note that these rights are still constrained by the overall container sandbox, and this setting does not relate to the use of privileged containers.", "RationaleStatement": "A container running with the `allowPrivilegeEscalation` flag set to `true` may have processes that can gain more privileges than their parent. There should be at least one admission control policy defined which does not permit containers to allow privilege escalation. The option exists (and is defaulted to true) to permit setuid binaries to run. If you have need to run containers which use setuid binaries or require privilege escalation, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2551,7 +2551,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Automated", "Description": "Do not generally permit containers to be run as the root user.", "RationaleStatement": "Containers may run as any Linux user. Containers which run as the root user, whilst constrained by Container Runtime security features still have a escalated likelihood of container breakout. Ideally, all containers should run as a defined non-UID 0 user. There should be at least one admission control policy defined which does not permit root containers. If you need to run root containers, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2574,7 +2574,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not generally permit containers with the potentially dangerous NET_RAW capability.", "RationaleStatement": "Containers run with a default set of capabilities as assigned by the Container Runtime. By default this can include potentially dangerous capabilities. With Docker as the container runtime the NET_RAW capability is enabled which may be misused by malicious containers. Ideally, all containers should drop this capability. There should be at least one admission control policy defined which does not permit containers with the NET_RAW capability. If you need to run containers with this capability, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2597,7 +2597,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Automated", "Description": "Do not generally permit containers with capabilities assigned beyond the default set.", "RationaleStatement": "Containers run with a default set of capabilities as assigned by the Container Runtime. Capabilities outside this set can be added to containers which could expose them to risks of container breakout attacks. There should be at least one policy defined which prevents containers with capabilities beyond the default set from launching. If you need to run containers with additional capabilities, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2620,7 +2620,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers with capabilities", "RationaleStatement": "Containers run with a default set of capabilities as assigned by the Container Runtime. Capabilities are parts of the rights generally granted on a Linux system to the root user. In many cases applications running in containers do not require any capabilities to operate, so from the perspective of the principal of least privilege use of capabilities should be minimized.", @@ -2643,7 +2643,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit Windows containers to be run with the `hostProcess` flag set to true.", "RationaleStatement": "A Windows container making use of the `hostProcess` flag can interact with the underlying Windows cluster node. As per the Kubernetes documentation, this provides \"privileged access\" to the Windows node. Where Windows containers are used inside a Kubernetes cluster, there should be at least one admission control policy which does not permit `hostProcess` Windows containers. If you need to run Windows containers which require `hostProcess`, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2664,7 +2664,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally admit containers which make use of `hostPath` volumes.", "RationaleStatement": "A container which mounts a `hostPath` volume as part of its specification will have access to the filesystem of the underlying cluster node. The use of `hostPath` volumes may allow containers access to privileged areas of the node filesystem. There should be at least one admission control policy defined which does not permit containers to mount `hostPath` volumes. If you need to run containers which require `hostPath` volumes, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2687,7 +2687,7 @@ { "Section": "5 Policies", "SubSection": "5.2 Pod Security Standards", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Do not generally permit containers which require the use of HostPorts.", "RationaleStatement": "Host ports connect containers directly to the host's network. This can bypass controls such as network policy. There should be at least one admission control policy defined which does not permit containers which require the use of HostPorts. If you need to run containers which require HostPorts, this should be defined in a separate policy and you should carefully check to ensure that only limited service accounts and users are given permission to use that policy.", @@ -2708,7 +2708,7 @@ { "Section": "5 Policies", "SubSection": "5.3 Network Policies and CNI", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "There are a variety of CNI plugins available for Kubernetes. If the CNI in use does not support Network Policies it may not be possible to effectively restrict traffic in the cluster.", "RationaleStatement": "Kubernetes network policies are enforced by the CNI plugin in use. As such it is important to ensure that the CNI plugin supports both Ingress and Egress network policies.", @@ -2729,7 +2729,7 @@ { "Section": "5 Policies", "SubSection": "5.3 Network Policies and CNI", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Use network policies to isolate traffic in your cluster network.", "RationaleStatement": "Running different applications on the same Kubernetes cluster creates a risk of one compromised application attacking a neighboring application. Network segmentation is important to ensure that containers can communicate only with those they are supposed to. A network policy is a specification of how selections of pods are allowed to communicate with each other and other network endpoints. Network Policies are namespace scoped. When a network policy is introduced to a given namespace, all traffic not allowed by the policy is denied. However, if there are no network policies in a namespace all traffic will be allowed into and out of the pods in that namespace.", @@ -2752,7 +2752,7 @@ { "Section": "5 Policies", "SubSection": "5.4 Secrets Management", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Kubernetes supports mounting secrets as data volumes or as environment variables. Minimize the use of environment variable secrets.", "RationaleStatement": "It is reasonably common for application code to log out its environment (particularly in the event of an error). This will include any secret values passed in as environment variables, so secrets can easily be exposed to any user or entity who has access to the logs.", @@ -2773,7 +2773,7 @@ { "Section": "5 Policies", "SubSection": "5.4 Secrets Management", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Consider the use of an external secrets storage and management system, instead of using Kubernetes Secrets directly, if you have more complex secret management needs. Ensure the solution requires authentication to access secrets, has auditing of access to and use of secrets, and encrypts secrets. Some solutions also make it easier to rotate secrets.", "RationaleStatement": "Kubernetes supports secrets as first-class objects, but care needs to be taken to ensure that access to secrets is carefully limited. Using an external secrets provider can ease the management of access to secrets, especially where secrests are used across both Kubernetes and non-Kubernetes environments.", @@ -2794,7 +2794,7 @@ { "Section": "5 Policies", "SubSection": "5.4 Secrets Management", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Configure Image Provenance for your deployment.", "RationaleStatement": "Kubernetes supports plugging in provenance rules to accept or reject the images in your deployments. You could configure such rules to ensure that only approved images are deployed in the cluster.", @@ -2815,7 +2815,7 @@ { "Section": "5 Policies", "SubSection": "5.7 General Policies", - "Profile": "Level 1 - Master Node", + "Profile": "Level 1", "AssessmentStatus": "Manual", "Description": "Use namespaces to isolate your Kubernetes objects.", "RationaleStatement": "Limiting the scope of user permissions can reduce the impact of mistakes or malicious activities. A Kubernetes namespace allows you to partition created resources into logically named groups. Resources created in one namespace can be hidden from other namespaces. By default, each resource created by a user in Kubernetes cluster runs in a default namespace, called `default`. You can create additional namespaces and attach resources and users to them. You can use Kubernetes Authorization plugins to create policies that segregate access to namespace resources between different users.", @@ -2838,7 +2838,7 @@ { "Section": "5 Policies", "SubSection": "5.7 General Policies", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Enable `docker/default` seccomp profile in your pod definitions.", "RationaleStatement": "Seccomp (secure computing mode) is used to restrict the set of system calls applications can make, allowing cluster administrators greater control over the security of workloads running in the cluster. Kubernetes disables seccomp profiles by default for historical reasons. You should enable it to ensure that the workloads have restricted actions available within the container.", @@ -2859,7 +2859,7 @@ { "Section": "5 Policies", "SubSection": "5.7 General Policies", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Apply Security Context to Your Pods and Containers", "RationaleStatement": "A security context defines the operating system security settings (uid, gid, capabilities, SELinux role, etc..) applied to a container. When designing your containers and pods, make sure that you configure the security context for your pods, containers, and volumes. A security context is a property defined in the deployment yaml. It controls the security parameters that will be assigned to the pod/container/volume. There are two levels of security context: pod level security context, and container level security context.", @@ -2880,7 +2880,7 @@ { "Section": "5 Policies", "SubSection": "5.7 General Policies", - "Profile": "Level 2 - Master Node", + "Profile": "Level 2", "AssessmentStatus": "Manual", "Description": "Kubernetes provides a default namespace, where objects are placed if no namespace is specified for them. Placing objects in this namespace makes application of RBAC and other controls more difficult.", "RationaleStatement": "Resources in a Kubernetes cluster should be segregated by namespace, to allow for security controls to be applied at that level and to make it easier to manage resources.", diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json new file mode 100644 index 0000000000..e6416c6cd6 --- /dev/null +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -0,0 +1,896 @@ +{ + "Framework": "ISO27001", + "Version": "2022", + "Provider": "M365", + "Description": "ISO (the International Organization for Standardization) and IEC (the International Electrotechnical Commission) form the specialized system for worldwide standardization. National bodies that are members of ISO or IEC participate in the development of International Standards through technical committees established by the respective organization to deal with particular fields of technical activity. ISO and IEC technical committees collaborate in fields of mutual interest. Other international organizations, governmental and non-governmental, in liaison with ISO and IEC, also take part in the work.", + "Requirements": [ + { + "Id": "A.5.1", + "Description": "Information security policy and topic-specific policies should be defined, approved by management, published, communicated to and acknowledged by relevant personnel and relevant interested parties, and reviewed at planned intervals and if significant changes occur.", + "Name": "Policies for information security", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.1", + "Objetive_Name": "Policies for information security", + "Check_Summary": "Information security policy and topic-specific policies should be defined, approved by management, published, communicated to and acknowledged by relevant personnel and relevant interested parties, and reviewed at planned intervals and if significant changes occur." + } + ], + "Checks": [ + "defender_antiphishing_policy_configured", + "defender_antispam_policy_inbound_no_allowed_domains", + "entra_identity_protection_sign_in_risk_enabled", + "entra_identity_protection_user_risk_enabled" + ] + }, + { + "Id": "A.5.2", + "Description": "Information security roles and responsibilities should be defined and allocated according to the organisation needs.", + "Name": "Roles and Responsibilities", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.2", + "Objetive_Name": "Roles and Responsibilities", + "Check_Summary": "Information security roles and responsibilities should be defined and allocated according to the organisation needs." + } + ], + "Checks": [ + "entra_admin_portals_access_restriction", + "entra_admin_users_mfa_enabled", + "entra_policy_guest_invite_only_for_admin_roles", + "exchange_roles_assignment_policy_addins_disabled", + "teams_meeting_external_control_disabled", + "admincenter_external_calendar_sharing_disabled", + "admincenter_groups_not_public_visibility", + "admincenter_organization_customer_lockbox_enabled", + "admincenter_settings_password_never_expire", + "admincenter_users_admins_reduced_license_footprint", + "admincenter_users_between_two_and_four_global_admins", + "defender_antispam_outbound_policy_configured", + "entra_admin_consent_workflow_enabled", + "entra_admin_portals_access_restriction", + "entra_admin_users_cloud_only", + "entra_admin_users_mfa_enabled", + "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_admin_users_sign_in_frequency_enabled", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles" + ] + }, + { + "Id": "A.5.3", + "Description": "Conflicting duties and conflicting areas of responsibility should be segregated.", + "Name": "Segregation of Duties", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.3", + "Objetive_Name": "Segregation of Duties", + "Check_Summary": "Conflicting duties and conflicting areas of responsibility should be segregated." + } + ], + "Checks": [ + "entra_admin_consent_workflow_enabled", + "entra_admin_portals_access_restriction", + "entra_admin_users_cloud_only", + "entra_admin_users_mfa_enabled", + "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_admin_users_sign_in_frequency_enabled", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles" + ] + }, + { + "Id": "A.5.5", + "Description": "The organisation should establish and maintain contact with relevant authorities.", + "Name": "Contact With Authorities", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.5", + "Objetive_Name": "Contact With Authorities", + "Check_Summary": "The organisation should establish and maintain contact with relevant authorities." + } + ], + "Checks": [ + "defender_antispam_outbound_policy_configured", + "defender_malware_policy_notifications_internal_users_malware_enabled" + ] + }, + { + "Id": "A.5.7", + "Description": "Information relating to information security threats should be collected and analysed to produce threat intelligence.", + "Name": "Threat Intelligence", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.7", + "Objetive_Name": "Threat Intelligence", + "Check_Summary": "Information relating to information security threats should be collected and analysed to produce threat intelligence." + } + ], + "Checks": [ + "entra_identity_protection_sign_in_risk_enabled", + "entra_identity_protection_user_risk_enabled", + "defender_antispam_outbound_policy_configured", + "defender_malware_policy_notifications_internal_users_malware_enabled", + "defender_antiphishing_policy_configured", + "entra_admin_users_phishing_resistant_mfa_enabled" + ] + }, + { + "Id": "A.5.10", + "Description": "Rules for the acceptable use and procedures for handling information and other associated assets should be identified, documented and implemented.", + "Name": "Acceptable Use Of Information And Other Associated Assets", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.10", + "Objetive_Name": "Acceptable Use Of Information And Other Associated Assets", + "Check_Summary": "Rules for the acceptable use and procedures for handling information and other associated assets should be identified, documented and implemented." + } + ], + "Checks": [ + "sharepoint_external_sharing_managed", + "sharepoint_external_sharing_restricted", + "entra_admin_portals_access_restriction", + "entra_policy_guest_users_access_restrictions" + ] + }, + { + "Id": "A.5.13", + "Description": "An appropriate set of procedures for information labelling should be developed and implemented in accordance with the information classification scheme adopted by the organisation.", + "Name": "Labelling Of Information", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.13", + "Objetive_Name": "Labelling Of Information", + "Check_Summary": "An appropriate set of procedures for information labelling should be developed and implemented in accordance with the information classification scheme adopted by the organisation." + } + ], + "Checks": [ + "sharepoint_external_sharing_managed", + "exchange_external_email_tagging_enabled" + ] + }, + { + "Id": "A.5.14", + "Description": "Information transfer rules, procedures, or agreements should be in place for all types of transfer facilities within the organisation and between the organisation and other parties.", + "Name": "Information Transfer", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.14", + "Objetive_Name": "Information Transfer", + "Check_Summary": "Information transfer rules, procedures, or agreements should be in place for all types of transfer facilities within the organisation and between the organisation and other parties." + } + ], + "Checks": [ + "teams_external_file_sharing_restricted", + "sharepoint_external_sharing_managed", + "sharepoint_external_sharing_restricted", + "sharepoint_guest_sharing_restricted", + "sharepoint_modern_authentication_required", + "sharepoint_onedrive_sync_restricted_unmanaged_devices", + "teams_external_file_sharing_restricted", + "exchange_transport_config_smtp_auth_disabled", + "exchange_transport_rules_mail_forwarding_disabled", + "exchange_transport_rules_whitelist_disabled" + ] + }, + { + "Id": "A.5.15", + "Description": "Rules to control physical and logical access to information and other associated assets should be established", + "Name": "Access Control", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.15", + "Objetive_Name": "Access Control", + "Check_Summary": "Rules to control physical and logical access to information and other associated assets should be established" + } + ], + "Checks": [ + "admincenter_users_admins_reduced_license_footprint", + "entra_admin_portals_access_restriction", + "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_policy_guest_users_access_restrictions" + ] + }, + { + "Id": "A.5.16", + "Description": "The full lifecycle of identities should be managed.", + "Name": "Identity Management", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.16", + "Objetive_Name": "Identity Management", + "Check_Summary": "The full lifecycle of identities should be managed." + } + ], + "Checks": [ + "admincenter_settings_password_never_expire" + ] + }, + { + "Id": "A.5.17", + "Description": "Allocation and management of authentication information should be controlled by a management process, including advising personnel on the appropriate handling of authentication information.", + "Name": "Authentication Information", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.17", + "Objetive_Name": "Authentication Information", + "Check_Summary": "Allocation and management of authentication information should be controlled by a management process, including advising personnel on the appropriate handling of authentication information." + } + ], + "Checks": [ + "entra_admin_users_sign_in_frequency_enabled", + "entra_admin_users_mfa_enabled", + "entra_admin_users_sign_in_frequency_enabled", + "entra_legacy_authentication_blocked", + "entra_managed_device_required_for_authentication", + "entra_users_mfa_enabled", + "exchange_organization_modern_authentication_enabled", + "exchange_transport_config_smtp_auth_disabled", + "sharepoint_modern_authentication_required" + ] + }, + { + "Id": "A.5.18", + "Description": "Access rights to information and other associated assets should be provisioned, reviewed, modified and removed in accordance with the organisations topic-specific policy on and rules for access control.", + "Name": "Access Rights", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.18", + "Objetive_Name": "Access Rights", + "Check_Summary": "Access rights to information and other associated assets should be provisioned, reviewed, modified and removed in accordance with the organisations topic-specific policy on and rules for access control." + } + ], + "Checks": [ + "sharepoint_external_sharing_restricted", + "sharepoint_external_sharing_managed", + "sharepoint_guest_sharing_restricted", + "entra_policy_guest_users_access_restrictions", + "entra_admin_portals_access_restriction" + ] + }, + { + "Id": "A.5.19", + "Description": "Processes and procedures should be defined and implemented to manage the information security risks associated with the use of suppliers products or services.", + "Name": "Information Security In Supplier Relationships", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.19", + "Objetive_Name": "Information Security In Supplier Relationships", + "Check_Summary": "Processes and procedures should be defined and implemented to manage the information security risks associated with the use of suppliers products or services." + } + ], + "Checks": [ + "sharepoint_external_sharing_managed", + "entra_identity_protection_sign_in_risk_enabled", + "entra_identity_protection_user_risk_enabled" + ] + }, + { + "Id": "A.5.21", + "Description": "Processes and procedures should be defined and implemented to manage the information security risks associated with the ICT products and services supply chain.", + "Name": "Managing Information Security In The ICT Supply Chain", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.21", + "Objetive_Name": "Managing Information Security In The ICT Supply Chain", + "Check_Summary": "Processes and procedures should be defined and implemented to manage the information security risks associated with the ICT products and services supply chain." + } + ], + "Checks": [ + "sharepoint_external_sharing_managed", + "entra_identity_protection_sign_in_risk_enabled", + "entra_identity_protection_user_risk_enabled" + ] + }, + { + "Id": "A.5.22", + "Description": "The organisation should regularly monitor, review, evaluate and manage change in supplier information security practices and service delivery.", + "Name": "Monitor, Review And Change Management Of Supplier Services", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.22", + "Objetive_Name": "Monitor, Review And Change Management Of Supplier Services", + "Check_Summary": "The organisation should regularly monitor, review, evaluate and manage change in supplier information security practices and service delivery." + } + ], + "Checks": [ + "purview_audit_log_search_enabled" + ] + }, + { + "Id": "A.5.24", + "Description": "The organization should plan and prepare for managing information security incidents by defining, establishing and communicating information security incident management processes, roles and responsibilities.", + "Name": "Information Security Incident Management Planning and Preparation", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.24", + "Objetive_Name": "Information Security Incident Management Planning and Preparation", + "Check_Summary": "The organization should plan and prepare for managing information security incidents by defining, establishing and communicating information security incident management processes, roles and responsibilities." + } + ], + "Checks": [ + "entra_admin_portals_access_restriction", + "entra_admin_users_mfa_enabled", + "entra_policy_guest_invite_only_for_admin_roles", + "exchange_roles_assignment_policy_addins_disabled" + ] + }, + { + "Id": "A.5.25", + "Description": "The organisation should assess information security events and decide if they are to be categorised as information security incidents.", + "Name": "Assessment And Decision On Information Security Events", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.25", + "Objetive_Name": "Assessment And Decision On Information Security Events", + "Check_Summary": "The organisation should assess information security events and decide if they are to be categorised as information security incidents." + } + ], + "Checks": [ + "defender_antispam_outbound_policy_configured", + "defender_malware_policy_notifications_internal_users_malware_enabled", + "defender_malware_policy_common_attachments_filter_enabled", + "defender_malware_policy_comprehensive_attachments_filter_applied", + "defender_antispam_connection_filter_policy_empty_ip_allowlist", + "defender_antispam_connection_filter_policy_safe_list_off", + "defender_antispam_outbound_policy_configured", + "defender_antispam_outbound_policy_forwarding_disabled", + "defender_antispam_policy_inbound_no_allowed_domains" + ] + }, + { + "Id": "A.5.26", + "Description": "Information security incidents should be responded to in accordance with the documented procedures.", + "Name": "Response To Information Security Incidents", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.26", + "Objetive_Name": "Response To Information Security Incidents", + "Check_Summary": "Information security incidents should be responded to in accordance with the documented procedures." + } + ], + "Checks": [ + "defender_malware_policy_common_attachments_filter_enabled", + "defender_malware_policy_comprehensive_attachments_filter_applied", + "defender_malware_policy_notifications_internal_users_malware_enabled", + "defender_antispam_outbound_policy_configured", + "defender_malware_policy_notifications_internal_users_malware_enabled" + ] + }, + { + "Id": "A.5.28", + "Description": "The organisation should establish and implement procedures for the identification, collection, acquisition and preservation of evidence related to information security events.", + "Name": "Collection Of Evidence", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.28", + "Objetive_Name": "Collection Of Evidence", + "Check_Summary": "The organisation should establish and implement procedures for the identification, collection, acquisition and preservation of evidence related to information security events." + } + ], + "Checks": [ + "purview_audit_log_search_enabled" + ] + }, + { + "Id": "A.5.33", + "Description": "Records should be protected from loss, destruction, falsification, unauthorised access and unauthorised release.", + "Name": "Protection Of Records", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.33", + "Objetive_Name": "Protection Of Records", + "Check_Summary": "Records should be protected from loss, destruction, falsification, unauthorised access and unauthorised release." + } + ], + "Checks": [ + "admincenter_groups_not_public_visibility", + "teams_meeting_recording_disabled" + ] + }, + { + "Id": "A.5.34", + "Description": "The organisation should identify and meet the requirements regarding the preservation of privacy and protection of PII according to applicable laws and regulations and contractual requirements.", + "Name": "Privacy And Protection Of PII", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.34", + "Objetive_Name": "Privacy And Protection Of PII", + "Check_Summary": "The organisation should identify and meet the requirements regarding the preservation of privacy and protection of PII according to applicable laws and regulations and contractual requirements." + } + ], + "Checks": [ + "sharepoint_external_sharing_restricted", + "entra_identity_protection_sign_in_risk_enabled", + "entra_identity_protection_user_risk_enabled" + ] + }, + { + "Id": "A.5.36", + "Description": "Compliance with the organisations information security policy, topic-specific policies, rules and standards should be regularly reviewed. ", + "Name": "Compliance With Policies, Rules And Standards For Information Security", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.36", + "Objetive_Name": "Compliance With Policies, Rules And Standards For Information Security", + "Check_Summary": "Compliance with the organisations information security policy, topic-specific policies, rules and standards should be regularly reviewed. " + } + ], + "Checks": [ + "admincenter_settings_password_never_expire", + "defender_antiphishing_policy_configured", + "defender_antispam_connection_filter_policy_empty_ip_allowlist", + "defender_antispam_connection_filter_policy_safe_list_off", + "defender_antispam_outbound_policy_configured", + "defender_antispam_outbound_policy_forwarding_disabled", + "defender_antispam_policy_inbound_no_allowed_domains", + "defender_chat_report_policy_configured", + "defender_malware_policy_common_attachments_filter_enabled", + "defender_malware_policy_comprehensive_attachments_filter_applied", + "defender_malware_policy_notifications_internal_users_malware_enabled", + "entra_identity_protection_sign_in_risk_enabled", + "entra_identity_protection_user_risk_enabled", + "entra_legacy_authentication_blocked", + "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", + "exchange_mailbox_policy_additional_storage_restricted", + "exchange_roles_assignment_policy_addins_disabled" + ] + }, + { + "Id": "A.5.37", + "Description": "Operating procedures for information processing facilities should be documented and made available to personnel who need them. ", + "Name": "Documented Operating Procedures", + "Attributes": [ + { + "Category": "A.5 Organizational controls", + "Objetive_ID": "A.5.37", + "Objetive_Name": "Documented Operating Procedures", + "Check_Summary": "Operating procedures for information processing facilities should be documented and made available to personnel who need them. " + } + ], + "Checks": [ + "defender_antiphishing_policy_configured", + "defender_antispam_connection_filter_policy_empty_ip_allowlist", + "defender_antispam_connection_filter_policy_safe_list_off", + "defender_antispam_outbound_policy_configured", + "defender_antispam_outbound_policy_forwarding_disabled", + "defender_antispam_policy_inbound_no_allowed_domains" + ] + }, + { + "Id": "A.6.4", + "Description": "A disciplinary process should be formalised and communicated to take actions against personnel and other relevant interested parties who have committed an information security policy violation.", + "Name": "Disciplinary Process", + "Attributes": [ + { + "Category": "A.6 People controls", + "Objetive_ID": "A.6.4", + "Objetive_Name": "Disciplinary Process", + "Check_Summary": "A disciplinary process should be formalised and communicated to take actions against personnel and other relevant interested parties who have committed an information security policy violation." + } + ], + "Checks": [ + "defender_antispam_outbound_policy_configured", + "defender_malware_policy_notifications_internal_users_malware_enabled" + ] + }, + { + "Id": "A.6.7", + "Description": "Security measures should be implemented when personnel are working remotely to protect information accessed, processed or stored outside the organisations premises.", + "Name": "Remote Working", + "Attributes": [ + { + "Category": "A.6 People controls", + "Objetive_ID": "A.6.7", + "Objetive_Name": "Remote Working", + "Check_Summary": "Security measures should be implemented when personnel are working remotely to protect information accessed, processed or stored outside the organisations premises." + } + ], + "Checks": [ + "sharepoint_external_sharing_restricted", + "sharepoint_external_sharing_managed", + "teams_external_file_sharing_restricted" + ] + }, + { + "Id": "A.6.8", + "Description": "The organisation should provide a mechanism for personnel to report observed or suspected information security events through appropriate channels in a timely manner.", + "Name": "Information Security Event Reporting", + "Attributes": [ + { + "Category": "A.6 People controls", + "Objetive_ID": "A.6.8", + "Objetive_Name": "Information Security Event Reporting", + "Check_Summary": "The organisation should provide a mechanism for personnel to report observed or suspected information security events through appropriate channels in a timely manner." + } + ], + "Checks": [ + "defender_malware_policy_notifications_internal_users_malware_enabled", + "defender_malware_policy_common_attachments_filter_enabled", + "defender_malware_policy_comprehensive_attachments_filter_applied" + ] + }, + { + "Id": "A.7.4", + "Description": "Premises should be continuously monitored for unauthorised physical access.", + "Name": "Physical Security Monitoring", + "Attributes": [ + { + "Category": "A.7 Physical controls", + "Objetive_ID": "A.7.4", + "Objetive_Name": "Physical Security Monitoring", + "Check_Summary": "Premises should be continuously monitored for unauthorised physical access." + } + ], + "Checks": [ + "entra_admin_users_sign_in_frequency_enabled", + "entra_admin_portals_access_restriction", + "entra_policy_guest_users_access_restrictions" + ] + }, + { + "Id": "A.7.10", + "Description": "Storage media should be managed through their life cycle of acquisition, use, transportation and disposal in accordance with the organisations classification scheme and handling requirements.", + "Name": "Storage Media", + "Attributes": [ + { + "Category": "A.7 Physical controls", + "Objetive_ID": "A.7.10", + "Objetive_Name": "Storage Media", + "Check_Summary": "Storage media should be managed through their life cycle of acquisition, use, transportation and disposal in accordance with the organisations classification scheme and handling requirements." + } + ], + "Checks": [ + "exchange_mailbox_policy_additional_storage_restricted", + "teams_external_file_sharing_restricted" + ] + }, + { + "Id": "A.7.14", + "Description": "Items of equipment containing storage media should be verified to ensure that any sensitive data and licensed software has been removed or securely overwritten prior to disposal or re-use.", + "Name": "Secure Disposal Or Re-Use Of Equipment", + "Attributes": [ + { + "Category": "A.7 Physical controls", + "Objetive_ID": "A.7.14", + "Objetive_Name": "Secure Disposal Or Re-Use Of Equipment", + "Check_Summary": "Items of equipment containing storage media should be verified to ensure that any sensitive data and licensed software has been removed or securely overwritten prior to disposal or re-use." + } + ], + "Checks": [ + "exchange_mailbox_policy_additional_storage_restricted", + "teams_external_file_sharing_restricted" + ] + }, + { + "Id": "A.8.1", + "Description": "Information stored on, processed by or accessible via user endpoint devices should be protected.", + "Name": "User Endpoint Devices", + "Attributes": [ + { + "Category": "A.8 Technological controls", + "Objetive_ID": "A.8.1", + "Objetive_Name": "User Endpoint Devices", + "Check_Summary": "Information stored on, processed by or accessible via user endpoint devices should be protected." + } + ], + "Checks": [ + "entra_managed_device_required_for_authentication", + "entra_users_mfa_enabled", + "entra_managed_device_required_for_mfa_registration", + "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_users_mfa_capable" + ] + }, + { + "Id": "A.8.2", + "Description": "The allocation and use of privileged access rights should be restricted and managed.", + "Name": "Privileged Access Rights", + "Attributes": [ + { + "Category": "A.8 Technological controls", + "Objetive_ID": "A.8.2", + "Objetive_Name": "Privileged Access Rights", + "Check_Summary": "The allocation and use of privileged access rights should be restricted and managed." + } + ], + "Checks": [ + "admincenter_external_calendar_sharing_disabled", + "admincenter_groups_not_public_visibility", + "admincenter_organization_customer_lockbox_enabled", + "admincenter_settings_password_never_expire", + "admincenter_users_admins_reduced_license_footprint", + "admincenter_users_between_two_and_four_global_admins", + "defender_antispam_outbound_policy_configured", + "entra_admin_consent_workflow_enabled", + "entra_admin_portals_access_restriction", + "entra_admin_users_cloud_only", + "entra_admin_users_mfa_enabled", + "entra_admin_users_phishing_resistant_mfa_enabled", + "entra_admin_users_sign_in_frequency_enabled", + "entra_policy_ensure_default_user_cannot_create_tenants", + "entra_policy_guest_invite_only_for_admin_roles" + ] + }, + { + "Id": "A.8.3", + "Description": "Access to information and other associated assets should be restricted in accordance with the established topic-specific policy on access control.", + "Name": "Information Access Restriction", + "Attributes": [ + { + "Category": "A.8 Technological controls", + "Objetive_ID": "A.8.3", + "Objetive_Name": "Information Access Restriction", + "Check_Summary": "Access to information and other associated assets should be restricted in accordance with the established topic-specific policy on access control." + } + ], + "Checks": [ + "sharepoint_external_sharing_restricted", + "entra_admin_portals_access_restriction", + "entra_policy_guest_users_access_restrictions" + ] + }, + { + "Id": "A.8.5", + "Description": "Secure authentication technologies and procedures should be implemented based on information access restrictions and the topic-specific policy on access control.", + "Name": "Secure Authentication", + "Attributes": [ + { + "Category": "A.8 Technological controls", + "Objetive_ID": "A.8.5", + "Objetive_Name": "Secure Authentication", + "Check_Summary": "Secure authentication technologies and procedures should be implemented based on information access restrictions and the topic-specific policy on access control." + } + ], + "Checks": [ + "entra_admin_users_sign_in_frequency_enabled", + "entra_admin_users_mfa_enabled", + "entra_managed_device_required_for_authentication", + "entra_users_mfa_enabled", + "entra_identity_protection_sign_in_risk_enabled" + ] + }, + { + "Id": "A.8.7", + "Description": "Protection against malware should be implemented and supported by appropriate user awareness.", + "Name": "Protection Against Malware", + "Attributes": [ + { + "Category": "A.8 Technological controls", + "Objetive_ID": "A.8.7", + "Objetive_Name": "Protection Against Malware", + "Check_Summary": "Protection against malware should be implemented and supported by appropriate user awareness." + } + ], + "Checks": [ + "defender_malware_policy_common_attachments_filter_enabled", + "defender_malware_policy_comprehensive_attachments_filter_applied", + "defender_malware_policy_notifications_internal_users_malware_enabled", + "teams_external_domains_restricted", + "teams_external_users_cannot_start_conversations" + ] + }, + { + "Id": "A.8.8", + "Description": "Information about technical vulnerabilities of information systems in use should be obtained, the organisations exposure to such vulnerabilities should be evaluated and appropriate measures should be taken.", + "Name": "Management of Technical Vulnerabilities", + "Attributes": [ + { + "Category": "A.8 Technological controls", + "Objetive_ID": "A.8.8", + "Objetive_Name": "Management of Technical Vulnerabilities", + "Check_Summary": "Information about technical vulnerabilities of information systems in use should be obtained, the organisations exposure to such vulnerabilities should be evaluated and appropriate measures should be taken." + } + ], + "Checks": [ + "defender_malware_policy_common_attachments_filter_enabled", + "defender_malware_policy_comprehensive_attachments_filter_applied", + "defender_malware_policy_notifications_internal_users_malware_enabled" + ] + }, + { + "Id": "A.8.12", + "Description": "Data leakage prevention measures should be applied to systems, networks and any other devices that process, store or transmit sensitive information.", + "Name": "Data Leakage Prevention", + "Attributes": [ + { + "Category": "A.8 Technological controls", + "Objetive_ID": "A.8.12", + "Objetive_Name": "Data Leakage Prevention", + "Check_Summary": "Data leakage prevention measures should be applied to systems, networks and any other devices that process, store or transmit sensitive information." + } + ], + "Checks": [ + "defender_antiphishing_policy_configured", + "entra_admin_users_phishing_resistant_mfa_enabled" + ] + }, + { + "Id": "A.8.15", + "Description": "Logs that record activities, exceptions, faults and other relevant events should be produced, stored, protected and analysed.", + "Name": "Logging", + "Attributes": [ + { + "Category": "A.8 Technological controls", + "Objetive_ID": "A.8.15", + "Objetive_Name": "Logging", + "Check_Summary": "Logs that record activities, exceptions, faults and other relevant events should be produced, stored, protected and analysed." + } + ], + "Checks": [ + "purview_audit_log_search_enabled" + ] + }, + { + "Id": "A.8.18", + "Description": "The use of utility programs that can be capable of overriding system and application controls should be restricted and tightly controlled", + "Name": "Use of Privileged Utility Programs", + "Attributes": [ + { + "Category": "A.8 Technological controls", + "Objetive_ID": "A.8.18", + "Objetive_Name": "Use of Privileged Utility Programs", + "Check_Summary": "The use of utility programs that can be capable of overriding system and application controls should be restricted and tightly controlled" + } + ], + "Checks": [ + "entra_thirdparty_integrated_apps_not_allowed", + "entra_policy_restricts_user_consent_for_apps", + "teams_external_domains_restricted", + "teams_external_users_cannot_start_conversations" + ] + }, + { + "Id": "A.8.19", + "Description": "Procedures and measures should be implemented to securely manage software installation on operational systems.", + "Name": "Installation of Software on Operational Systems", + "Attributes": [ + { + "Category": "A.8 Technological controls", + "Objetive_ID": "A.8.19", + "Objetive_Name": "Installation of Software on Operational Systems", + "Check_Summary": "Procedures and measures should be implemented to securely manage software installation on operational systems." + } + ], + "Checks": [ + "admincenter_users_admins_reduced_license_footprint" + ] + }, + { + "Id": "A.8.20", + "Description": "Networks and network devices should be secured, managed and controlled to protect information in systems and applications.", + "Name": "Network Security", + "Attributes": [ + { + "Category": "A.8 Technological controls", + "Objetive_ID": "A.8.20", + "Objetive_Name": "Network Security", + "Check_Summary": "Networks and network devices should be secured, managed and controlled to protect information in systems and applications." + } + ], + "Checks": [ + "teams_external_file_sharing_restricted", + "admincenter_external_calendar_sharing_disabled" + ] + }, + { + "Id": "A.8.21", + "Description": "Security mechanisms, service levels and service requirements of network services should be identified, implemented and monitored.", + "Name": "Security of Network Services", + "Attributes": [ + { + "Category": "A.8 Technological controls", + "Objetive_ID": "A.8.21", + "Objetive_Name": "Security of Network Services", + "Check_Summary": "Security mechanisms, service levels and service requirements of network services should be identified, implemented and monitored." + } + ], + "Checks": [ + "defender_antispam_policy_inbound_no_allowed_domains", + "defender_domain_dkim_enabled", + "exchange_transport_rules_whitelist_disabled", + "sharepoint_external_sharing_managed", + "teams_external_domains_restricted" + ] + }, + { + "Id": "A.8.23", + "Description": "Access to external websites should be managed to reduce exposure to malicious content.", + "Name": "Web Filtering", + "Attributes": [ + { + "Category": "A.8 Technological controls", + "Objetive_ID": "A.8.23", + "Objetive_Name": "Web Filtering", + "Check_Summary": "Access to external websites should be managed to reduce exposure to malicious content." + } + ], + "Checks": [ + "teams_external_domains_restricted", + "teams_external_users_cannot_start_conversations", + "sharepoint_external_sharing_restricted", + "sharepoint_external_sharing_managed" + ] + }, + { + "Id": "A.8.26", + "Description": "Information security requirements should be identified, specified and approved when developing or acquiring applications.", + "Name": "Application Security Requirements", + "Attributes": [ + { + "Category": "A.8 Technological controls", + "Objetive_ID": "A.8.26", + "Objetive_Name": "Application Security Requirements", + "Check_Summary": "Information security requirements should be identified, specified and approved when developing or acquiring applications." + } + ], + "Checks": [ + "entra_policy_restricts_user_consent_for_apps", + "admincenter_users_admins_reduced_license_footprint", + "defender_malware_policy_comprehensive_attachments_filter_applied", + "entra_thirdparty_integrated_apps_not_allowed", + "sharepoint_modern_authentication_required" + ] + }, + { + "Id": "A.8.30", + "Description": "The organisation should direct, monitor and review the activities related to outsourced system development.", + "Name": "Outsourced Development", + "Attributes": [ + { + "Category": "A.8 Technological controls", + "Objetive_ID": "A.8.30", + "Objetive_Name": "Outsourced Development", + "Check_Summary": "The organisation should direct, monitor and review the activities related to outsourced system development." + } + ], + "Checks": [ + "entra_identity_protection_sign_in_risk_enabled", + "entra_identity_protection_user_risk_enabled" + ] + }, + { + "Id": "A.8.34", + "Description": "Audit tests and other assurance activities involving assessment of operational systems should be planned and agreed between the tester and appropriate management.", + "Name": "Protection of Information Systems During Audit Testing", + "Attributes": [ + { + "Category": "A.8 Technological controls", + "Objetive_ID": "A.8.34", + "Objetive_Name": "Protection of Information Systems During Audit Testing", + "Check_Summary": "Audit tests and other assurance activities involving assessment of operational systems should be planned and agreed between the tester and appropriate management." + } + ], + "Checks": [ + "exchange_organization_mailbox_auditing_enabled", + "exchange_mailbox_audit_bypass_disabled", + "exchange_user_mailbox_auditing_enabled", + "purview_audit_log_search_enabled" + ] + } + ] +} diff --git a/prowler/config/aws_mutelist_example.yaml b/prowler/config/aws_mutelist_example.yaml index ad2e5d36d4..9c95ee704d 100644 --- a/prowler/config/aws_mutelist_example.yaml +++ b/prowler/config/aws_mutelist_example.yaml @@ -28,6 +28,7 @@ Mutelist: Tags: - "test=test" # Will ignore every resource containing the string "test" and the tags 'test=test' and - "project=test|project=stage" # either of ('project=test' OR project=stage) in account 123456789012 and every region + - "environment=prod" # Will ignore every resource except in account 123456789012 except the ones containing the string "test" and tag environment=prod "*": Checks: @@ -46,9 +47,6 @@ Mutelist: - "*" Tags: - "environment=dev" # Will ignore every resource containing the tag 'environment=dev' in every account and region - - "*": - Checks: "ecs_task_definitions_no_environment_secrets": Regions: - "*" @@ -60,16 +58,3 @@ Mutelist: Regions: - "eu-west-1" - "eu-south-2" # Will ignore every resource in check ecs_task_definitions_no_environment_secrets except the ones in account 0123456789012 located in eu-south-2 or eu-west-1 - - "123456789012": - Checks: - "*": - Regions: - - "*" - Resources: - - "*" - Exceptions: - Resources: - - "test" - Tags: - - "environment=prod" # Will ignore every resource except in account 123456789012 except the ones containing the string "test" and tag environment=prod diff --git a/prowler/config/config.py b/prowler/config/config.py index de54add886..dbbe71db9e 100644 --- a/prowler/config/config.py +++ b/prowler/config/config.py @@ -30,6 +30,7 @@ class Provider(str, Enum): KUBERNETES = "kubernetes" M365 = "m365" GITHUB = "github" + IAC = "iac" NHN = "nhn" diff --git a/prowler/config/config.yaml b/prowler/config/config.yaml index 1385ffef4f..37d3388e6b 100644 --- a/prowler/config/config.yaml +++ b/prowler/config/config.yaml @@ -417,6 +417,11 @@ aws: {"name": "TwilioKeyDetector"}, ] + # AWS CodeBuild Configuration + # aws.codebuild_project_uses_allowed_github_organizations + codebuild_github_allowed_organizations: + [ + ] # Azure Configuration azure: diff --git a/prowler/config/m365_mutelist_example.yaml b/prowler/config/m365_mutelist_example.yaml index 34fda956e0..fea88df6e2 100644 --- a/prowler/config/m365_mutelist_example.yaml +++ b/prowler/config/m365_mutelist_example.yaml @@ -28,9 +28,6 @@ Mutelist: Tags: - "test=test" # Will ignore every resource containing the string "test" and the tags 'test=test' and - "project=test|project=stage" # either of ('project=test' OR project=stage) in Azure subscription 1 and every location - - "*": - Checks: "admincenter_*": Regions: - "*" diff --git a/prowler/lib/check/checks_loader.py b/prowler/lib/check/checks_loader.py index f75e9672b0..f45b0e917d 100644 --- a/prowler/lib/check/checks_loader.py +++ b/prowler/lib/check/checks_loader.py @@ -20,6 +20,10 @@ def load_checks_to_execute( ) -> set: """Generate the list of checks to execute based on the cloud provider and the input arguments given""" try: + # Bypass check loading for IAC provider since it uses Checkov directly + if provider == "iac": + return set() + # Local subsets checks_to_execute = set() check_aliases = {} diff --git a/prowler/lib/check/compliance_models.py b/prowler/lib/check/compliance_models.py index 5e01e590f3..0d0200ba63 100644 --- a/prowler/lib/check/compliance_models.py +++ b/prowler/lib/check/compliance_models.py @@ -3,7 +3,7 @@ import sys from enum import Enum from typing import Optional, Union -from pydantic import BaseModel, ValidationError, root_validator +from pydantic.v1 import BaseModel, ValidationError, root_validator from prowler.lib.check.utils import list_compliance_modules from prowler.lib.logger import logger @@ -56,22 +56,26 @@ class ENS_Requirement_Attribute(BaseModel): class Generic_Compliance_Requirement_Attribute(BaseModel): """Generic Compliance Requirement Attribute""" - ItemId: Optional[str] - Section: Optional[str] - SubSection: Optional[str] - SubGroup: Optional[str] - Service: Optional[str] - Type: Optional[str] + ItemId: Optional[str] = None + Section: Optional[str] = None + SubSection: Optional[str] = None + SubGroup: Optional[str] = None + Service: Optional[str] = None + Type: Optional[str] = None -class CIS_Requirement_Attribute_Profile(str): +class CIS_Requirement_Attribute_Profile(str, Enum): """CIS Requirement Attribute Profile""" Level_1 = "Level 1" Level_2 = "Level 2" + E3_Level_1 = "E3 Level 1" + E3_Level_2 = "E3 Level 2" + E5_Level_1 = "E5 Level 1" + E5_Level_2 = "E5 Level 2" -class CIS_Requirement_Attribute_AssessmentStatus(str): +class CIS_Requirement_Attribute_AssessmentStatus(str, Enum): """CIS Requirement Attribute Assessment Status""" Manual = "Manual" @@ -83,7 +87,7 @@ class CIS_Requirement_Attribute(BaseModel): """CIS Requirement Attribute""" Section: str - SubSection: Optional[str] + SubSection: Optional[str] = None Profile: CIS_Requirement_Attribute_Profile AssessmentStatus: CIS_Requirement_Attribute_AssessmentStatus Description: str @@ -92,7 +96,7 @@ class CIS_Requirement_Attribute(BaseModel): RemediationProcedure: str AuditProcedure: str AdditionalInformation: str - DefaultValue: Optional[str] + DefaultValue: Optional[str] = None References: str @@ -104,7 +108,7 @@ class AWS_Well_Architected_Requirement_Attribute(BaseModel): WellArchitectedQuestionId: str WellArchitectedPracticeId: str Section: str - SubSection: Optional[str] + SubSection: Optional[str] = None LevelOfRisk: str AssessmentMethod: str Description: str @@ -177,10 +181,10 @@ class KISA_ISMSP_Requirement_Attribute(BaseModel): Domain: str Subdomain: str Section: str - AuditChecklist: Optional[list[str]] - RelatedRegulations: Optional[list[str]] - AuditEvidence: Optional[list[str]] - NonComplianceCases: Optional[list[str]] + AuditChecklist: Optional[list[str]] = None + RelatedRegulations: Optional[list[str]] = None + AuditEvidence: Optional[list[str]] = None + NonComplianceCases: Optional[list[str]] = None # Prowler ThreatScore Requirement Attribute @@ -203,7 +207,7 @@ class Compliance_Requirement(BaseModel): Id: str Description: str - Name: Optional[str] + Name: Optional[str] = None Attributes: list[ Union[ CIS_Requirement_Attribute, @@ -224,7 +228,7 @@ class Compliance(BaseModel): Framework: str Provider: str - Version: Optional[str] + Version: Optional[str] = None Description: str Requirements: list[ Union[ diff --git a/prowler/lib/check/models.py b/prowler/lib/check/models.py index 9c8ee40eb6..bbd5b8ccc1 100644 --- a/prowler/lib/check/models.py +++ b/prowler/lib/check/models.py @@ -5,9 +5,10 @@ import sys from abc import ABC, abstractmethod from dataclasses import asdict, dataclass, is_dataclass from enum import Enum -from typing import Any, Dict, Set +from typing import Any, Dict, Optional, Set -from pydantic import BaseModel, ValidationError, validator +from checkov.common.output.record import Record +from pydantic.v1 import BaseModel, ValidationError, validator from prowler.config.config import Provider from prowler.lib.check.compliance_models import Compliance @@ -96,6 +97,7 @@ class CheckMetadata(BaseModel): severity_to_lower(severity): Validator function to convert the severity to lowercase. valid_severity(severity): Validator function to validate the severity of the check. valid_cli_command(remediation): Validator function to validate the CLI command is not an URL. + valid_resource_type(resource_type): Validator function to validate the resource type is not empty. """ Provider: str @@ -118,7 +120,7 @@ class CheckMetadata(BaseModel): Notes: str # We set the compliance to None to # store the compliance later if supplied - Compliance: list = None + Compliance: Optional[list[Any]] = [] @validator("Categories", each_item=True, pre=True, always=True) def valid_category(value): @@ -141,6 +143,12 @@ class CheckMetadata(BaseModel): raise ValueError("CLI command cannot be an URL") return remediation + @validator("ResourceType", pre=True, always=True) + def valid_resource_type(resource_type): + if not resource_type or not isinstance(resource_type, str): + raise ValueError("ResourceType must be a non-empty string") + return resource_type + @staticmethod def get_bulk(provider: str) -> dict[str, "CheckMetadata"]: """ @@ -434,6 +442,8 @@ class Check_Report: self.resource = resource.to_dict() elif is_dataclass(resource): self.resource = asdict(resource) + elif hasattr(resource, "__dict__"): + self.resource = resource.__dict__ else: logger.error( f"Resource metadata {type(resource)} in {self.check_metadata.CheckID} could not be converted to dict" @@ -512,7 +522,11 @@ class Check_Report_GCP(Check_Report): or getattr(resource, "name", None) or "" ) - self.resource_name = resource_name or getattr(resource, "name", "") + self.resource_name = ( + resource_name + or getattr(resource, "name", "") + or getattr(resource, "id", "") + ) self.project_id = project_id or getattr(resource, "project_id", "") self.location = ( location @@ -607,6 +621,28 @@ class CheckReportM365(Check_Report): self.location = resource_location +@dataclass +class CheckReportIAC(Check_Report): + """Contains the IAC Check's finding information using Checkov.""" + + resource_name: str + resource_path: str + resource_line_range: str + + def __init__(self, metadata: dict = {}, resource: Record = None) -> None: + """ + Initialize the IAC Check's finding information from a Checkov failed_check dict. + + Args: + metadata (Dict): Optional check metadata (can be None). + failed_check (dict): A single failed_check result from Checkov's JSON output. + """ + super().__init__(metadata, resource) + self.resource_name = resource.resource + self.resource_path = resource.file_path + self.resource_line_range = resource.file_line_range + + @dataclass class CheckReportNHN(Check_Report): """Contains the NHN Check's finding information.""" @@ -646,7 +682,6 @@ def load_check_metadata(metadata_file: str) -> CheckMetadata: check_metadata = CheckMetadata.parse_file(metadata_file) except ValidationError as error: logger.critical(f"Metadata from {metadata_file} is not valid: {error}") - # TODO: remove this exit and raise an exception - sys.exit(1) + raise error else: return check_metadata diff --git a/prowler/lib/check/utils.py b/prowler/lib/check/utils.py index c9e6d6de00..bf8854600d 100644 --- a/prowler/lib/check/utils.py +++ b/prowler/lib/check/utils.py @@ -14,6 +14,10 @@ def recover_checks_from_provider( Returns a list of tuples with the following format (check_name, check_path) """ try: + # Bypass check loading for IAC provider since it uses Checkov directly + if provider == "iac": + return [] + checks = [] modules = list_modules(provider, service) for module_name in modules: @@ -59,6 +63,10 @@ def recover_checks_from_service(service_list: list, provider: str) -> set: Returns a set of checks from the given services """ try: + # Bypass check loading for IAC provider since it uses Checkov directly + if provider == "iac": + return set() + checks = set() service_list = [ "awslambda" if service == "lambda" else service for service in service_list diff --git a/prowler/lib/cli/parser.py b/prowler/lib/cli/parser.py index 746ec4e92a..02039c3c71 100644 --- a/prowler/lib/cli/parser.py +++ b/prowler/lib/cli/parser.py @@ -26,16 +26,17 @@ class ProwlerArgumentParser: self.parser = argparse.ArgumentParser( prog="prowler", formatter_class=RawTextHelpFormatter, - usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,dashboard} ...", + usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,dashboard,iac} ...", epilog=""" Available Cloud Providers: - {aws,azure,gcp,kubernetes,m365,nhn} + {aws,azure,gcp,kubernetes,m365,github,iac,nhn} aws AWS Provider azure Azure Provider gcp GCP Provider kubernetes Kubernetes Provider m365 Microsoft 365 Provider github GitHub Provider + iac IaC Provider (Preview) nhn NHN Provider (Unofficial) Available components: diff --git a/prowler/lib/mutelist/mutelist.py b/prowler/lib/mutelist/mutelist.py index 4940d0202f..f76d51a39f 100644 --- a/prowler/lib/mutelist/mutelist.py +++ b/prowler/lib/mutelist/mutelist.py @@ -439,12 +439,13 @@ class Mutelist(ABC): return False @staticmethod - def validate_mutelist(mutelist: dict) -> dict: + def validate_mutelist(mutelist: dict, raise_on_exception: bool = False) -> dict: """ Validate the mutelist against the schema. Args: mutelist (dict): The mutelist to be validated. + raise_on_exception (bool): Whether to raise an exception if the mutelist is invalid. Returns: dict: The mutelist itself. @@ -453,7 +454,10 @@ class Mutelist(ABC): 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}]" - ) + if raise_on_exception: + raise error + else: + logger.error( + f"{error.__class__.__name__} -- Mutelist YAML is malformed - {error}[{error.__traceback__.tb_lineno}]" + ) return {} diff --git a/prowler/lib/outputs/asff/asff.py b/prowler/lib/outputs/asff/asff.py index ceb81e9ca2..a5216b36ee 100644 --- a/prowler/lib/outputs/asff/asff.py +++ b/prowler/lib/outputs/asff/asff.py @@ -2,7 +2,7 @@ from json import dump from os import SEEK_SET from typing import Optional -from pydantic import BaseModel, validator +from pydantic.v1 import BaseModel, validator from prowler.config.config import prowler_version, timestamp_utc from prowler.lib.logger import logger @@ -279,7 +279,7 @@ class Resource(BaseModel): Id: str Partition: str Region: str - Tags: Optional[dict] + Tags: Optional[dict] = None @validator("Tags", pre=True, always=True) def tags_cannot_be_empty_dict(tags): diff --git a/prowler/lib/outputs/compliance/aws_well_architected/aws_well_architected.py b/prowler/lib/outputs/compliance/aws_well_architected/aws_well_architected.py index 560e1b9f8d..25b2ca464d 100644 --- a/prowler/lib/outputs/compliance/aws_well_architected/aws_well_architected.py +++ b/prowler/lib/outputs/compliance/aws_well_architected/aws_well_architected.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.aws_well_architected.models import ( AWSWellArchitectedModel, @@ -46,7 +47,7 @@ class AWSWellArchitected(ComplianceOutput): Description=compliance.Description, AccountId=finding.account_uid, Region=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Name=attribute.Name, @@ -75,7 +76,7 @@ class AWSWellArchitected(ComplianceOutput): Description=compliance.Description, AccountId="", Region="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Name=attribute.Name, diff --git a/prowler/lib/outputs/compliance/aws_well_architected/models.py b/prowler/lib/outputs/compliance/aws_well_architected/models.py index 3cb75fce9b..2a2abbb06c 100644 --- a/prowler/lib/outputs/compliance/aws_well_architected/models.py +++ b/prowler/lib/outputs/compliance/aws_well_architected/models.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel class AWSWellArchitectedModel(BaseModel): @@ -19,7 +19,7 @@ class AWSWellArchitectedModel(BaseModel): Requirements_Attributes_WellArchitectedQuestionId: str Requirements_Attributes_WellArchitectedPracticeId: str Requirements_Attributes_Section: str - Requirements_Attributes_SubSection: Optional[str] + Requirements_Attributes_SubSection: Optional[str] = None Requirements_Attributes_LevelOfRisk: str Requirements_Attributes_AssessmentMethod: str Requirements_Attributes_Description: str diff --git a/prowler/lib/outputs/compliance/cis/cis_aws.py b/prowler/lib/outputs/compliance/cis/cis_aws.py index 3f4b2c8cde..bb2e71fc4c 100644 --- a/prowler/lib/outputs/compliance/cis/cis_aws.py +++ b/prowler/lib/outputs/compliance/cis/cis_aws.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import AWSCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -44,7 +45,7 @@ class AWSCIS(ComplianceOutput): Description=compliance.Description, AccountId=finding.account_uid, Region=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, @@ -76,7 +77,7 @@ class AWSCIS(ComplianceOutput): Description=compliance.Description, AccountId="", Region="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, diff --git a/prowler/lib/outputs/compliance/cis/cis_azure.py b/prowler/lib/outputs/compliance/cis/cis_azure.py index 942f4a6a6f..155eb8672c 100644 --- a/prowler/lib/outputs/compliance/cis/cis_azure.py +++ b/prowler/lib/outputs/compliance/cis/cis_azure.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import AzureCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -44,7 +45,7 @@ class AzureCIS(ComplianceOutput): Description=compliance.Description, SubscriptionId=finding.account_uid, Location=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, @@ -76,7 +77,7 @@ class AzureCIS(ComplianceOutput): Description=compliance.Description, SubscriptionId="", Location="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, diff --git a/prowler/lib/outputs/compliance/cis/cis_gcp.py b/prowler/lib/outputs/compliance/cis/cis_gcp.py index 573ead30cd..d3d587a081 100644 --- a/prowler/lib/outputs/compliance/cis/cis_gcp.py +++ b/prowler/lib/outputs/compliance/cis/cis_gcp.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import GCPCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -44,7 +45,7 @@ class GCPCIS(ComplianceOutput): Description=compliance.Description, ProjectId=finding.account_uid, Location=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, @@ -75,7 +76,7 @@ class GCPCIS(ComplianceOutput): Description=compliance.Description, ProjectId="", Location="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, diff --git a/prowler/lib/outputs/compliance/cis/cis_github.py b/prowler/lib/outputs/compliance/cis/cis_github.py index c06a766a05..442644adcc 100644 --- a/prowler/lib/outputs/compliance/cis/cis_github.py +++ b/prowler/lib/outputs/compliance/cis/cis_github.py @@ -1,5 +1,4 @@ -from datetime import datetime - +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import GithubCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -46,7 +45,7 @@ class GithubCIS(ComplianceOutput): Description=compliance.Description, Account_Id=finding.account_uid, Account_Name=finding.account_name, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, @@ -77,7 +76,7 @@ class GithubCIS(ComplianceOutput): Description=compliance.Description, Account_Id="", Account_Name="", - AssessmentDate=str(datetime.now()), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, diff --git a/prowler/lib/outputs/compliance/cis/cis_kubernetes.py b/prowler/lib/outputs/compliance/cis/cis_kubernetes.py index 2850a0f475..47ae0f7fb4 100644 --- a/prowler/lib/outputs/compliance/cis/cis_kubernetes.py +++ b/prowler/lib/outputs/compliance/cis/cis_kubernetes.py @@ -1,5 +1,4 @@ -from datetime import datetime - +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import KubernetesCISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -46,7 +45,7 @@ class KubernetesCIS(ComplianceOutput): Description=compliance.Description, Context=finding.account_name, Namespace=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, @@ -78,7 +77,7 @@ class KubernetesCIS(ComplianceOutput): Description=compliance.Description, Context="", Namespace="", - AssessmentDate=str(datetime.now()), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, diff --git a/prowler/lib/outputs/compliance/cis/cis_m365.py b/prowler/lib/outputs/compliance/cis/cis_m365.py index addc78a104..0f62e8910c 100644 --- a/prowler/lib/outputs/compliance/cis/cis_m365.py +++ b/prowler/lib/outputs/compliance/cis/cis_m365.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.cis.models import M365CISModel from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput @@ -44,7 +45,7 @@ class M365CIS(ComplianceOutput): Description=compliance.Description, TenantId=finding.account_uid, Location=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, @@ -76,7 +77,7 @@ class M365CIS(ComplianceOutput): Description=compliance.Description, TenantId=finding.account_uid, Location=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, diff --git a/prowler/lib/outputs/compliance/cis/models.py b/prowler/lib/outputs/compliance/cis/models.py index 1a4764c294..9bf51c6430 100644 --- a/prowler/lib/outputs/compliance/cis/models.py +++ b/prowler/lib/outputs/compliance/cis/models.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel class AWSCISModel(BaseModel): @@ -16,7 +16,7 @@ class AWSCISModel(BaseModel): Requirements_Id: str Requirements_Description: str Requirements_Attributes_Section: str - Requirements_Attributes_SubSection: Optional[str] + Requirements_Attributes_SubSection: Optional[str] = None Requirements_Attributes_Profile: str Requirements_Attributes_AssessmentStatus: str Requirements_Attributes_Description: str @@ -25,9 +25,9 @@ class AWSCISModel(BaseModel): Requirements_Attributes_RemediationProcedure: str Requirements_Attributes_AuditProcedure: str Requirements_Attributes_AdditionalInformation: str - Requirements_Attributes_DefaultValue: Optional[ - str - ] # TODO Optional for now since it's not present in the CIS 1.5, 2.0 and 3.0 AWS benchmark + Requirements_Attributes_DefaultValue: Optional[str] = ( + None # TODO Optional for now since it's not present in the CIS 1.5, 2.0 and 3.0 AWS benchmark + ) Requirements_Attributes_References: str Status: str StatusExtended: str @@ -50,7 +50,7 @@ class AzureCISModel(BaseModel): Requirements_Id: str Requirements_Description: str Requirements_Attributes_Section: str - Requirements_Attributes_SubSection: Optional[str] + Requirements_Attributes_SubSection: Optional[str] = None Requirements_Attributes_Profile: str Requirements_Attributes_AssessmentStatus: str Requirements_Attributes_Description: str @@ -82,7 +82,7 @@ class M365CISModel(BaseModel): Requirements_Id: str Requirements_Description: str Requirements_Attributes_Section: str - Requirements_Attributes_SubSection: Optional[str] + Requirements_Attributes_SubSection: Optional[str] = None Requirements_Attributes_Profile: str Requirements_Attributes_AssessmentStatus: str Requirements_Attributes_Description: str @@ -114,7 +114,7 @@ class GCPCISModel(BaseModel): Requirements_Id: str Requirements_Description: str Requirements_Attributes_Section: str - Requirements_Attributes_SubSection: Optional[str] + Requirements_Attributes_SubSection: Optional[str] = None Requirements_Attributes_Profile: str Requirements_Attributes_AssessmentStatus: str Requirements_Attributes_Description: str @@ -145,8 +145,8 @@ class KubernetesCISModel(BaseModel): Requirements_Id: str Requirements_Description: str Requirements_Attributes_Section: str - Requirements_Attributes_SubSection: Optional[str] - Requirements_Attributes_Profile: str + Requirements_Attributes_SubSection: Optional[str] = None + Requirements_Attributes_Profile: Optional[str] = None Requirements_Attributes_AssessmentStatus: str Requirements_Attributes_Description: str Requirements_Attributes_RationaleStatement: str diff --git a/prowler/lib/outputs/compliance/ens/ens_aws.py b/prowler/lib/outputs/compliance/ens/ens_aws.py index f6d3fa5b21..e1ddffe112 100644 --- a/prowler/lib/outputs/compliance/ens/ens_aws.py +++ b/prowler/lib/outputs/compliance/ens/ens_aws.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.ens.models import AWSENSModel @@ -44,7 +45,7 @@ class AWSENS(ComplianceOutput): Description=compliance.Description, AccountId=finding.account_uid, Region=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_IdGrupoControl=attribute.IdGrupoControl, @@ -77,7 +78,7 @@ class AWSENS(ComplianceOutput): Description=compliance.Description, AccountId="", Region="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_IdGrupoControl=attribute.IdGrupoControl, diff --git a/prowler/lib/outputs/compliance/ens/ens_azure.py b/prowler/lib/outputs/compliance/ens/ens_azure.py index 53992ef03a..30ce792364 100644 --- a/prowler/lib/outputs/compliance/ens/ens_azure.py +++ b/prowler/lib/outputs/compliance/ens/ens_azure.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.ens.models import AzureENSModel @@ -44,7 +45,7 @@ class AzureENS(ComplianceOutput): Description=compliance.Description, SubscriptionId=finding.account_name, Location=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_IdGrupoControl=attribute.IdGrupoControl, @@ -77,7 +78,7 @@ class AzureENS(ComplianceOutput): Description=compliance.Description, SubscriptionId="", Location="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_IdGrupoControl=attribute.IdGrupoControl, diff --git a/prowler/lib/outputs/compliance/ens/ens_gcp.py b/prowler/lib/outputs/compliance/ens/ens_gcp.py index 7c719436ac..9f3cd43da7 100644 --- a/prowler/lib/outputs/compliance/ens/ens_gcp.py +++ b/prowler/lib/outputs/compliance/ens/ens_gcp.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.ens.models import GCPENSModel @@ -44,7 +45,7 @@ class GCPENS(ComplianceOutput): Description=compliance.Description, ProjectId=finding.account_uid, Location=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_IdGrupoControl=attribute.IdGrupoControl, @@ -77,7 +78,7 @@ class GCPENS(ComplianceOutput): Description=compliance.Description, ProjectId="", Location="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_IdGrupoControl=attribute.IdGrupoControl, diff --git a/prowler/lib/outputs/compliance/ens/models.py b/prowler/lib/outputs/compliance/ens/models.py index 6ff2b9e52f..8f9a6ad03a 100644 --- a/prowler/lib/outputs/compliance/ens/models.py +++ b/prowler/lib/outputs/compliance/ens/models.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel class AWSENSModel(BaseModel): diff --git a/prowler/lib/outputs/compliance/generic/generic.py b/prowler/lib/outputs/compliance/generic/generic.py index 390e47e899..a6a3677454 100644 --- a/prowler/lib/outputs/compliance/generic/generic.py +++ b/prowler/lib/outputs/compliance/generic/generic.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.generic.models import GenericComplianceModel @@ -44,7 +45,7 @@ class GenericCompliance(ComplianceOutput): Description=compliance.Description, AccountId=finding.account_uid, Region=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, @@ -69,7 +70,7 @@ class GenericCompliance(ComplianceOutput): Description=compliance.Description, AccountId="", Region="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Section=attribute.Section, diff --git a/prowler/lib/outputs/compliance/generic/models.py b/prowler/lib/outputs/compliance/generic/models.py index 8cd38ec49a..900066478d 100644 --- a/prowler/lib/outputs/compliance/generic/models.py +++ b/prowler/lib/outputs/compliance/generic/models.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel class GenericComplianceModel(BaseModel): @@ -15,11 +15,11 @@ class GenericComplianceModel(BaseModel): AssessmentDate: str Requirements_Id: str Requirements_Description: str - Requirements_Attributes_Section: Optional[str] - Requirements_Attributes_SubSection: Optional[str] - Requirements_Attributes_SubGroup: Optional[str] - Requirements_Attributes_Service: Optional[str] - Requirements_Attributes_Type: Optional[str] + Requirements_Attributes_Section: Optional[str] = None + Requirements_Attributes_SubSection: Optional[str] = None + Requirements_Attributes_SubGroup: Optional[str] = None + Requirements_Attributes_Service: Optional[str] = None + Requirements_Attributes_Type: Optional[str] = None Status: str StatusExtended: str ResourceId: str diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_aws.py b/prowler/lib/outputs/compliance/iso27001/iso27001_aws.py index 807d559243..2c64390967 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_aws.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_aws.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import AWSISO27001Model @@ -44,7 +45,7 @@ class AWSISO27001(ComplianceOutput): Description=compliance.Description, AccountId=finding.account_uid, Region=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Name=requirement.Name, Requirements_Description=requirement.Description, @@ -69,7 +70,7 @@ class AWSISO27001(ComplianceOutput): Description=compliance.Description, AccountId="", Region="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Name=requirement.Name, Requirements_Description=requirement.Description, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_azure.py b/prowler/lib/outputs/compliance/iso27001/iso27001_azure.py index 58aff0d348..c88ede4566 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_azure.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_azure.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import AzureISO27001Model @@ -44,7 +45,7 @@ class AzureISO27001(ComplianceOutput): Description=compliance.Description, SubscriptionId=finding.account_uid, Location=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Name=requirement.Name, @@ -69,7 +70,7 @@ class AzureISO27001(ComplianceOutput): Description=compliance.Description, SubscriptionId="", Location="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Name=requirement.Name, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_gcp.py b/prowler/lib/outputs/compliance/iso27001/iso27001_gcp.py index d7aadb268a..e0b625a807 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_gcp.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_gcp.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import GCPISO27001Model @@ -44,7 +45,7 @@ class GCPISO27001(ComplianceOutput): Description=compliance.Description, ProjectId=finding.account_uid, Location=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Name=requirement.Name, @@ -69,7 +70,7 @@ class GCPISO27001(ComplianceOutput): Description=compliance.Description, ProjectId="", Location="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Name=requirement.Name, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py b/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py index d3890c408c..b45d2d87e9 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_kubernetes.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import KubernetesISO27001Model @@ -44,7 +45,7 @@ class KubernetesISO27001(ComplianceOutput): Description=compliance.Description, Context=finding.account_name, Namespace=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Name=requirement.Name, @@ -69,7 +70,7 @@ class KubernetesISO27001(ComplianceOutput): Description=compliance.Description, Context="", Namespace="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Name=requirement.Name, diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_m365.py b/prowler/lib/outputs/compliance/iso27001/iso27001_m365.py new file mode 100644 index 0000000000..101d61e7be --- /dev/null +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_m365.py @@ -0,0 +1,88 @@ +from prowler.config.config import timestamp +from prowler.lib.check.compliance_models import Compliance +from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput +from prowler.lib.outputs.compliance.iso27001.models import M365ISO27001Model +from prowler.lib.outputs.finding import Finding + + +class M365ISO27001(ComplianceOutput): + """ + This class represents the M365 ISO 27001 compliance output. + + Attributes: + - _data (list): A list to store transformed data from findings. + - _file_descriptor (TextIOWrapper): A file descriptor to write data to a file. + + Methods: + - transform: Transforms findings into M365 ISO 27001 compliance format. + """ + + def transform( + self, + findings: list[Finding], + compliance: Compliance, + compliance_name: str, + ) -> None: + """ + Transforms a list of findings into M365 ISO 27001 compliance format. + + Parameters: + - findings (list): A list of findings. + - compliance (Compliance): A compliance model. + - compliance_name (str): The name of the compliance model. + + Returns: + - None + """ + for finding in findings: + finding_requirements = finding.compliance.get(compliance_name, []) + for requirement in compliance.Requirements: + if requirement.Id in finding_requirements: + for attribute in requirement.Attributes: + compliance_row = M365ISO27001Model( + Provider=finding.provider, + Description=compliance.Description, + TenantId=finding.account_uid, + Location=finding.region, + AssessmentDate=str(timestamp), + Requirements_Id=requirement.Id, + Requirements_Description=requirement.Description, + Requirements_Name=requirement.Name, + Requirements_Attributes_Category=attribute.Category, + Requirements_Attributes_Objetive_ID=attribute.Objetive_ID, + Requirements_Attributes_Objetive_Name=attribute.Objetive_Name, + Requirements_Attributes_Check_Summary=attribute.Check_Summary, + Status=finding.status, + StatusExtended=finding.status_extended, + ResourceId=finding.resource_uid, + CheckId=finding.check_id, + Muted=finding.muted, + ResourceName=finding.resource_name, + ) + self._data.append(compliance_row) + + # Add manual requirements to the compliance output + for requirement in compliance.Requirements: + if not requirement.Checks: + for attribute in requirement.Attributes: + compliance_row = M365ISO27001Model( + Provider=compliance.Provider.lower(), + Description=compliance.Description, + TenantId="", + Location="", + AssessmentDate=str(timestamp), + Requirements_Id=requirement.Id, + Requirements_Description=requirement.Description, + Requirements_Name=requirement.Name, + Requirements_Attributes_Category=attribute.Category, + Requirements_Attributes_Objetive_ID=attribute.Objetive_ID, + Requirements_Attributes_Objetive_Name=attribute.Objetive_Name, + Requirements_Attributes_Check_Summary=attribute.Check_Summary, + Status="MANUAL", + StatusExtended="Manual check", + ResourceId="manual_check", + ResourceName="Manual check", + CheckId="manual", + Muted=False, + ) + self._data.append(compliance_row) diff --git a/prowler/lib/outputs/compliance/iso27001/iso27001_nhn.py b/prowler/lib/outputs/compliance/iso27001/iso27001_nhn.py index 03bbfa7195..8215505471 100644 --- a/prowler/lib/outputs/compliance/iso27001/iso27001_nhn.py +++ b/prowler/lib/outputs/compliance/iso27001/iso27001_nhn.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.iso27001.models import NHNISO27001Model @@ -43,7 +44,7 @@ class NHNISO27001(ComplianceOutput): Description=compliance.Description, AccountId=finding.account_uid, Region=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Name=requirement.Name, @@ -69,7 +70,7 @@ class NHNISO27001(ComplianceOutput): Description=compliance.Description, AccountId="", Region="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Name=requirement.Name, diff --git a/prowler/lib/outputs/compliance/iso27001/models.py b/prowler/lib/outputs/compliance/iso27001/models.py index 16e97a178d..d3b1429103 100644 --- a/prowler/lib/outputs/compliance/iso27001/models.py +++ b/prowler/lib/outputs/compliance/iso27001/models.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel class AWSISO27001Model(BaseModel): @@ -124,3 +124,28 @@ class NHNISO27001Model(BaseModel): CheckId: str Muted: bool ResourceName: str + + +class M365ISO27001Model(BaseModel): + """ + M365ISO27001Model generates a finding's output in CSV M365 ISO27001 format. + """ + + Provider: str + Description: str + TenantId: str + Location: str + AssessmentDate: str + Requirements_Id: str + Requirements_Name: str + Requirements_Description: str + Requirements_Attributes_Category: str + Requirements_Attributes_Objetive_ID: str + Requirements_Attributes_Objetive_Name: str + Requirements_Attributes_Check_Summary: str + Status: str + StatusExtended: str + ResourceId: str + CheckId: str + Muted: bool + ResourceName: str diff --git a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws.py b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws.py index f23aac87b2..738ed7451c 100644 --- a/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws.py +++ b/prowler/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.kisa_ismsp.models import AWSKISAISMSPModel @@ -44,7 +45,7 @@ class AWSKISAISMSP(ComplianceOutput): Description=compliance.Description, AccountId=finding.account_uid, Region=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Name=requirement.Name, Requirements_Description=requirement.Description, @@ -72,7 +73,7 @@ class AWSKISAISMSP(ComplianceOutput): Description=compliance.Description, AccountId="", Region="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Name=requirement.Name, Requirements_Description=requirement.Description, diff --git a/prowler/lib/outputs/compliance/kisa_ismsp/models.py b/prowler/lib/outputs/compliance/kisa_ismsp/models.py index 98b1f00a78..b4ba1d6c76 100644 --- a/prowler/lib/outputs/compliance/kisa_ismsp/models.py +++ b/prowler/lib/outputs/compliance/kisa_ismsp/models.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel class AWSKISAISMSPModel(BaseModel): @@ -19,10 +19,10 @@ class AWSKISAISMSPModel(BaseModel): Requirements_Attributes_Domain: str Requirements_Attributes_Subdomain: str Requirements_Attributes_Section: str - Requirements_Attributes_AuditChecklist: Optional[list[str]] - Requirements_Attributes_RelatedRegulations: Optional[list[str]] - Requirements_Attributes_AuditEvidence: Optional[list[str]] - Requirements_Attributes_NonComplianceCases: Optional[list[str]] + Requirements_Attributes_AuditChecklist: Optional[list[str]] = None + Requirements_Attributes_RelatedRegulations: Optional[list[str]] = None + Requirements_Attributes_AuditEvidence: Optional[list[str]] = None + Requirements_Attributes_NonComplianceCases: Optional[list[str]] = None Status: str StatusExtended: str ResourceId: str diff --git a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_aws.py b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_aws.py index 501d9e0df8..e3fcd40151 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_aws.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_aws.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.mitre_attack.models import AWSMitreAttackModel @@ -44,7 +45,7 @@ class AWSMitreAttack(ComplianceOutput): Description=compliance.Description, AccountId=finding.account_uid, Region=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Name=requirement.Name, Requirements_Description=requirement.Description, @@ -83,7 +84,7 @@ class AWSMitreAttack(ComplianceOutput): Description=compliance.Description, AccountId="", Region="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Name=requirement.Name, Requirements_Description=requirement.Description, diff --git a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_azure.py b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_azure.py index 739aabde70..4bf135aaa6 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_azure.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_azure.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.mitre_attack.models import AzureMitreAttackModel @@ -44,7 +45,7 @@ class AzureMitreAttack(ComplianceOutput): Description=compliance.Description, SubscriptionId=finding.account_uid, Location=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Name=requirement.Name, Requirements_Description=requirement.Description, @@ -84,7 +85,7 @@ class AzureMitreAttack(ComplianceOutput): Description=compliance.Description, SubscriptionId="", Location="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Name=requirement.Name, Requirements_Description=requirement.Description, diff --git a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_gcp.py b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_gcp.py index 46754a4216..0c8e682510 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_gcp.py +++ b/prowler/lib/outputs/compliance/mitre_attack/mitre_attack_gcp.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.mitre_attack.models import GCPMitreAttackModel @@ -44,7 +45,7 @@ class GCPMitreAttack(ComplianceOutput): Description=compliance.Description, ProjectId=finding.account_uid, Location=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Name=requirement.Name, Requirements_Description=requirement.Description, @@ -83,7 +84,7 @@ class GCPMitreAttack(ComplianceOutput): Description=compliance.Description, ProjectId="", Location="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Name=requirement.Name, Requirements_Description=requirement.Description, diff --git a/prowler/lib/outputs/compliance/mitre_attack/models.py b/prowler/lib/outputs/compliance/mitre_attack/models.py index e01ffeeae7..4b304dd151 100644 --- a/prowler/lib/outputs/compliance/mitre_attack/models.py +++ b/prowler/lib/outputs/compliance/mitre_attack/models.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel class AWSMitreAttackModel(BaseModel): diff --git a/prowler/lib/outputs/compliance/prowler_threatscore/models.py b/prowler/lib/outputs/compliance/prowler_threatscore/models.py index c8ac0dd783..363ea40f3f 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/models.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/models.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel class ProwlerThreatScoreAWSModel(BaseModel): @@ -17,7 +17,7 @@ class ProwlerThreatScoreAWSModel(BaseModel): Requirements_Description: str Requirements_Attributes_Title: str Requirements_Attributes_Section: str - Requirements_Attributes_SubSection: Optional[str] + Requirements_Attributes_SubSection: Optional[str] = None Requirements_Attributes_AttributeDescription: str Requirements_Attributes_AdditionalInformation: str Requirements_Attributes_LevelOfRisk: int @@ -44,7 +44,7 @@ class ProwlerThreatScoreAzureModel(BaseModel): Requirements_Description: str Requirements_Attributes_Title: str Requirements_Attributes_Section: str - Requirements_Attributes_SubSection: Optional[str] + Requirements_Attributes_SubSection: Optional[str] = None Requirements_Attributes_AttributeDescription: str Requirements_Attributes_AdditionalInformation: str Requirements_Attributes_LevelOfRisk: int @@ -71,7 +71,7 @@ class ProwlerThreatScoreGCPModel(BaseModel): Requirements_Description: str Requirements_Attributes_Title: str Requirements_Attributes_Section: str - Requirements_Attributes_SubSection: Optional[str] + Requirements_Attributes_SubSection: Optional[str] = None Requirements_Attributes_AttributeDescription: str Requirements_Attributes_AdditionalInformation: str Requirements_Attributes_LevelOfRisk: int @@ -98,7 +98,7 @@ class ProwlerThreatScoreM365Model(BaseModel): Requirements_Description: str Requirements_Attributes_Title: str Requirements_Attributes_Section: str - Requirements_Attributes_SubSection: Optional[str] + Requirements_Attributes_SubSection: Optional[str] = None Requirements_Attributes_AttributeDescription: str Requirements_Attributes_AdditionalInformation: str Requirements_Attributes_LevelOfRisk: int 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 b4021646ca..88ba93e5c7 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_aws.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.prowler_threatscore.models import ( @@ -46,7 +47,7 @@ class ProwlerThreatScoreAWS(ComplianceOutput): Description=compliance.Description, AccountId=finding.account_uid, Region=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Title=attribute.Title, @@ -73,7 +74,7 @@ class ProwlerThreatScoreAWS(ComplianceOutput): Description=compliance.Description, AccountId="", Region="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Title=attribute.Title, 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 4671666ba0..5fa1dd01be 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_azure.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.prowler_threatscore.models import ( @@ -46,7 +47,7 @@ class ProwlerThreatScoreAzure(ComplianceOutput): Description=compliance.Description, SubscriptionId=finding.account_uid, Location=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Title=attribute.Title, @@ -73,7 +74,7 @@ class ProwlerThreatScoreAzure(ComplianceOutput): Description=compliance.Description, SubscriptionId="", Location="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Title=attribute.Title, 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 0d57ce0ba7..45800d405b 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_gcp.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.prowler_threatscore.models import ( @@ -46,7 +47,7 @@ class ProwlerThreatScoreGCP(ComplianceOutput): Description=compliance.Description, ProjectId=finding.account_uid, Location=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Title=attribute.Title, @@ -73,7 +74,7 @@ class ProwlerThreatScoreGCP(ComplianceOutput): Description=compliance.Description, ProjectId="", Location="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Title=attribute.Title, 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 f4ff630572..0659a7cc45 100644 --- a/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365.py +++ b/prowler/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365.py @@ -1,3 +1,4 @@ +from prowler.config.config import timestamp from prowler.lib.check.compliance_models import Compliance from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput from prowler.lib.outputs.compliance.prowler_threatscore.models import ( @@ -46,7 +47,7 @@ class ProwlerThreatScoreM365(ComplianceOutput): Description=compliance.Description, TenantId=finding.account_uid, Location=finding.region, - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Title=attribute.Title, @@ -73,7 +74,7 @@ class ProwlerThreatScoreM365(ComplianceOutput): Description=compliance.Description, TenantId="", Location="", - AssessmentDate=str(finding.timestamp), + AssessmentDate=str(timestamp), Requirements_Id=requirement.Id, Requirements_Description=requirement.Description, Requirements_Attributes_Title=attribute.Title, diff --git a/prowler/lib/outputs/finding.py b/prowler/lib/outputs/finding.py index ffa0a2f44e..433f04b041 100644 --- a/prowler/lib/outputs/finding.py +++ b/prowler/lib/outputs/finding.py @@ -3,7 +3,7 @@ from datetime import datetime from types import SimpleNamespace from typing import Optional, Union -from pydantic import BaseModel, Field, ValidationError +from pydantic.v1 import BaseModel, Field, ValidationError from prowler.config.config import prowler_version from prowler.lib.check.models import ( @@ -38,7 +38,7 @@ class Finding(BaseModel): account_organization_uid: Optional[str] = None account_organization_name: Optional[str] = None metadata: CheckMetadata - account_tags: dict = {} + account_tags: dict = Field(default_factory=dict) uid: str status: Status status_extended: str @@ -50,7 +50,7 @@ class Finding(BaseModel): resource_tags: dict = Field(default_factory=dict) partition: Optional[str] = None region: str - compliance: dict + compliance: dict = Field(default_factory=dict) prowler_version: str = prowler_version raw: dict = Field(default_factory=dict) @@ -282,6 +282,18 @@ class Finding(BaseModel): output_data["resource_uid"] = check_output.resource_id output_data["region"] = check_output.location + elif provider.type == "iac": + output_data["auth_method"] = "local" # Until we support remote repos + output_data["account_uid"] = "iac" + output_data["account_name"] = "iac" + output_data["resource_name"] = check_output.resource_name + output_data["resource_uid"] = check_output.resource_name + output_data["region"] = check_output.resource_path + output_data["resource_line_range"] = check_output.resource_line_range + output_data["framework"] = ( + check_output.check_metadata.ServiceName + ) # TODO: can we get the framework from the check_output? + # check_output Unique ID # TODO: move this to a function # TODO: in Azure, GCP and K8s there are findings without resource_name diff --git a/prowler/lib/outputs/html/html.py b/prowler/lib/outputs/html/html.py index f775a00d3d..6c54501640 100644 --- a/prowler/lib/outputs/html/html.py +++ b/prowler/lib/outputs/html/html.py @@ -41,7 +41,7 @@ class HTML(Output): {finding_status} {finding.metadata.Severity.value} {finding.metadata.ServiceName} - {finding.region.lower()} + {":".join([finding.resource_metadata['file_path'], "-".join(map(str, finding.resource_metadata['file_line_range']))]) if finding.metadata.Provider == "iac" else finding.region.lower()} {finding.metadata.CheckID.replace("_", "_")} {finding.metadata.CheckTitle} {finding.resource_uid.replace("<", "<").replace(">", ">").replace("_", "_")} @@ -204,7 +204,7 @@ class HTML(Output): Status Severity Service Name - Region + {"File" if provider.type == "iac" else "Region"} Check ID Check Title Resource ID @@ -689,6 +689,51 @@ class HTML(Output): ) return "" + @staticmethod + def get_iac_assessment_summary(provider: Provider) -> str: + """ + get_iac_assessment_summary gets the HTML assessment summary for the provider + + Args: + provider (Provider): the provider object + + Returns: + str: the HTML assessment summary + """ + try: + return f""" +
+
+
+ IAC Assessment Summary +
+
    +
  • + IAC path: {provider.scan_path} +
  • +
+
+
+
+
+
+ IAC Credentials +
+
    +
  • + IAC authentication method: local +
  • +
+
+
""" + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}" + ) + return "" + @staticmethod def get_assessment_summary(provider: Provider) -> str: """ diff --git a/prowler/lib/outputs/ocsf/ocsf.py b/prowler/lib/outputs/ocsf/ocsf.py index c84a6acf7c..7cb2186f38 100644 --- a/prowler/lib/outputs/ocsf/ocsf.py +++ b/prowler/lib/outputs/ocsf/ocsf.py @@ -3,9 +3,9 @@ from datetime import datetime from typing import List from py_ocsf_models.events.base_event import SeverityID, StatusID -from py_ocsf_models.events.findings.detection_finding import DetectionFinding from py_ocsf_models.events.findings.detection_finding import ( - TypeID as DetectionFindingTypeID, + DetectionFinding, + DetectionFindingTypeID, ) from py_ocsf_models.events.findings.finding import ActivityID, FindingInformation from py_ocsf_models.objects.account import Account, TypeID @@ -40,7 +40,7 @@ class OCSF(Output): - get_finding_status_id(muted: bool) -> StatusID: Returns the StatusID based on the muted value. References: - - OCSF: https://schema.ocsf.io/1.2.0/classes/detection_finding + - OCSF: https://schema.ocsf.io/classes/detection_finding - PY-OCSF-Model: https://github.com/prowler-cloud/py-ocsf-models """ diff --git a/prowler/lib/outputs/summary_table.py b/prowler/lib/outputs/summary_table.py index b1d9c7b8c1..fadceea23e 100644 --- a/prowler/lib/outputs/summary_table.py +++ b/prowler/lib/outputs/summary_table.py @@ -54,6 +54,9 @@ def display_summary_table( elif provider.type == "nhn": entity_type = "Tenant Domain" audited_entities = provider.identity.tenant_domain + elif provider.type == "iac": + entity_type = "Directory" + audited_entities = provider.scan_path # Check if there are findings and that they are not all MANUAL if findings and not all(finding.status == "MANUAL" for finding in findings): diff --git a/prowler/providers/aws/aws_provider.py b/prowler/providers/aws/aws_provider.py index bdfc4b27d7..c7f329eeb5 100644 --- a/prowler/providers/aws/aws_provider.py +++ b/prowler/providers/aws/aws_provider.py @@ -796,6 +796,7 @@ class AwsProvider(Provider): "elb", "efs", "sqs", + "eks", ] service_list = set() sub_service_list = set() diff --git a/prowler/providers/aws/aws_regions_by_service.json b/prowler/providers/aws/aws_regions_by_service.json index eb2dc553f8..ef19bcba0d 100644 --- a/prowler/providers/aws/aws_regions_by_service.json +++ b/prowler/providers/aws/aws_regions_by_service.json @@ -785,7 +785,10 @@ "ap-southeast-2", "ap-southeast-3", "ap-southeast-4", + "ap-southeast-5", + "ap-southeast-7", "ca-central-1", + "ca-west-1", "eu-central-1", "eu-central-2", "eu-north-1", @@ -1181,6 +1184,16 @@ ] } }, + "awstransform": { + "regions": { + "aws": [ + "eu-central-1", + "us-east-1" + ], + "aws-cn": [], + "aws-us-gov": [] + } + }, "b2bi": { "regions": { "aws": [ @@ -3387,6 +3400,7 @@ "eu-west-1", "eu-west-2", "eu-west-3", + "il-central-1", "me-central-1", "sa-east-1", "us-east-1", @@ -5072,6 +5086,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -5358,6 +5373,7 @@ "il-central-1", "me-central-1", "me-south-1", + "mx-central-1", "sa-east-1", "us-east-1", "us-east-2", @@ -6219,6 +6235,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -7813,13 +7830,18 @@ "regions": { "aws": [ "ap-northeast-1", + "ap-northeast-2", + "ap-northeast-3", + "ap-south-1", "ap-southeast-1", "ap-southeast-2", + "ca-central-1", "eu-central-1", "eu-north-1", "eu-west-1", "eu-west-2", "eu-west-3", + "sa-east-1", "us-east-1", "us-east-2", "us-west-1", @@ -8091,6 +8113,8 @@ "regions": { "aws": [ "ap-northeast-1", + "ap-northeast-3", + "ap-south-1", "ap-southeast-1", "eu-central-1", "eu-west-1", @@ -8193,7 +8217,10 @@ "us-west-2" ], "aws-cn": [], - "aws-us-gov": [] + "aws-us-gov": [ + "us-gov-east-1", + "us-gov-west-1" + ] } }, "personalize": { @@ -8831,6 +8858,7 @@ "aws": [ "af-south-1", "ap-east-1", + "ap-east-2", "ap-northeast-1", "ap-northeast-2", "ap-northeast-3", @@ -10920,6 +10948,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/arn/models.py b/prowler/providers/aws/lib/arn/models.py index ba0be84452..1f8f923e29 100644 --- a/prowler/providers/aws/lib/arn/models.py +++ b/prowler/providers/aws/lib/arn/models.py @@ -1,7 +1,7 @@ import os from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.providers.aws.exceptions.exceptions import AWSIAMRoleARNMissingFieldsError @@ -10,7 +10,7 @@ class ARN(BaseModel): arn: str partition: str service: str - region: Optional[str] # In IAM ARN's do not have region + region: Optional[str] = None # In IAM ARN's do not have region account_id: str resource: str resource_type: str diff --git a/prowler/providers/aws/lib/s3/s3.py b/prowler/providers/aws/lib/s3/s3.py index 83347566bb..95c800cc5c 100644 --- a/prowler/providers/aws/lib/s3/s3.py +++ b/prowler/providers/aws/lib/s3/s3.py @@ -1,12 +1,39 @@ +import os import tempfile from os import path from tempfile import NamedTemporaryFile from typing import Optional -from botocore import exceptions +from boto3.session import Session +from botocore.exceptions import ClientError, NoCredentialsError, ProfileNotFound from prowler.lib.logger import logger from prowler.lib.outputs.output import Output +from prowler.providers.aws.aws_provider import AwsProvider +from prowler.providers.aws.config import ( + AWS_STS_GLOBAL_ENDPOINT_REGION, + ROLE_SESSION_NAME, +) +from prowler.providers.aws.exceptions.exceptions import ( + AWSAccessKeyIDInvalidError, + AWSArgumentTypeValidationError, + AWSAssumeRoleError, + AWSIAMRoleARNEmptyResourceError, + AWSIAMRoleARNInvalidAccountIDError, + AWSIAMRoleARNInvalidResourceTypeError, + AWSIAMRoleARNPartitionEmptyError, + AWSIAMRoleARNRegionNotEmtpyError, + AWSIAMRoleARNServiceNotIAMnorSTSError, + AWSNoCredentialsError, + AWSProfileNotFoundError, + AWSSecretAccessKeyInvalidError, + AWSSessionTokenExpiredError, + AWSSetUpSessionError, +) +from prowler.providers.aws.lib.arguments.arguments import ( + validate_role_session_name, + validate_session_duration, +) from prowler.providers.aws.lib.s3.exceptions.exceptions import ( S3BucketAccessDeniedError, S3ClientError, @@ -14,8 +41,11 @@ from prowler.providers.aws.lib.s3.exceptions.exceptions import ( S3InvalidBucketNameError, S3TestConnectionError, ) -from prowler.providers.aws.lib.session.aws_set_up_session import AwsSetUpSession -from prowler.providers.aws.models import AWSIdentityInfo, AWSSession +from prowler.providers.aws.lib.session.aws_set_up_session import ( + AwsSetUpSession, + parse_iam_credentials_arn, +) +from prowler.providers.aws.models import AWSAssumeRoleInfo, AWSIdentityInfo, AWSSession from prowler.providers.common.models import Connection @@ -220,7 +250,18 @@ class S3: @staticmethod def test_connection( - session, bucket_name: str, raise_on_exception: bool = True + bucket_name: str, + profile: str = None, + aws_region: str = AWS_STS_GLOBAL_ENDPOINT_REGION, + role_arn: str = None, + role_session_name: str = ROLE_SESSION_NAME, + session_duration: int = 3600, + external_id: str = None, + mfa_enabled: bool = False, + raise_on_exception: bool = True, + aws_access_key_id: str = None, + aws_secret_access_key: str = None, + aws_session_token: Optional[str] = None, ) -> Connection: """ Test the connection to the S3 bucket. @@ -236,7 +277,39 @@ class S3: Raises: - Exception: An exception indicating that the connection test failed. """ + # TODO: Refactor this method, the AWSProvider.test_connection() and the SecurityHubProvider.test_connection() are similar. try: + session = AwsProvider.setup_session( + mfa=mfa_enabled, + profile=profile, + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + ) + + if role_arn: + session_duration = validate_session_duration(session_duration) + role_session_name = validate_role_session_name(role_session_name) + role_arn = parse_iam_credentials_arn(role_arn) + assumed_role_information = AWSAssumeRoleInfo( + role_arn=role_arn, + session_duration=session_duration, + external_id=external_id, + mfa_enabled=mfa_enabled, + role_session_name=role_session_name, + ) + assumed_role_credentials = AwsProvider.assume_role( + session, + assumed_role_information, + ) + session = Session( + aws_access_key_id=assumed_role_credentials.aws_access_key_id, + aws_secret_access_key=assumed_role_credentials.aws_secret_access_key, + aws_session_token=assumed_role_credentials.aws_session_token, + region_name=aws_region, + profile_name=profile, + ) + s3_client = session.client(__class__.__name__.lower()) if "s3://" in bucket_name: bucket_name = bucket_name.removeprefix("s3://") @@ -273,7 +346,125 @@ class S3: ) return Connection(is_connected=True) - except exceptions.ClientError as client_error: + except AWSSetUpSessionError as setup_session_error: + logger.error( + f"{setup_session_error.__class__.__name__}[{setup_session_error.__traceback__.tb_lineno}]: {setup_session_error}" + ) + if raise_on_exception: + raise setup_session_error + return Connection(error=setup_session_error) + + except AWSArgumentTypeValidationError as validation_error: + logger.error( + f"{validation_error.__class__.__name__}[{validation_error.__traceback__.tb_lineno}]: {validation_error}" + ) + if raise_on_exception: + raise validation_error + return Connection(error=validation_error) + + except AWSIAMRoleARNRegionNotEmtpyError as arn_region_not_empty_error: + logger.error( + f"{arn_region_not_empty_error.__class__.__name__}[{arn_region_not_empty_error.__traceback__.tb_lineno}]: {arn_region_not_empty_error}" + ) + if raise_on_exception: + raise arn_region_not_empty_error + return Connection(error=arn_region_not_empty_error) + + except AWSIAMRoleARNPartitionEmptyError as arn_partition_empty_error: + logger.error( + f"{arn_partition_empty_error.__class__.__name__}[{arn_partition_empty_error.__traceback__.tb_lineno}]: {arn_partition_empty_error}" + ) + if raise_on_exception: + raise arn_partition_empty_error + return Connection(error=arn_partition_empty_error) + + except AWSIAMRoleARNServiceNotIAMnorSTSError as arn_service_not_iam_sts_error: + logger.error( + f"{arn_service_not_iam_sts_error.__class__.__name__}[{arn_service_not_iam_sts_error.__traceback__.tb_lineno}]: {arn_service_not_iam_sts_error}" + ) + if raise_on_exception: + raise arn_service_not_iam_sts_error + return Connection(error=arn_service_not_iam_sts_error) + + except AWSIAMRoleARNInvalidAccountIDError as arn_invalid_account_id_error: + logger.error( + f"{arn_invalid_account_id_error.__class__.__name__}[{arn_invalid_account_id_error.__traceback__.tb_lineno}]: {arn_invalid_account_id_error}" + ) + if raise_on_exception: + raise arn_invalid_account_id_error + return Connection(error=arn_invalid_account_id_error) + + except AWSIAMRoleARNInvalidResourceTypeError as arn_invalid_resource_type_error: + logger.error( + f"{arn_invalid_resource_type_error.__class__.__name__}[{arn_invalid_resource_type_error.__traceback__.tb_lineno}]: {arn_invalid_resource_type_error}" + ) + if raise_on_exception: + raise arn_invalid_resource_type_error + return Connection(error=arn_invalid_resource_type_error) + + except AWSIAMRoleARNEmptyResourceError as arn_empty_resource_error: + logger.error( + f"{arn_empty_resource_error.__class__.__name__}[{arn_empty_resource_error.__traceback__.tb_lineno}]: {arn_empty_resource_error}" + ) + if raise_on_exception: + raise arn_empty_resource_error + return Connection(error=arn_empty_resource_error) + + except AWSAssumeRoleError as assume_role_error: + logger.error( + f"{assume_role_error.__class__.__name__}[{assume_role_error.__traceback__.tb_lineno}]: {assume_role_error}" + ) + if raise_on_exception: + raise assume_role_error + return Connection(error=assume_role_error) + + except ProfileNotFound as profile_not_found_error: + logger.error( + f"AWSProfileNotFoundError[{profile_not_found_error.__traceback__.tb_lineno}]: {profile_not_found_error}" + ) + if raise_on_exception: + raise AWSProfileNotFoundError( + file=os.path.basename(__file__), + original_exception=profile_not_found_error, + ) from profile_not_found_error + return Connection(error=profile_not_found_error) + + except NoCredentialsError as no_credentials_error: + logger.error( + f"AWSNoCredentialsError[{no_credentials_error.__traceback__.tb_lineno}]: {no_credentials_error}" + ) + if raise_on_exception: + raise AWSNoCredentialsError( + file=os.path.basename(__file__), + original_exception=no_credentials_error, + ) from no_credentials_error + return Connection(error=no_credentials_error) + + except AWSAccessKeyIDInvalidError as access_key_id_invalid_error: + logger.error( + f"{access_key_id_invalid_error.__class__.__name__}[{access_key_id_invalid_error.__traceback__.tb_lineno}]: {access_key_id_invalid_error}" + ) + if raise_on_exception: + raise access_key_id_invalid_error + return Connection(error=access_key_id_invalid_error) + + except AWSSecretAccessKeyInvalidError as secret_access_key_invalid_error: + logger.error( + f"{secret_access_key_invalid_error.__class__.__name__}[{secret_access_key_invalid_error.__traceback__.tb_lineno}]: {secret_access_key_invalid_error}" + ) + if raise_on_exception: + raise secret_access_key_invalid_error + return Connection(error=secret_access_key_invalid_error) + + except AWSSessionTokenExpiredError as session_token_expired: + logger.error( + f"{session_token_expired.__class__.__name__}[{session_token_expired.__traceback__.tb_lineno}]: {session_token_expired}" + ) + if raise_on_exception: + raise session_token_expired + return Connection(error=session_token_expired) + + except ClientError as client_error: if raise_on_exception: if ( "specified bucket does not exist" @@ -291,9 +482,9 @@ class S3: raise S3BucketAccessDeniedError(original_exception=client_error) else: raise S3ClientError(original_exception=client_error) - return Connection(is_connected=False, error=client_error) + return Connection(error=client_error) except Exception as error: if raise_on_exception: raise S3TestConnectionError(original_exception=error) - return False + return Connection(error=error) diff --git a/prowler/providers/aws/services/accessanalyzer/accessanalyzer_service.py b/prowler/providers/aws/services/accessanalyzer/accessanalyzer_service.py index fbe6f3826e..1732ba7e50 100644 --- a/prowler/providers/aws/services/accessanalyzer/accessanalyzer_service.py +++ b/prowler/providers/aws/services/accessanalyzer/accessanalyzer_service.py @@ -1,7 +1,7 @@ from typing import Optional from botocore.exceptions import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/account/account_service.py b/prowler/providers/aws/services/account/account_service.py index 786d827e1a..08000112ca 100644 --- a/prowler/providers/aws/services/account/account_service.py +++ b/prowler/providers/aws/services/account/account_service.py @@ -2,7 +2,7 @@ from typing import Optional from venv import logger from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.providers.aws.lib.service.service import AWSService @@ -101,6 +101,6 @@ class Account(AWSService): class Contact(BaseModel): type: str - email: Optional[str] - name: Optional[str] - phone_number: Optional[str] + email: Optional[str] = None + name: Optional[str] = None + phone_number: Optional[str] = None diff --git a/prowler/providers/aws/services/acm/acm_service.py b/prowler/providers/aws/services/acm/acm_service.py index cb9996831c..b21737cd9a 100644 --- a/prowler/providers/aws/services/acm/acm_service.py +++ b/prowler/providers/aws/services/acm/acm_service.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered @@ -111,5 +111,5 @@ class Certificate(BaseModel): tags: Optional[list] = [] expiration_days: int in_use: bool - transparency_logging: Optional[bool] + transparency_logging: Optional[bool] = None region: str diff --git a/prowler/providers/aws/services/apigateway/apigateway_service.py b/prowler/providers/aws/services/apigateway/apigateway_service.py index c1525e0ea1..f61a502791 100644 --- a/prowler/providers/aws/services/apigateway/apigateway_service.py +++ b/prowler/providers/aws/services/apigateway/apigateway_service.py @@ -1,7 +1,7 @@ from typing import Optional from botocore.exceptions import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered @@ -224,11 +224,11 @@ class Stage(BaseModel): arn: str logging: bool client_certificate: bool - waf: Optional[str] + waf: Optional[str] = None tags: Optional[list] = [] - tracing_enabled: Optional[bool] - cache_enabled: Optional[bool] - cache_data_encrypted: Optional[bool] + tracing_enabled: Optional[bool] = None + cache_enabled: Optional[bool] = None + cache_data_encrypted: Optional[bool] = None class PathResourceMethods(BaseModel): diff --git a/prowler/providers/aws/services/apigatewayv2/apigatewayv2_service.py b/prowler/providers/aws/services/apigatewayv2/apigatewayv2_service.py index cb8d88e5ef..e8ea2583cb 100644 --- a/prowler/providers/aws/services/apigatewayv2/apigatewayv2_service.py +++ b/prowler/providers/aws/services/apigatewayv2/apigatewayv2_service.py @@ -1,7 +1,7 @@ from typing import Optional from botocore.exceptions import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/appstream/appstream_service.py b/prowler/providers/aws/services/appstream/appstream_service.py index b1ee202bb9..f88496ec38 100644 --- a/prowler/providers/aws/services/appstream/appstream_service.py +++ b/prowler/providers/aws/services/appstream/appstream_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/appsync/appsync_service.py b/prowler/providers/aws/services/appsync/appsync_service.py index cb39aa8f5d..576f533241 100644 --- a/prowler/providers/aws/services/appsync/appsync_service.py +++ b/prowler/providers/aws/services/appsync/appsync_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/athena/athena_service.py b/prowler/providers/aws/services/athena/athena_service.py index ea8ae707e4..ba145bfd81 100644 --- a/prowler/providers/aws/services/athena/athena_service.py +++ b/prowler/providers/aws/services/athena/athena_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel, Field +from pydantic.v1 import BaseModel, Field from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/autoscaling/autoscaling_service.py b/prowler/providers/aws/services/autoscaling/autoscaling_service.py index fe2671f88b..eae261344e 100644 --- a/prowler/providers/aws/services/autoscaling/autoscaling_service.py +++ b/prowler/providers/aws/services/autoscaling/autoscaling_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/awslambda/awslambda_service.py b/prowler/providers/aws/services/awslambda/awslambda_service.py index 32150b7313..aea2bec272 100644 --- a/prowler/providers/aws/services/awslambda/awslambda_service.py +++ b/prowler/providers/aws/services/awslambda/awslambda_service.py @@ -7,7 +7,7 @@ from typing import Any, Optional import requests from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered @@ -196,12 +196,12 @@ class Function(BaseModel): name: str arn: str security_groups: list - runtime: Optional[str] + runtime: Optional[str] = None environment: dict = None region: str - policy: dict = None + policy: dict = {} code: LambdaCode = None url_config: URLConfig = None - vpc_id: Optional[str] - subnet_ids: Optional[set] + vpc_id: Optional[str] = None + subnet_ids: Optional[set] = None tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/backup/backup_service.py b/prowler/providers/aws/services/backup/backup_service.py index 92aea28672..4320d42c0b 100644 --- a/prowler/providers/aws/services/backup/backup_service.py +++ b/prowler/providers/aws/services/backup/backup_service.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered @@ -227,7 +227,7 @@ class BackupVault(BaseModel): locked: bool min_retention_days: int = None max_retention_days: int = None - tags: Optional[list] + tags: Optional[list] = None class BackupPlan(BaseModel): @@ -236,17 +236,17 @@ class BackupPlan(BaseModel): region: str name: str version_id: str - last_execution_date: Optional[datetime] + last_execution_date: Optional[datetime] = None advanced_settings: list - tags: Optional[list] + tags: Optional[list] = None class BackupReportPlan(BaseModel): arn: str region: str name: str - last_attempted_execution_date: Optional[datetime] - last_successful_execution_date: Optional[datetime] + last_attempted_execution_date: Optional[datetime] = None + last_successful_execution_date: Optional[datetime] = None class RecoveryPoint(BaseModel): @@ -256,4 +256,4 @@ class RecoveryPoint(BaseModel): backup_vault_name: str encrypted: bool backup_vault_region: str - tags: Optional[list] + tags: Optional[list] = None diff --git a/prowler/providers/aws/services/bedrock/bedrock_service.py b/prowler/providers/aws/services/bedrock/bedrock_service.py index 118e0cb8ae..c00fc61ac0 100644 --- a/prowler/providers/aws/services/bedrock/bedrock_service.py +++ b/prowler/providers/aws/services/bedrock/bedrock_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered @@ -116,7 +116,7 @@ class Guardrail(BaseModel): region: str tags: Optional[list] = [] sensitive_information_filter: bool = False - prompt_attack_filter_strength: Optional[str] + prompt_attack_filter_strength: Optional[str] = None class BedrockAgent(AWSService): @@ -169,6 +169,6 @@ class Agent(BaseModel): id: str name: str arn: str - guardrail_id: Optional[str] + guardrail_id: Optional[str] = None region: str tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/cloudformation/cloudformation_service.py b/prowler/providers/aws/services/cloudformation/cloudformation_service.py index 38caf5033f..fb8a491486 100644 --- a/prowler/providers/aws/services/cloudformation/cloudformation_service.py +++ b/prowler/providers/aws/services/cloudformation/cloudformation_service.py @@ -1,7 +1,7 @@ from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/cloudfront/cloudfront_service.py b/prowler/providers/aws/services/cloudfront/cloudfront_service.py index f6d8f63365..b7f85113b4 100644 --- a/prowler/providers/aws/services/cloudfront/cloudfront_service.py +++ b/prowler/providers/aws/services/cloudfront/cloudfront_service.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered @@ -186,7 +186,7 @@ class SSLSupportMethod(Enum): class DefaultCacheConfigBehaviour(BaseModel): - realtime_log_config_arn: Optional[str] + realtime_log_config_arn: Optional[str] = None viewer_protocol_policy: ViewerProtocolPolicy field_level_encryption_id: str @@ -196,8 +196,8 @@ class Origin(BaseModel): domain_name: str origin_protocol_policy: str origin_ssl_protocols: list[str] - origin_access_control: Optional[str] - s3_origin_config: Optional[dict] + origin_access_control: Optional[str] = None + s3_origin_config: Optional[dict] = None class Distribution(BaseModel): @@ -207,14 +207,14 @@ class Distribution(BaseModel): id: str region: str logging_enabled: bool = False - default_cache_config: Optional[DefaultCacheConfigBehaviour] - geo_restriction_type: Optional[GeoRestrictionType] + default_cache_config: Optional[DefaultCacheConfigBehaviour] = None + geo_restriction_type: Optional[GeoRestrictionType] = None origins: list[Origin] web_acl_id: str = "" - default_certificate: Optional[bool] - default_root_object: Optional[str] - viewer_protocol_policy: Optional[str] + default_certificate: Optional[bool] = None + default_root_object: Optional[str] = None + viewer_protocol_policy: Optional[str] = None tags: Optional[list] = [] - origin_failover: Optional[bool] - ssl_support_method: Optional[SSLSupportMethod] - certificate: Optional[str] + origin_failover: Optional[bool] = None + ssl_support_method: Optional[SSLSupportMethod] = None + certificate: Optional[str] = None diff --git a/prowler/providers/aws/services/cloudtrail/cloudtrail_service.py b/prowler/providers/aws/services/cloudtrail/cloudtrail_service.py index b3f21522dc..d146dc14c7 100644 --- a/prowler/providers/aws/services/cloudtrail/cloudtrail_service.py +++ b/prowler/providers/aws/services/cloudtrail/cloudtrail_service.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/cloudwatch/cloudwatch_service.py b/prowler/providers/aws/services/cloudwatch/cloudwatch_service.py index ac05ebae10..29ac103867 100644 --- a/prowler/providers/aws/services/cloudwatch/cloudwatch_service.py +++ b/prowler/providers/aws/services/cloudwatch/cloudwatch_service.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from typing import Optional from botocore.exceptions import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered @@ -278,8 +278,8 @@ class Logs(AWSService): class MetricAlarm(BaseModel): arn: str name: str - metric: Optional[str] - name_space: Optional[str] + metric: Optional[str] = None + name_space: Optional[str] = None region: str tags: Optional[list] = [] alarm_actions: list @@ -310,7 +310,7 @@ class MetricFilter(BaseModel): name: str metric: str pattern: str - log_group: Optional[LogGroup] + log_group: Optional[LogGroup] = None region: str diff --git a/prowler/providers/aws/services/codeartifact/codeartifact_service.py b/prowler/providers/aws/services/codeartifact/codeartifact_service.py index c48b791390..f3d312a531 100644 --- a/prowler/providers/aws/services/codeartifact/codeartifact_service.py +++ b/prowler/providers/aws/services/codeartifact/codeartifact_service.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Optional from botocore.exceptions import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered @@ -246,7 +246,7 @@ class Package(BaseModel): """Details of a package""" name: str - namespace: Optional[str] + namespace: Optional[str] = None format: str origin_configuration: OriginConfiguration latest_version: LatestPackageVersion diff --git a/prowler/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations.metadata.json b/prowler/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations.metadata.json new file mode 100644 index 0000000000..7f6bc0c024 --- /dev/null +++ b/prowler/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "aws", + "CheckID": "codebuild_project_uses_allowed_github_organizations", + "CheckTitle": "Ensure AWS CodeBuild projects using GitHub connect only to allowed organizations", + "CheckType": [], + "ServiceName": "codebuild", + "SubServiceName": "", + "ResourceIdTemplate": "arn:aws:codebuild:region:account-id:project:project-name", + "Severity": "high", + "ResourceType": "AwsCodeBuildProject", + "Description": "Check for CodeBuild projects using GitHub repositories from untrusted organizations that could lead to backdoored IAM roles", + "Risk": "Attackers can use GitHub Actions in untrusted repositories to backdoor IAM roles used by CodeBuild projects, gaining persistent access to AWS accounts.", + "RelatedUrl": "https://medium.com/@adan.alvarez/gaining-long-term-aws-access-with-codebuild-and-github-873324638784", + "Remediation": { + "Code": { + "NativeIaC": "", + "Terraform": "", + "CLI": "", + "Other": "" + }, + "Recommendation": { + "Text": "Only use GitHub repositories from trusted organizations with CodeBuild projects. Configure the allowed GitHub organizations in your Prowler configuration.", + "Url": "https://docs.aws.amazon.com/codebuild/latest/userguide/auth-and-access-control-iam-identity-based-access-control.html" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations.py b/prowler/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations.py new file mode 100644 index 0000000000..750a5a6fdd --- /dev/null +++ b/prowler/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations.py @@ -0,0 +1,57 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.codebuild.codebuild_client import codebuild_client +from prowler.providers.aws.services.iam.iam_client import iam_client +from prowler.providers.aws.services.iam.lib.policy import ( + has_codebuild_trusted_principal, + is_codebuild_using_allowed_github_org, +) + + +class codebuild_project_uses_allowed_github_organizations(Check): + def execute(self): + findings = [] + allowed_organizations = codebuild_client.audit_config.get( + "codebuild_github_allowed_organizations", [] + ) + + for project in codebuild_client.projects.values(): + if project.source and project.source.type in ( + "GITHUB", + "GITHUB_ENTERPRISE", + ): + project_github_repo_url = project.source.location + project_role = next( + ( + role + for role in iam_client.roles + if role.arn == project.service_role_arn + ), + None, + ) + project_iam_trust_policy = ( + project_role.assume_role_policy if project_role else None + ) + + if not project_iam_trust_policy or not has_codebuild_trusted_principal( + project_iam_trust_policy + ): + continue + + report = Check_Report_AWS(metadata=self.metadata(), resource=project) + report.status = "PASS" + + is_allowed, org_name = is_codebuild_using_allowed_github_org( + project_iam_trust_policy, + project_github_repo_url, + allowed_organizations, + ) + if org_name is not None: + if is_allowed: + report.status_extended = f"CodeBuild project {project.name} uses GitHub organization '{org_name}', which is in the allowed organizations." + else: + report.status = "FAIL" + report.status_extended = f"CodeBuild project {project.name} uses GitHub organization '{org_name}', which is not in the allowed organizations." + + findings.append(report) + + return findings diff --git a/prowler/providers/aws/services/codebuild/codebuild_service.py b/prowler/providers/aws/services/codebuild/codebuild_service.py index a1e00790bb..c7210a67dd 100644 --- a/prowler/providers/aws/services/codebuild/codebuild_service.py +++ b/prowler/providers/aws/services/codebuild/codebuild_service.py @@ -1,7 +1,7 @@ import datetime from typing import List, Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered @@ -120,6 +120,7 @@ class Codebuild(AWSService): stream_name=cloudwatch_logs.get("streamName", ""), ) project.tags = project_info.get("tags", []) + project.service_role_arn = project_info.get("serviceRole", "") except Exception as error: logger.error( f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -211,11 +212,12 @@ class Project(BaseModel): name: str arn: str region: str - last_build: Optional[Build] - last_invoked_time: Optional[datetime.datetime] - buildspec: Optional[str] - source: Optional[Source] + last_build: Optional[Build] = None + last_invoked_time: Optional[datetime.datetime] = None + buildspec: Optional[str] = None + source: Optional[Source] = None secondary_sources: Optional[list[Source]] = [] + service_role_arn: Optional[str] = None environment_variables: Optional[List[EnvironmentVariable]] s3_logs: Optional[s3Logs] cloudwatch_logs: Optional[CloudWatchLogs] @@ -233,6 +235,6 @@ class ReportGroup(BaseModel): arn: str name: str region: str - status: Optional[str] - export_config: Optional[ExportConfig] - tags: Optional[list] + status: Optional[str] = None + export_config: Optional[ExportConfig] = None + tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/cognito/cognito_service.py b/prowler/providers/aws/services/cognito/cognito_service.py index 70075cad2e..813ef8decf 100644 --- a/prowler/providers/aws/services/cognito/cognito_service.py +++ b/prowler/providers/aws/services/cognito/cognito_service.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/config/config_service.py b/prowler/providers/aws/services/config/config_service.py index a53c61caa1..443cee2233 100644 --- a/prowler/providers/aws/services/config/config_service.py +++ b/prowler/providers/aws/services/config/config_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/datasync/datasync_service.py b/prowler/providers/aws/services/datasync/datasync_service.py index 9a75855d75..690a265be6 100644 --- a/prowler/providers/aws/services/datasync/datasync_service.py +++ b/prowler/providers/aws/services/datasync/datasync_service.py @@ -1,7 +1,7 @@ from typing import Dict, List, Optional from botocore.exceptions import ClientError -from pydantic import BaseModel, Field +from pydantic.v1 import BaseModel, Field from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/directconnect/directconnect_connection_redundancy/directconnect_connection_redundancy.metadata.json b/prowler/providers/aws/services/directconnect/directconnect_connection_redundancy/directconnect_connection_redundancy.metadata.json index fc13f982e4..42dbbe68dc 100644 --- a/prowler/providers/aws/services/directconnect/directconnect_connection_redundancy/directconnect_connection_redundancy.metadata.json +++ b/prowler/providers/aws/services/directconnect/directconnect_connection_redundancy/directconnect_connection_redundancy.metadata.json @@ -9,7 +9,7 @@ "SubServiceName": "", "ResourceIdTemplate": "arn:partition:directconnect:region:account-id:directconnect/resource-id", "Severity": "medium", - "ResourceType": "", + "ResourceType": "Other", "Description": "Checks the resilience of the AWS Direct Connect used to connect your on-premises.", "Risk": "This check alerts you if any Direct Connect connections are not redundant and the connections are coming from two distinct Direct Connect locations. Lack of location resiliency can result in unexpected downtime during maintenance, a fiber cut, a device failure, or a complete location failure.", "RelatedUrl": "https://docs.aws.amazon.com/awssupport/latest/user/fault-tolerance-checks.html#amazon-direct-connect-location-resiliency", diff --git a/prowler/providers/aws/services/directconnect/directconnect_service.py b/prowler/providers/aws/services/directconnect/directconnect_service.py index 3f79087523..bf6b9e7eb7 100644 --- a/prowler/providers/aws/services/directconnect/directconnect_service.py +++ b/prowler/providers/aws/services/directconnect/directconnect_service.py @@ -1,7 +1,7 @@ from typing import Optional from botocore.exceptions import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/directconnect/directconnect_virtual_interface_redundancy/directconnect_virtual_interface_redundancy.metadata.json b/prowler/providers/aws/services/directconnect/directconnect_virtual_interface_redundancy/directconnect_virtual_interface_redundancy.metadata.json index cf16a0664e..a42a71ed83 100644 --- a/prowler/providers/aws/services/directconnect/directconnect_virtual_interface_redundancy/directconnect_virtual_interface_redundancy.metadata.json +++ b/prowler/providers/aws/services/directconnect/directconnect_virtual_interface_redundancy/directconnect_virtual_interface_redundancy.metadata.json @@ -9,7 +9,7 @@ "SubServiceName": "", "ResourceIdTemplate": "arn:partition:directconnect:region:account-id:directconnect/resource-id", "Severity": "medium", - "ResourceType": "", + "ResourceType": "Other", "Description": "Checks the resilience of the AWS Direct Connect used to connect your on-premises to each Direct Connect gateway or virtual private gateway.", "Risk": "This check alerts you if any Direct Connect gateway or virtual private gateway isn't configured with virtual interfaces across at least two distinct Direct Connect locations. Lack of location resiliency can result in unexpected downtime during maintenance, a fiber cut, a device failure, or a complete location failure.", "RelatedUrl": "https://docs.aws.amazon.com/awssupport/latest/user/fault-tolerance-checks.html#amazon-direct-connect-location-resiliency", diff --git a/prowler/providers/aws/services/directoryservice/directoryservice_service.py b/prowler/providers/aws/services/directoryservice/directoryservice_service.py index 0beea818e2..6d57708620 100644 --- a/prowler/providers/aws/services/directoryservice/directoryservice_service.py +++ b/prowler/providers/aws/services/directoryservice/directoryservice_service.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Optional, Union from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/dlm/dlm_service.py b/prowler/providers/aws/services/dlm/dlm_service.py index e06992660a..1d6fff9b5a 100644 --- a/prowler/providers/aws/services/dlm/dlm_service.py +++ b/prowler/providers/aws/services/dlm/dlm_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.aws.lib.service.service import AWSService diff --git a/prowler/providers/aws/services/dms/dms_service.py b/prowler/providers/aws/services/dms/dms_service.py index 97764dd439..1aaa1e3762 100644 --- a/prowler/providers/aws/services/dms/dms_service.py +++ b/prowler/providers/aws/services/dms/dms_service.py @@ -1,7 +1,7 @@ import json from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/documentdb/documentdb_service.py b/prowler/providers/aws/services/documentdb/documentdb_service.py index 3beaf901d3..39674c1998 100644 --- a/prowler/providers/aws/services/documentdb/documentdb_service.py +++ b/prowler/providers/aws/services/documentdb/documentdb_service.py @@ -1,7 +1,7 @@ from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/drs/drs_service.py b/prowler/providers/aws/services/drs/drs_service.py index c3e99a6345..e49cbeff34 100644 --- a/prowler/providers/aws/services/drs/drs_service.py +++ b/prowler/providers/aws/services/drs/drs_service.py @@ -1,5 +1,5 @@ from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/dynamodb/dynamodb_service.py b/prowler/providers/aws/services/dynamodb/dynamodb_service.py index ad6c959955..110d9d4c9c 100644 --- a/prowler/providers/aws/services/dynamodb/dynamodb_service.py +++ b/prowler/providers/aws/services/dynamodb/dynamodb_service.py @@ -2,7 +2,7 @@ import json from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/ec2/ec2_service.py b/prowler/providers/aws/services/ec2/ec2_service.py index 2336f43478..9559346db4 100644 --- a/prowler/providers/aws/services/ec2/ec2_service.py +++ b/prowler/providers/aws/services/ec2/ec2_service.py @@ -3,7 +3,7 @@ from ipaddress import IPv4Address, IPv6Address, ip_address from typing import Optional, Union from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/ecr/ecr_service.py b/prowler/providers/aws/services/ecr/ecr_service.py index c892ad1fca..a09969725a 100644 --- a/prowler/providers/aws/services/ecr/ecr_service.py +++ b/prowler/providers/aws/services/ecr/ecr_service.py @@ -3,7 +3,7 @@ from json import loads from typing import Optional from botocore.exceptions import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/ecs/ecs_cluster_container_insights_enabled/ecs_cluster_container_insights_enabled.py b/prowler/providers/aws/services/ecs/ecs_cluster_container_insights_enabled/ecs_cluster_container_insights_enabled.py index 59a18ff779..c113e72a60 100644 --- a/prowler/providers/aws/services/ecs/ecs_cluster_container_insights_enabled/ecs_cluster_container_insights_enabled.py +++ b/prowler/providers/aws/services/ecs/ecs_cluster_container_insights_enabled/ecs_cluster_container_insights_enabled.py @@ -13,11 +13,10 @@ class ecs_cluster_container_insights_enabled(Check): ) if cluster.settings: for setting in cluster.settings: - if ( - setting["name"] == "containerInsights" - and setting["value"] == "enabled" + if setting["name"] == "containerInsights" and ( + setting["value"] == "enabled" or setting["value"] == "enhanced" ): report.status = "PASS" - report.status_extended = f"ECS cluster {cluster.name} has container insights enabled." + report.status_extended = f"ECS cluster {cluster.name} has container insights {setting['value']}." findings.append(report) return findings diff --git a/prowler/providers/aws/services/ecs/ecs_service.py b/prowler/providers/aws/services/ecs/ecs_service.py index f01dbbb667..560125bf58 100644 --- a/prowler/providers/aws/services/ecs/ecs_service.py +++ b/prowler/providers/aws/services/ecs/ecs_service.py @@ -1,7 +1,7 @@ from re import sub from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered @@ -175,6 +175,7 @@ class ECS(AWSService): clusters=[cluster.arn], include=[ "TAGS", + "SETTINGS", ], ) cluster.settings = response["clusters"][0].get("settings", []) diff --git a/prowler/providers/aws/services/efs/efs_service.py b/prowler/providers/aws/services/efs/efs_service.py index 38b7f03073..3d82198284 100644 --- a/prowler/providers/aws/services/efs/efs_service.py +++ b/prowler/providers/aws/services/efs/efs_service.py @@ -2,7 +2,7 @@ import json from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/eks/eks_service.py b/prowler/providers/aws/services/eks/eks_service.py index 4731887a02..e7b3ec389c 100644 --- a/prowler/providers/aws/services/eks/eks_service.py +++ b/prowler/providers/aws/services/eks/eks_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/elasticache/elasticache_service.py b/prowler/providers/aws/services/elasticache/elasticache_service.py index 6e11a85bc6..47d54f704a 100644 --- a/prowler/providers/aws/services/elasticache/elasticache_service.py +++ b/prowler/providers/aws/services/elasticache/elasticache_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service.py b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service.py index 3f8faa0e64..0005177bdb 100644 --- a/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service.py +++ b/prowler/providers/aws/services/elasticbeanstalk/elasticbeanstalk_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/elb/elb_service.py b/prowler/providers/aws/services/elb/elb_service.py index 85d56d4946..fb8f21982f 100644 --- a/prowler/providers/aws/services/elb/elb_service.py +++ b/prowler/providers/aws/services/elb/elb_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/elbv2/elbv2_desync_mitigation_mode/elbv2_desync_mitigation_mode.py b/prowler/providers/aws/services/elbv2/elbv2_desync_mitigation_mode/elbv2_desync_mitigation_mode.py index e0212c133e..a80b20d524 100644 --- a/prowler/providers/aws/services/elbv2/elbv2_desync_mitigation_mode/elbv2_desync_mitigation_mode.py +++ b/prowler/providers/aws/services/elbv2/elbv2_desync_mitigation_mode/elbv2_desync_mitigation_mode.py @@ -12,7 +12,7 @@ class elbv2_desync_mitigation_mode(Check): report.status_extended = f"ELBv2 ALB {lb.name} is configured with correct desync mitigation mode." if ( lb.desync_mitigation_mode != "strictest" - or lb.desync_mitigation_mode != "defensive" + and lb.desync_mitigation_mode != "defensive" ): if lb.drop_invalid_header_fields == "false": report.status = "FAIL" diff --git a/prowler/providers/aws/services/elbv2/elbv2_service.py b/prowler/providers/aws/services/elbv2/elbv2_service.py index 96889b54e4..c52110869f 100644 --- a/prowler/providers/aws/services/elbv2/elbv2_service.py +++ b/prowler/providers/aws/services/elbv2/elbv2_service.py @@ -1,7 +1,7 @@ from typing import Dict, Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/emr/emr_service.py b/prowler/providers/aws/services/emr/emr_service.py index e1fb7c15da..df2a5176c4 100644 --- a/prowler/providers/aws/services/emr/emr_service.py +++ b/prowler/providers/aws/services/emr/emr_service.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/eventbridge/eventbridge_service.py b/prowler/providers/aws/services/eventbridge/eventbridge_service.py index 58b717d05d..28972e49c9 100644 --- a/prowler/providers/aws/services/eventbridge/eventbridge_service.py +++ b/prowler/providers/aws/services/eventbridge/eventbridge_service.py @@ -2,7 +2,7 @@ import json from typing import Optional from botocore.exceptions import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/firehose/firehose_service.py b/prowler/providers/aws/services/firehose/firehose_service.py index 9ef76d2fbb..d496da9c4e 100644 --- a/prowler/providers/aws/services/firehose/firehose_service.py +++ b/prowler/providers/aws/services/firehose/firehose_service.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Dict, List, Optional from botocore.client import ClientError -from pydantic import BaseModel, Field +from pydantic.v1 import BaseModel, Field from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/fms/fms_service.py b/prowler/providers/aws/services/fms/fms_service.py index 443fde4a47..1875283c63 100644 --- a/prowler/providers/aws/services/fms/fms_service.py +++ b/prowler/providers/aws/services/fms/fms_service.py @@ -1,5 +1,5 @@ from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/fsx/fsx_service.py b/prowler/providers/aws/services/fsx/fsx_service.py index 7366204eca..27df140016 100644 --- a/prowler/providers/aws/services/fsx/fsx_service.py +++ b/prowler/providers/aws/services/fsx/fsx_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/glacier/glacier_service.py b/prowler/providers/aws/services/glacier/glacier_service.py index 9281f7485b..96e1d5be95 100644 --- a/prowler/providers/aws/services/glacier/glacier_service.py +++ b/prowler/providers/aws/services/glacier/glacier_service.py @@ -2,7 +2,7 @@ import json from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/globalaccelerator/globalaccelerator_service.py b/prowler/providers/aws/services/globalaccelerator/globalaccelerator_service.py index 706056c5b3..0a767cafed 100644 --- a/prowler/providers/aws/services/globalaccelerator/globalaccelerator_service.py +++ b/prowler/providers/aws/services/globalaccelerator/globalaccelerator_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/glue/glue_service.py b/prowler/providers/aws/services/glue/glue_service.py index 21e7fd4907..4376b19f6a 100644 --- a/prowler/providers/aws/services/glue/glue_service.py +++ b/prowler/providers/aws/services/glue/glue_service.py @@ -2,7 +2,7 @@ import json from typing import Dict, List, Optional from botocore.exceptions import ClientError -from pydantic import BaseModel, Field +from pydantic.v1 import BaseModel, Field from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/guardduty/guardduty_service.py b/prowler/providers/aws/services/guardduty/guardduty_service.py index f67f0881e6..c267771209 100644 --- a/prowler/providers/aws/services/guardduty/guardduty_service.py +++ b/prowler/providers/aws/services/guardduty/guardduty_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/iam/iam_policy_no_full_access_to_kms/iam_policy_no_full_access_to_kms.py b/prowler/providers/aws/services/iam/iam_policy_no_full_access_to_kms/iam_policy_no_full_access_to_kms.py index 64facbfbc2..1cd7faf2c0 100644 --- a/prowler/providers/aws/services/iam/iam_policy_no_full_access_to_kms/iam_policy_no_full_access_to_kms.py +++ b/prowler/providers/aws/services/iam/iam_policy_no_full_access_to_kms/iam_policy_no_full_access_to_kms.py @@ -15,7 +15,6 @@ class iam_policy_no_full_access_to_kms(Check): report.region = iam_client.region report.status = "PASS" report.status_extended = f"Custom Policy {policy.name} does not allow '{critical_service}:*' privileges." - if policy.document and check_full_service_access( critical_service, policy.document ): diff --git a/prowler/providers/aws/services/iam/iam_service.py b/prowler/providers/aws/services/iam/iam_service.py index 7fc821a329..27af7b8019 100644 --- a/prowler/providers/aws/services/iam/iam_service.py +++ b/prowler/providers/aws/services/iam/iam_service.py @@ -3,7 +3,7 @@ from datetime import datetime from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.config.config import encoding_format_utf_8 from prowler.lib.logger import logger diff --git a/prowler/providers/aws/services/iam/lib/policy.py b/prowler/providers/aws/services/iam/lib/policy.py index e023b137b5..b560389feb 100644 --- a/prowler/providers/aws/services/iam/lib/policy.py +++ b/prowler/providers/aws/services/iam/lib/policy.py @@ -1,57 +1,226 @@ -from ipaddress import ip_address, ip_network import re +from ipaddress import ip_address, ip_network +from typing import Optional, Tuple + +from py_iam_expand.actions import InvalidActionHandling, expand_actions from prowler.lib.logger import logger from prowler.providers.aws.aws_provider import read_aws_regions_file +def _get_patterns_from_standard_value(value): + """ + Helper function to process standard action/notaction values. + Accepts a string or list of strings and returns a set of string patterns. + """ + patterns = set() + if isinstance(value, str): + patterns.add(value) + elif isinstance(value, list): + patterns.update(item for item in value if isinstance(item, str)) + return patterns + + +def get_effective_actions(policy: dict) -> set[str]: + """ + Calculates the set of effectively allowed IAM actions from a policy document. + + This function considers Allow/Deny effects, Action/NotAction fields, + expands wildcards, handles invalid NotAction patterns correctly, + and applies the Deny > Allow precedence. Assumes standard AWS policy + format where Action/NotAction is a string or a list of strings. + + Args: + policy (dict): The IAM policy document. + + Returns: + set[str]: A set of effectively allowed IAM action strings. + """ + if not policy or "Statement" not in policy: + return set() + + directly_allowed_actions = set() + directly_denied_actions = set() + allow_not_action_exclusions = set() + deny_not_action_exclusions = set() + has_allow_not_action_statement = False + has_deny_not_action_statement = False + + statements = policy.get("Statement", []) + if not isinstance(statements, list): + statements = [statements] + + for statement in statements: + effect = statement.get("Effect", "") + if not isinstance(effect, str): + continue + effect = effect.strip().lower() + + if effect not in ["allow", "deny"]: + continue + + actions = statement.get("Action") + not_actions = statement.get("NotAction") + + action_patterns_to_expand = _get_patterns_from_standard_value(actions) + if action_patterns_to_expand: + expanded = set() + for pattern in action_patterns_to_expand: + expanded.update( + expand_actions( + pattern, + InvalidActionHandling.REMOVE, + ) + ) + if effect == "allow": + directly_allowed_actions.update(expanded) + else: # deny + directly_denied_actions.update(expanded) + + not_action_patterns_to_expand = _get_patterns_from_standard_value(not_actions) + if not_action_patterns_to_expand: + expanded_exclusions = set() + for pattern in not_action_patterns_to_expand: + expanded_exclusions.update( + expand_actions( + pattern, + InvalidActionHandling.REMOVE, + ) + ) + if effect == "allow": + allow_not_action_exclusions.update(expanded_exclusions) + has_allow_not_action_statement = True + else: # deny + deny_not_action_exclusions.update(expanded_exclusions) + has_deny_not_action_statement = True + + all_actions = None + + # Actions allowed by "Allow Action" statements + potentially_allowed = directly_allowed_actions + + # Actions allowed by "Allow NotAction" statements + if has_allow_not_action_statement: + if all_actions is None: + all_actions = set( + expand_actions( + "*", + InvalidActionHandling.REMOVE, + ) + ) + allowed_by_not_action = all_actions.difference(allow_not_action_exclusions) + potentially_allowed.update(allowed_by_not_action) + + # Actions denied by "Deny Action" statements + potentially_denied = directly_denied_actions + + # Actions denied by "Deny NotAction" statements + if has_deny_not_action_statement: + if all_actions is None: + all_actions = set( + expand_actions( + "*", + InvalidActionHandling.REMOVE, + ) + ) + denied_by_not_action = all_actions.difference(deny_not_action_exclusions) + potentially_denied.update(denied_by_not_action) + + effective_actions = potentially_allowed.difference(potentially_denied) + + return effective_actions + + def check_full_service_access(service: str, policy: dict) -> bool: """ - check_full_service_access checks if the policy allows full access to a service. + Determines if a policy grants full access to a specific AWS service + on all resources ("*"). + Args: - service (str): The service to check. - policy (dict): The policy to check. + service (str): The AWS service name (e.g., 's3', 'ec2', or '*' for admin). + policy (dict): The IAM policy document. + Returns: - bool: True if the policy allows full access to the service, False otherwise. + bool: True if full access on all resources is granted, False otherwise. """ + if not policy or "Statement" not in policy: + return False - full_access = False + service_wildcard = f"{service}:*" if service != "*" else "*" + all_target_service_actions = set( + expand_actions( + service_wildcard, + InvalidActionHandling.REMOVE, + ) + ) - if policy: - policy_statements = policy.get("Statement", []) + effective_allowed_actions = get_effective_actions(policy) - if not isinstance(policy_statements, list): - policy_statements = [policy["Statement"]] + if not all_target_service_actions.issubset(effective_allowed_actions): + return False - for statement in policy_statements: - if statement.get("Effect", "") == "Allow": - resources = statement.get("Resource", []) + actions_allowed_on_all_resources = set() + statements = policy.get("Statement", []) + if not isinstance(statements, list): + statements = [statements] - if not isinstance(resources, list): - resources = [statement.get("Resource", [])] + all_aws_actions_for_inversion = None - if "*" in resources: - if "Action" in statement: - actions = statement.get("Action", []) + for statement in statements: + effect = statement.get("Effect", "") + resources = statement.get("Resource", []) - if not isinstance(actions, list): - actions = [actions] + if not isinstance(effect, str) or effect.strip().lower() != "allow": + continue + if isinstance(resources, str): + resources = [resources] + if "*" not in resources: + continue - if f"{service}:*" in actions: - full_access = True - break + actions = statement.get("Action") + not_actions = statement.get("NotAction") + statement_specific_allowed = set() - elif "NotAction" in statement: - not_actions = statement.get("NotAction", []) + # Use the shared helper function instead of the duplicated one + action_patterns = _get_patterns_from_standard_value(actions) + for pattern in action_patterns: + statement_specific_allowed.update( + expand_actions( + pattern, + InvalidActionHandling.REMOVE, + ) + ) - if not isinstance(not_actions, list): - not_actions = [not_actions] + not_action_patterns = _get_patterns_from_standard_value(not_actions) + if not_action_patterns: + if all_aws_actions_for_inversion is None: + all_aws_actions_for_inversion = set( + expand_actions( + "*", + InvalidActionHandling.REMOVE, + ) + ) - if f"{service}:*" not in not_actions: - full_access = True - break + statement_exclusions = set() + for pattern in not_action_patterns: + statement_exclusions.update( + expand_actions( + pattern, + InvalidActionHandling.REMOVE, + ) + ) + # Actions allowed by THIS NotAction statement + statement_specific_allowed.update( + all_aws_actions_for_inversion.difference(statement_exclusions) + ) - return full_access + actions_allowed_on_all_resources.update( + action + for action in statement_specific_allowed + if action in all_target_service_actions + ) + + return all_target_service_actions.issubset(actions_allowed_on_all_resources) def is_condition_restricting_from_private_ip(condition_statement: dict) -> bool: @@ -570,3 +739,75 @@ def is_valid_aws_service(service): if service in read_aws_regions_file()["services"]: return True return False + + +def is_codebuild_using_allowed_github_org( + trust_policy: dict, github_repo_url: str, allowed_organizations: list +) -> Tuple[bool, Optional[str]]: + """ + Checks if the trust policy allows codebuild.amazonaws.com as a trusted principal and if the GitHub organization + in the repo URL is in the allowed organizations list. + Returns (is_allowed: bool, org_name: str or None) + """ + try: + if not trust_policy or not github_repo_url: + return False, None + + if not has_codebuild_trusted_principal(trust_policy): + return False, None + + # Extract org name from GitHub repo URL + org_name = ( + github_repo_url.split("/")[3] + if len(github_repo_url.split("/")) > 3 + else None + ) + if not org_name: + raise ValueError(f"Malformed GitHub repo URL: {github_repo_url}") + if org_name in allowed_organizations: + return True, org_name + return False, org_name + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return False, None + + +def has_codebuild_trusted_principal(trust_policy: dict) -> bool: + """ + Returns True if the trust policy allows codebuild.amazonaws.com as a trusted principal, otherwise False. + """ + if not trust_policy: + return False + statements = trust_policy.get("Statement", []) + if not isinstance(statements, list): + statements = [statements] + return any( + s.get("Effect") == "Allow" + and "Principal" in s + and ( + ( + isinstance(s["Principal"], dict) + and ( + ( + isinstance(s["Principal"].get("Service"), str) + and s["Principal"].get("Service") == "codebuild.amazonaws.com" + ) + or ( + isinstance(s["Principal"].get("Service"), list) + and "codebuild.amazonaws.com" in s["Principal"].get("Service") + ) + ) + ) + or ( + isinstance(s["Principal"], str) + and s["Principal"] == "codebuild.amazonaws.com" + ) + or ( + isinstance(s["Principal"], list) + and "codebuild.amazonaws.com" in s["Principal"] + ) + ) + for s in statements + ) diff --git a/prowler/providers/aws/services/iam/lib/privilege_escalation.py b/prowler/providers/aws/services/iam/lib/privilege_escalation.py index 3ed2b67093..76245ce2c9 100644 --- a/prowler/providers/aws/services/iam/lib/privilege_escalation.py +++ b/prowler/providers/aws/services/iam/lib/privilege_escalation.py @@ -1,8 +1,7 @@ +from py_iam_expand.actions import expand_actions + from prowler.lib.logger import logger -from prowler.providers.aws.services.iam.lib.policy import ( - check_invalid_not_actions, - process_actions, -) +from prowler.providers.aws.services.iam.lib.policy import get_effective_actions # Does the tool analyze both users and roles, or just one or the other? --> Everything using AttachementCount. # Does the tool take a principal-centric or policy-centric approach? --> Policy-centric approach. @@ -69,7 +68,6 @@ privilege_escalation_policies_combination = { "datapipeline:ActivatePipeline", }, "GlueUpdateDevEndpoint": {"glue:UpdateDevEndpoint"}, - "GlueUpdateDevEndpoints": {"glue:UpdateDevEndpoints"}, "lambda:UpdateFunctionCode": {"lambda:UpdateFunctionCode"}, "iam:CreateAccessKey": {"iam:CreateAccessKey"}, "iam:CreateLoginProfile": {"iam:CreateLoginProfile"}, @@ -93,148 +91,55 @@ privilege_escalation_policies_combination = { } -def find_privilege_escalation_combinations( - allowed_actions: set, - denied_actions: set, - allowed_not_actions: set, - denied_not_actions: set, -) -> set: - """ - find_privilege_escalation_combinations finds the privilege escalation combinations. - Args: - allowed_actions (set): The allowed actions. - denied_actions (set): The denied actions. - allowed_not_actions (set): The allowed not actions. - denied_not_actions (set): The denied not actions. - Returns: - set: The privilege escalation combinations. - """ - - # Store all the action's combinations - policies_combination = set() - hard_allowed_not_actions = set() - - try: - # First, we need to perform a difference with allowed_actions and denied_actions - allowed_actions = allowed_actions.difference(denied_actions) - # Then, we need to do perform a difference with allowed_not_actions and denied_not_actions - allowed_not_actions = allowed_not_actions.difference(denied_not_actions) - # If there are allowed_not_actions, we have to check if there are allowed_actions that are not allowed by allowed_not_actions - if allowed_not_actions: - # If allowed_actions is *, we need to save allowed_not_actions since we cannot subtract them - if "*" in allowed_actions: - hard_allowed_not_actions = allowed_not_actions - else: - allowed_actions = allowed_actions - allowed_not_actions - # If there are denied_not_actions, means that every other action is denied - if denied_not_actions: - allowed_actions = allowed_actions.intersection(denied_not_actions) - for values in privilege_escalation_policies_combination.values(): - for val in values: - val_set = set() - val_set.add(val) - # Look for specific api:action - if allowed_actions.intersection(val_set) == val_set: - policies_combination.add(val) - # Look for api:* - else: - for permission in allowed_actions: - # Here we have to handle if the api-action is admin, so "*" - api_action = permission.split(":") - # len() == 2, so api:action - if len(api_action) == 2: - api = api_action[0] - action = api_action[1] - # Add permissions if the API is present - if action == "*": - val_api = val.split(":")[0] - if api == val_api: - policies_combination.add(val) - - # len() == 1, so * - elif len(api_action) == 1: - # Unless the action is *, we have to check if the action to evaluate is in the hard_allowed_not_actions - if ( - not hard_allowed_not_actions - or val not in hard_allowed_not_actions - ): - api = api_action[0] - # Add permissions if the API is present - if api == "*": - policies_combination.add(val) - except Exception as error: - logger.error( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - - return policies_combination - - def check_privilege_escalation(policy: dict) -> str: """ - check_privilege_escalation checks if the policy allows privilege escalation. + Checks if the policy allows known privilege escalation combinations. + Args: - policy (dict): The policy to check. + policy (dict): The IAM policy document. + Returns: - str: The policies affected by privilege escalation, separated by commas. + str: A comma-separated string of the privilege escalation actions found, + or an empty string if none are found. """ - policies_affected = "" + if not policy: + return policies_affected - if policy: - allowed_actions = set() - allowed_not_actions = set() - denied_actions = set() - denied_not_actions = set() + try: + effective_allowed_actions = get_effective_actions(policy) - statements = policy.get("Statement", []) - if not isinstance(statements, list): - statements = [statements] + matched_combo_actions = set() + matched_combo_keys = set() - for statement in statements: - effect = statement.get("Effect") - actions = statement.get("Action") - not_actions = statement.get("NotAction") + for ( + combo_key, + required_actions_patterns, + ) in privilege_escalation_policies_combination.items(): + # Expand the required actions for the current combo + expanded_required_actions = set() + for action_pattern in required_actions_patterns: + expanded_required_actions.update(expand_actions(action_pattern)) - if effect == "Allow": - process_actions(effect, actions, allowed_actions) - process_actions(effect, not_actions, allowed_not_actions) - elif effect == "Deny": - process_actions(effect, actions, denied_actions) - process_actions(effect, not_actions, denied_not_actions) + # Check if all expanded required actions are present in the effective actions + if expanded_required_actions and expanded_required_actions.issubset( + effective_allowed_actions + ): + # If match, store the original patterns and the key + matched_combo_actions.update(required_actions_patterns) + matched_combo_keys.add(combo_key) - # If there is only NotAction, it allows the rest of the actions - if not allowed_actions and allowed_not_actions: - allowed_actions.add("*") - # Check for invalid services in allowed NotAction - if allowed_not_actions: - invalid_not_actions = check_invalid_not_actions(allowed_not_actions) - if invalid_not_actions: - # Since it is an invalid NotAction, it allows all AWS actions - allowed_actions.add("*") + if matched_combo_keys: + # Use the original patterns from the matched combos for the output + policies_affected = ", ".join( + f"'{action}'" for action in sorted(list(matched_combo_actions)) + ) + # Alternative: Output based on combo keys + # print("DEBUG: matched_combo_keys =", ", ".join(sorted(list(matched_combo_keys)))) - policies_combination = find_privilege_escalation_combinations( - allowed_actions, denied_actions, allowed_not_actions, denied_not_actions + except Exception as error: + logger.error( + f"Error checking privilege escalation for policy: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) - # Check all policies combinations and see if matches with some combo key - combos = set() - for ( - key, - values, - ) in privilege_escalation_policies_combination.items(): - intersection = policies_combination.intersection(values) - if intersection == values: - combos.add(key) - - if combos: - policies_affected = ( - ", ".join( - str(privilege_escalation_policies_combination[key]) - for key in combos - ) - .replace("{", "") - .replace("}", "") - ) - return policies_affected diff --git a/prowler/providers/aws/services/inspector2/inspector2_service.py b/prowler/providers/aws/services/inspector2/inspector2_service.py index 52a84fa9d5..cc6dac7413 100644 --- a/prowler/providers/aws/services/inspector2/inspector2_service.py +++ b/prowler/providers/aws/services/inspector2/inspector2_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.aws.lib.service.service import AWSService diff --git a/prowler/providers/aws/services/kafka/kafka_service.py b/prowler/providers/aws/services/kafka/kafka_service.py index 96a1418490..4197e543c4 100644 --- a/prowler/providers/aws/services/kafka/kafka_service.py +++ b/prowler/providers/aws/services/kafka/kafka_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/kinesis/kinesis_service.py b/prowler/providers/aws/services/kinesis/kinesis_service.py index cc5a7c1495..455d0fff0d 100644 --- a/prowler/providers/aws/services/kinesis/kinesis_service.py +++ b/prowler/providers/aws/services/kinesis/kinesis_service.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Dict, List, Optional -from pydantic import BaseModel, Field +from pydantic.v1 import BaseModel, Field from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/kms/kms_service.py b/prowler/providers/aws/services/kms/kms_service.py index 1dc4988842..4269e5ccf8 100644 --- a/prowler/providers/aws/services/kms/kms_service.py +++ b/prowler/providers/aws/services/kms/kms_service.py @@ -1,7 +1,7 @@ import json from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/lightsail/lightsail_service.py b/prowler/providers/aws/services/lightsail/lightsail_service.py index 56c137e21f..ce364a2238 100644 --- a/prowler/providers/aws/services/lightsail/lightsail_service.py +++ b/prowler/providers/aws/services/lightsail/lightsail_service.py @@ -1,6 +1,6 @@ from typing import Dict, List -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/macie/macie_service.py b/prowler/providers/aws/services/macie/macie_service.py index a11c78f940..f1ebd5cac3 100644 --- a/prowler/providers/aws/services/macie/macie_service.py +++ b/prowler/providers/aws/services/macie/macie_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.aws.lib.service.service import AWSService diff --git a/prowler/providers/aws/services/memorydb/memorydb_service.py b/prowler/providers/aws/services/memorydb/memorydb_service.py index 2fbe096940..6bfbb255e3 100644 --- a/prowler/providers/aws/services/memorydb/memorydb_service.py +++ b/prowler/providers/aws/services/memorydb/memorydb_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/mq/mq_service.py b/prowler/providers/aws/services/mq/mq_service.py index b97e51f35c..cc51c2a7bb 100644 --- a/prowler/providers/aws/services/mq/mq_service.py +++ b/prowler/providers/aws/services/mq/mq_service.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Dict, List -from pydantic import BaseModel, Field +from pydantic.v1 import BaseModel, Field from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/neptune/neptune_service.py b/prowler/providers/aws/services/neptune/neptune_service.py index 1c48d713ba..1ac631e3db 100644 --- a/prowler/providers/aws/services/neptune/neptune_service.py +++ b/prowler/providers/aws/services/neptune/neptune_service.py @@ -1,7 +1,7 @@ from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/networkfirewall/networkfirewall_service.py b/prowler/providers/aws/services/networkfirewall/networkfirewall_service.py index 7ed0bcfbc4..18f9e1eed7 100644 --- a/prowler/providers/aws/services/networkfirewall/networkfirewall_service.py +++ b/prowler/providers/aws/services/networkfirewall/networkfirewall_service.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/opensearch/opensearch_service.py b/prowler/providers/aws/services/opensearch/opensearch_service.py index 6e7cf2619e..b4602ba69c 100644 --- a/prowler/providers/aws/services/opensearch/opensearch_service.py +++ b/prowler/providers/aws/services/opensearch/opensearch_service.py @@ -1,7 +1,7 @@ from json import JSONDecodeError, loads from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/organizations/organizations_scp_check_deny_regions/organizations_scp_check_deny_regions.py b/prowler/providers/aws/services/organizations/organizations_scp_check_deny_regions/organizations_scp_check_deny_regions.py index 8de3f30cdd..cced82a762 100644 --- a/prowler/providers/aws/services/organizations/organizations_scp_check_deny_regions/organizations_scp_check_deny_regions.py +++ b/prowler/providers/aws/services/organizations/organizations_scp_check_deny_regions/organizations_scp_check_deny_regions.py @@ -34,9 +34,9 @@ class organizations_scp_check_deny_regions(Check): "SERVICE_CONTROL_POLICY", [] ): # Statements are not always list - statements = policy.content.get("Statement") - if type(policy.content["Statement"]) is not list: - statements = [policy.content.get("Statement")] + statements = policy.content.get("Statement", []) + if type(statements) is not list: + statements = [statements] for statement in statements: # Deny if Condition = {"StringNotEquals": {"aws:RequestedRegion": [region1, region2]}} diff --git a/prowler/providers/aws/services/organizations/organizations_service.py b/prowler/providers/aws/services/organizations/organizations_service.py index b3580fbebd..78f5b134bd 100644 --- a/prowler/providers/aws/services/organizations/organizations_service.py +++ b/prowler/providers/aws/services/organizations/organizations_service.py @@ -2,7 +2,7 @@ import json from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/rds/rds_service.py b/prowler/providers/aws/services/rds/rds_service.py index 9e2a921bdd..2f8941c8b3 100644 --- a/prowler/providers/aws/services/rds/rds_service.py +++ b/prowler/providers/aws/services/rds/rds_service.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/redshift/redshift_service.py b/prowler/providers/aws/services/redshift/redshift_service.py index b0f4a06421..2a587f01b7 100644 --- a/prowler/providers/aws/services/redshift/redshift_service.py +++ b/prowler/providers/aws/services/redshift/redshift_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/resourceexplorer2/resourceexplorer2_service.py b/prowler/providers/aws/services/resourceexplorer2/resourceexplorer2_service.py index 8fe0b413f6..c828dab731 100644 --- a/prowler/providers/aws/services/resourceexplorer2/resourceexplorer2_service.py +++ b/prowler/providers/aws/services/resourceexplorer2/resourceexplorer2_service.py @@ -1,5 +1,5 @@ from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/route53/route53_service.py b/prowler/providers/aws/services/route53/route53_service.py index 4cc744c985..2e0eeb4499 100644 --- a/prowler/providers/aws/services/route53/route53_service.py +++ b/prowler/providers/aws/services/route53/route53_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/s3/s3_service.py b/prowler/providers/aws/services/s3/s3_service.py index 4ff091052c..3dec3fc441 100644 --- a/prowler/providers/aws/services/s3/s3_service.py +++ b/prowler/providers/aws/services/s3/s3_service.py @@ -2,7 +2,7 @@ import json from typing import Dict, List, Optional from botocore.client import ClientError -from pydantic import BaseModel, Field +from pydantic.v1 import BaseModel, Field from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/sagemaker/sagemaker_service.py b/prowler/providers/aws/services/sagemaker/sagemaker_service.py index 8001b742cd..3e52bf0a82 100644 --- a/prowler/providers/aws/services/sagemaker/sagemaker_service.py +++ b/prowler/providers/aws/services/sagemaker/sagemaker_service.py @@ -1,7 +1,7 @@ from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/secretsmanager/secretsmanager_service.py b/prowler/providers/aws/services/secretsmanager/secretsmanager_service.py index 12f23d7ff0..8a85d33502 100644 --- a/prowler/providers/aws/services/secretsmanager/secretsmanager_service.py +++ b/prowler/providers/aws/services/secretsmanager/secretsmanager_service.py @@ -2,7 +2,7 @@ import json from datetime import datetime, timezone from typing import Dict, List, Optional -from pydantic import BaseModel, Field +from pydantic.v1 import BaseModel, Field from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/securityhub/securityhub_service.py b/prowler/providers/aws/services/securityhub/securityhub_service.py index 3485113571..0799c6e048 100644 --- a/prowler/providers/aws/services/securityhub/securityhub_service.py +++ b/prowler/providers/aws/services/securityhub/securityhub_service.py @@ -1,7 +1,7 @@ from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/servicecatalog/servicecatalog_service.py b/prowler/providers/aws/services/servicecatalog/servicecatalog_service.py index 2950d234b2..94efb5f623 100644 --- a/prowler/providers/aws/services/servicecatalog/servicecatalog_service.py +++ b/prowler/providers/aws/services/servicecatalog/servicecatalog_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/ses/ses_service.py b/prowler/providers/aws/services/ses/ses_service.py index 57a24083d3..ff19f829e6 100644 --- a/prowler/providers/aws/services/ses/ses_service.py +++ b/prowler/providers/aws/services/ses/ses_service.py @@ -1,7 +1,7 @@ from json import loads from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/shield/shield_service.py b/prowler/providers/aws/services/shield/shield_service.py index 06129a94a6..baeb768b37 100644 --- a/prowler/providers/aws/services/shield/shield_service.py +++ b/prowler/providers/aws/services/shield/shield_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.aws.lib.service.service import AWSService diff --git a/prowler/providers/aws/services/sns/sns_service.py b/prowler/providers/aws/services/sns/sns_service.py index 2eb93ee792..766967269f 100644 --- a/prowler/providers/aws/services/sns/sns_service.py +++ b/prowler/providers/aws/services/sns/sns_service.py @@ -2,7 +2,7 @@ from json import loads from typing import Optional from botocore.exceptions import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/sqs/sqs_service.py b/prowler/providers/aws/services/sqs/sqs_service.py index 5371ab9c98..598cd625a4 100644 --- a/prowler/providers/aws/services/sqs/sqs_service.py +++ b/prowler/providers/aws/services/sqs/sqs_service.py @@ -2,7 +2,7 @@ from json import loads from typing import Optional from botocore.exceptions import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/ssm/ssm_service.py b/prowler/providers/aws/services/ssm/ssm_service.py index 3f8c62d6f5..33f1187993 100644 --- a/prowler/providers/aws/services/ssm/ssm_service.py +++ b/prowler/providers/aws/services/ssm/ssm_service.py @@ -4,7 +4,7 @@ from enum import Enum from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/ssmincidents/ssmincidents_service.py b/prowler/providers/aws/services/ssmincidents/ssmincidents_service.py index fd6dce7b62..90ed978cd9 100644 --- a/prowler/providers/aws/services/ssmincidents/ssmincidents_service.py +++ b/prowler/providers/aws/services/ssmincidents/ssmincidents_service.py @@ -1,5 +1,5 @@ from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/stepfunctions/stepfunctions_service.py b/prowler/providers/aws/services/stepfunctions/stepfunctions_service.py index 76f041a897..7580c88357 100644 --- a/prowler/providers/aws/services/stepfunctions/stepfunctions_service.py +++ b/prowler/providers/aws/services/stepfunctions/stepfunctions_service.py @@ -3,7 +3,7 @@ from enum import Enum from typing import Dict, List, Optional from botocore.exceptions import ClientError -from pydantic import BaseModel, Field +from pydantic.v1 import BaseModel, Field from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/storagegateway/storagegateway_service.py b/prowler/providers/aws/services/storagegateway/storagegateway_service.py index b55c5c7f4d..5f8b6392e3 100644 --- a/prowler/providers/aws/services/storagegateway/storagegateway_service.py +++ b/prowler/providers/aws/services/storagegateway/storagegateway_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/transfer/transfer_service.py b/prowler/providers/aws/services/transfer/transfer_service.py index e1d037fdae..f86e195a31 100644 --- a/prowler/providers/aws/services/transfer/transfer_service.py +++ b/prowler/providers/aws/services/transfer/transfer_service.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Dict, List -from pydantic import BaseModel, Field +from pydantic.v1 import BaseModel, Field from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/trustedadvisor/trustedadvisor_service.py b/prowler/providers/aws/services/trustedadvisor/trustedadvisor_service.py index 4dd899c0ca..bad30341b2 100644 --- a/prowler/providers/aws/services/trustedadvisor/trustedadvisor_service.py +++ b/prowler/providers/aws/services/trustedadvisor/trustedadvisor_service.py @@ -1,7 +1,7 @@ from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.aws.lib.service.service import AWSService diff --git a/prowler/providers/aws/services/vpc/vpc_endpoint_multi_az_enabled/vpc_endpoint_multi_az_enabled.py b/prowler/providers/aws/services/vpc/vpc_endpoint_multi_az_enabled/vpc_endpoint_multi_az_enabled.py index 2a79594172..203522c274 100644 --- a/prowler/providers/aws/services/vpc/vpc_endpoint_multi_az_enabled/vpc_endpoint_multi_az_enabled.py +++ b/prowler/providers/aws/services/vpc/vpc_endpoint_multi_az_enabled/vpc_endpoint_multi_az_enabled.py @@ -9,10 +9,10 @@ class vpc_endpoint_multi_az_enabled(Check): if endpoint.vpc_id in vpc_client.vpcs and endpoint.type == "Interface": report = Check_Report_AWS(metadata=self.metadata(), resource=endpoint) report.status = "FAIL" - report.status_extended = f"VPC Endpoint {endpoint.id} in VPC {endpoint.vpc_id} has subnets in different AZs." + report.status_extended = f"VPC Endpoint {endpoint.id} in VPC {endpoint.vpc_id} does not have subnets in different AZs." if len(endpoint.subnet_ids) > 1: report.status = "PASS" - report.status_extended = f"VPC Endpoint {endpoint.id} in VPC {endpoint.vpc_id} does not have subnets in different AZs." + report.status_extended = f"VPC Endpoint {endpoint.id} in VPC {endpoint.vpc_id} has subnets in different AZs." findings.append(report) diff --git a/prowler/providers/aws/services/vpc/vpc_service.py b/prowler/providers/aws/services/vpc/vpc_service.py index f2a4298dd8..50df596d6b 100644 --- a/prowler/providers/aws/services/vpc/vpc_service.py +++ b/prowler/providers/aws/services/vpc/vpc_service.py @@ -2,7 +2,7 @@ import json from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/waf/waf_service.py b/prowler/providers/aws/services/waf/waf_service.py index b0521fd992..b1fda19c50 100644 --- a/prowler/providers/aws/services/waf/waf_service.py +++ b/prowler/providers/aws/services/waf/waf_service.py @@ -1,6 +1,6 @@ from typing import Dict, List, Optional -from pydantic import BaseModel, Field +from pydantic.v1 import BaseModel, Field from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/wafv2/wafv2_service.py b/prowler/providers/aws/services/wafv2/wafv2_service.py index c867691b77..6a9d3ca5b8 100644 --- a/prowler/providers/aws/services/wafv2/wafv2_service.py +++ b/prowler/providers/aws/services/wafv2/wafv2_service.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Optional from botocore.exceptions import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/wellarchitected/wellarchitected_service.py b/prowler/providers/aws/services/wellarchitected/wellarchitected_service.py index 3e8951d8c0..8bc87afdf5 100644 --- a/prowler/providers/aws/services/wellarchitected/wellarchitected_service.py +++ b/prowler/providers/aws/services/wellarchitected/wellarchitected_service.py @@ -1,7 +1,7 @@ from typing import Optional from botocore.client import ClientError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/aws/services/workspaces/workspaces_service.py b/prowler/providers/aws/services/workspaces/workspaces_service.py index 48617897ff..319fa8cf73 100644 --- a/prowler/providers/aws/services/workspaces/workspaces_service.py +++ b/prowler/providers/aws/services/workspaces/workspaces_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.lib.scan_filters.scan_filters import is_resource_filtered diff --git a/prowler/providers/azure/models.py b/prowler/providers/azure/models.py index cf0cd4be9b..752d1372c6 100644 --- a/prowler/providers/azure/models.py +++ b/prowler/providers/azure/models.py @@ -1,4 +1,6 @@ -from pydantic import BaseModel +from typing import Optional + +from pydantic.v1 import BaseModel from prowler.config.config import output_file_timestamp from prowler.providers.common.models import ProviderOutputOptions @@ -15,7 +17,7 @@ class AzureIdentityInfo(BaseModel): class AzureRegionConfig(BaseModel): name: str = "" - authority: str = None + authority: Optional[str] = None base_url: str = "" credential_scopes: list = [] diff --git a/prowler/providers/azure/services/aisearch/aisearch_service.py b/prowler/providers/azure/services/aisearch/aisearch_service.py index c1d530798c..2324be227d 100644 --- a/prowler/providers/azure/services/aisearch/aisearch_service.py +++ b/prowler/providers/azure/services/aisearch/aisearch_service.py @@ -1,5 +1,5 @@ from azure.mgmt.search import SearchManagementClient -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.azure.azure_provider import AzureProvider diff --git a/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.metadata.json b/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.metadata.json index 7130068a23..0eaadbec0f 100644 --- a/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.metadata.json +++ b/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "high", - "ResourceType": "", + "ResourceType": "AzureSearchService", "Description": "Ensure that public network access to the Search Service is restricted.", "Risk": "Public accessibility exposes the Search Service to potential attacks, unauthorized usage, and data breaches. Restricting access minimizes the surface area for attacks and ensures that only authorized networks can access the search service.", "RelatedUrl": "https://learn.microsoft.com/en-us/azure/search/service-configure-firewall#configure-network-access-in-azure-portal", @@ -23,7 +23,9 @@ "Url": "https://learn.microsoft.com/en-us/azure/search/service-configure-firewall#configure-network-access-in-azure-portal" } }, - "Categories": ["gen-ai"], + "Categories": [ + "gen-ai" + ], "DependsOn": [], "RelatedTo": [], "Notes": "" 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 3c5102dbbb..004af0da30 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 @@ -1,8 +1,5 @@ from prowler.lib.check.models import Check, Check_Report_Azure from prowler.providers.azure.services.app.app_client import app_client -from prowler.providers.azure.services.appinsights.appinsights_client import ( - appinsights_client, -) class app_function_application_insights_enabled(Check): @@ -25,13 +22,10 @@ class app_function_application_insights_enabled(Check): ) if function.enviroment_variables.get( - "APPINSIGHTS_INSTRUMENTATIONKEY", "" - ) in [ - component.instrumentation_key - for component in appinsights_client.components[ - subscription_name - ].values() - ]: + "APPINSIGHTS_INSTRUMENTATIONKEY", None + ) or function.enviroment_variables.get( + "APPLICATIONINSIGHTS_CONNECTION_STRING", None + ): report.status = "PASS" report.status_extended = ( f"Function {function.name} is using Application Insights." diff --git a/prowler/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges.py b/prowler/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges.py index c4db014bc3..9804ce283c 100644 --- a/prowler/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges.py +++ b/prowler/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges.py @@ -41,9 +41,15 @@ class app_function_identity_without_admin_privileges(Check): USER_ACCESS_ADMINISTRATOR_ROLE_ID, ] ): - for role in iam_client.roles[subscription_name]: - if role.id.split("/")[-1] == role_assignment.role_id: - admin_roles_assigned.append(role.name) + admin_roles_assigned.append( + getattr( + iam_client.roles[subscription_name].get( + f"/subscriptions/{iam_client.subscriptions[subscription_name]}/providers/Microsoft.Authorization/roleDefinitions/{role_assignment.role_id}" + ), + "name", + "", + ) + ) if admin_roles_assigned: report.status = "FAIL" diff --git a/prowler/providers/azure/services/app/app_service.py b/prowler/providers/azure/services/app/app_service.py index 584555e2ad..d70c51e778 100644 --- a/prowler/providers/azure/services/app/app_service.py +++ b/prowler/providers/azure/services/app/app_service.py @@ -132,10 +132,16 @@ class App(AzureService): else: function_keys = None - function_config = self._get_function_config( + application_settings = self._list_application_settings( subscription_name, function.resource_group, function.name ) + function_config = self._get_function_config( + subscription_name, + function.resource_group, + function.name, + ) + functions[subscription_name].update( { function.id: FunctionApp( @@ -145,7 +151,7 @@ class App(AzureService): kind=function.kind, function_keys=function_keys, enviroment_variables=getattr( - function_config, "properties", None + application_settings, "properties", None ), identity=getattr(function, "identity", None), public_access=( @@ -218,7 +224,7 @@ class App(AzureService): def _get_function_config(self, subscription, resource_group, name): try: - return self.clients[subscription].web_apps.list_application_settings( + return self.clients[subscription].web_apps.get_configuration( resource_group_name=resource_group, name=name, ) @@ -228,6 +234,18 @@ class App(AzureService): ) return None + def _list_application_settings(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 application settings for {name} in {resource_group}: {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return None + @dataclass class ManagedServiceIdentity: diff --git a/prowler/providers/azure/services/appinsights/appinsights_service.py b/prowler/providers/azure/services/appinsights/appinsights_service.py index f101a5e40b..aae9dbf9b0 100644 --- a/prowler/providers/azure/services/appinsights/appinsights_service.py +++ b/prowler/providers/azure/services/appinsights/appinsights_service.py @@ -1,5 +1,5 @@ from azure.mgmt.applicationinsights import ApplicationInsightsManagementClient -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.azure.azure_provider import AzureProvider diff --git a/prowler/providers/azure/services/databricks/__init__.py b/prowler/providers/azure/services/databricks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/databricks/databricks_client.py b/prowler/providers/azure/services/databricks/databricks_client.py new file mode 100644 index 0000000000..178db476fa --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_client.py @@ -0,0 +1,4 @@ +from prowler.providers.azure.services.databricks.databricks_service import Databricks +from prowler.providers.common.provider import Provider + +databricks_client = Databricks(Provider.get_global_provider()) diff --git a/prowler/providers/azure/services/databricks/databricks_service.py b/prowler/providers/azure/services/databricks/databricks_service.py new file mode 100644 index 0000000000..7920500d88 --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_service.py @@ -0,0 +1,118 @@ +from typing import Optional + +from azure.mgmt.databricks import AzureDatabricksManagementClient +from pydantic import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.azure.azure_provider import AzureProvider +from prowler.providers.azure.lib.service.service import AzureService + + +class Databricks(AzureService): + """ + Service class for interacting with Azure Databricks workspaces. + + This class initializes the Azure Databricks Management Client for each subscription + and retrieves all Databricks workspaces within those subscriptions. + """ + + def __init__(self, provider: AzureProvider): + """ + Initialize the Databricks service with the given Azure provider. + + Args: + provider: The Azure provider instance containing credentials and configuration. + """ + super().__init__(AzureDatabricksManagementClient, provider) + self.workspaces = self._get_workspaces() + + def _get_workspaces(self) -> dict: + """ + Retrieve all Databricks workspaces for each subscription. + + Returns: + A dictionary mapping subscription IDs to their Databricks workspaces. + """ + logger.info("Databricks - Getting workspaces...") + workspaces = {} + for subscription, client in self.clients.items(): + try: + workspaces[subscription] = {} + + for workspace in client.workspaces.list_by_subscription(): + workspace_parameters = getattr(workspace, "parameters", None) + workspace_managed_disk_encryption = getattr( + getattr( + getattr(workspace, "encryption", None), "entities", None + ), + "managed_disk", + None, + ) + + key_vault_properties = getattr( + workspace_managed_disk_encryption, "key_vault_properties", None + ) + + if key_vault_properties: + managed_disk_encryption = ManagedDiskEncryption( + key_name=key_vault_properties.key_name, + key_version=key_vault_properties.key_version, + key_vault_uri=key_vault_properties.key_vault_uri, + ) + else: + managed_disk_encryption = None + + workspaces[subscription][workspace.id] = DatabricksWorkspace( + id=workspace.id, + name=workspace.name, + location=workspace.location, + custom_managed_vnet_id=( + getattr( + workspace_parameters, "custom_virtual_network_id", None + ).value + if getattr( + workspace_parameters, "custom_virtual_network_id", None + ) + else None + ), + managed_disk_encryption=managed_disk_encryption, + ) + except Exception as error: + logger.error( + f"Subscription: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return workspaces + + +class ManagedDiskEncryption(BaseModel): + """ + Pydantic model representing the encryption settings for a workspace's managed disks. + + Attributes: + key_name: The name of the key used for encryption. + key_version: The version of the key used for encryption. + key_vault_uri: The URI of the key vault containing the key used for encryption. + """ + + key_name: str + key_version: str + key_vault_uri: str + + +class DatabricksWorkspace(BaseModel): + """ + Pydantic model representing an Azure Databricks workspace. + + Attributes: + id: The unique identifier of the workspace. + name: The name of the workspace. + location: The Azure region where the workspace is deployed. + custom_managed_vnet_id: The ID of the custom managed virtual network, if configured. + managed_disk_encryption: The encryption settings for the workspace's managed disks. + """ + + id: str + name: str + location: str + custom_managed_vnet_id: Optional[str] = None + managed_disk_encryption: Optional[ManagedDiskEncryption] = None diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/__init__.py b/prowler/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled.metadata.json b/prowler/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled.metadata.json new file mode 100644 index 0000000000..3742060d17 --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "databricks_workspace_cmk_encryption_enabled", + "CheckTitle": "Ensure Azure Databricks workspaces use customer-managed keys (CMK) for encryption at rest", + "CheckType": [], + "ServiceName": "databricks", + "SubServiceName": "workspace", + "ResourceIdTemplate": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Databricks/workspaces/{workspaceName}", + "Severity": "high", + "ResourceType": "AzureDatabricksWorkspace", + "Description": "Checks whether Azure Databricks workspaces are configured to use customer-managed keys (CMK) for encryption at rest, providing greater control over data encryption and compliance.", + "Risk": "Without CMK, organizations have less control over encryption keys, which may impact regulatory compliance and increase risk of unauthorized data access.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/databricks/security/keys/customer-managed-keys", + "Remediation": { + "Code": { + "CLI": "az databricks workspace update --name --resource-group --prepare-encryption && databricks workspace update --name --resource-group --key-source 'Microsoft.KeyVault' --key-name --key-vault --key-version ", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable customer-managed keys (CMK) for Databricks workspaces using Azure Key Vault to enhance control over data encryption, auditing, and compliance.", + "Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Databricks/enable-encryption-with-cmk.html" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Customer-managed key (CMK) encryption is only available for Databricks workspaces on the Premium tier." +} diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled.py b/prowler/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled.py new file mode 100644 index 0000000000..a8e366f15a --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled.py @@ -0,0 +1,33 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.databricks.databricks_client import ( + databricks_client, +) + + +class databricks_workspace_cmk_encryption_enabled(Check): + """ + Ensure Azure Databricks workspaces use customer-managed keys (CMK) for encryption at rest. + + This check evaluates whether each Azure Databricks workspace in the subscription is configured to use a customer-managed key (CMK) for encrypting data at rest. + + - PASS: The workspace has CMK encryption enabled (managed_disk_encryption is set). + - FAIL: The workspace does not have CMK encryption enabled. + """ + + def execute(self): + findings = [] + for subscription, workspaces in databricks_client.workspaces.items(): + for workspace in workspaces.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=workspace + ) + report.subscription = subscription + enc = workspace.managed_disk_encryption + if enc: + report.status = "PASS" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription} has customer-managed key (CMK) encryption enabled with key {enc.key_vault_uri}/{enc.key_name}/{enc.key_version}." + else: + report.status = "FAIL" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription} does not have customer-managed key (CMK) encryption enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/__init__.py b/prowler/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled.metadata.json b/prowler/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled.metadata.json new file mode 100644 index 0000000000..d66e789e7d --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "databricks_workspace_vnet_injection_enabled", + "CheckTitle": "Ensure Azure Databricks workspaces are deployed in a customer-managed VNet (VNet Injection)", + "CheckType": [], + "ServiceName": "databricks", + "SubServiceName": "", + "ResourceIdTemplate": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Databricks/workspaces/{workspaceName}", + "Severity": "medium", + "ResourceType": "AzureDatabricksWorkspace", + "Description": "Checks whether Azure Databricks workspaces are deployed in a customer-managed Virtual Network (VNet Injection) instead of a Databricks-managed VNet.", + "Risk": "Using a Databricks-managed VNet limits control over network security policies, firewall configurations, and routing, increasing the risk of unauthorized access or data exfiltration.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/databricks/administration-guide/cloud-configurations/azure/vnet-inject", + "Remediation": { + "Code": { + "CLI": "az databricks workspace create --name --resource-group --location --managed-resource-group --enable-no-public-ip true --network-security-group-rule \"NoAzureServices\" --public-network-access Disabled --custom-virtual-network-id /subscriptions//resourceGroups//providers/Microsoft.Network/virtualNetworks/", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Deploy Databricks workspaces into a customer-managed VNet to ensure better control over network security and compliance.", + "Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/Databricks/check-for-vnet-injection.html" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled.py b/prowler/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled.py new file mode 100644 index 0000000000..f667342dab --- /dev/null +++ b/prowler/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled.py @@ -0,0 +1,32 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.databricks.databricks_client import ( + databricks_client, +) + + +class databricks_workspace_vnet_injection_enabled(Check): + """ + Ensure Azure Databricks workspaces are deployed in a customer-managed VNet (VNet Injection). + + This check evaluates whether each Azure Databricks workspace in the subscription is configured to use VNet Injection, meaning it is deployed in a customer-managed virtual network (VNet). + + - PASS: The workspace is deployed in a customer-managed VNet (custom_managed_vnet_id is set). + - FAIL: The workspace is not deployed in a customer-managed VNet (VNet Injection is not enabled). + """ + + def execute(self): + findings = [] + for subscription, workspaces in databricks_client.workspaces.items(): + for workspace in workspaces.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=workspace + ) + report.subscription = subscription + if workspace.custom_managed_vnet_id: + report.status = "PASS" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription} is deployed in a customer-managed VNet ({workspace.custom_managed_vnet_id})." + else: + report.status = "FAIL" + report.status_extended = f"Databricks workspace {workspace.name} in subscription {subscription} is not deployed in a customer-managed VNet (VNet Injection is not enabled)." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/defender/defender_service.py b/prowler/providers/azure/services/defender/defender_service.py index 6b66eaa1c8..2f6eaeb05d 100644 --- a/prowler/providers/azure/services/defender/defender_service.py +++ b/prowler/providers/azure/services/defender/defender_service.py @@ -7,7 +7,7 @@ from azure.core.exceptions import ( ResourceNotFoundError, ) from azure.mgmt.security import SecurityCenter -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.azure.azure_provider import AzureProvider @@ -161,7 +161,8 @@ class Defender(AzureService): { security_contact_default.name: SecurityContacts( resource_id=security_contact_default.id, - name=getattr(security_contact_default, "name", "default"), + name=getattr(security_contact_default, "name", "default") + or "default", emails=security_contact_default.emails, phone=security_contact_default.phone, alert_notifications_minimal_severity=security_contact_default.alert_notifications.minimal_severity, diff --git a/prowler/providers/azure/services/entra/entra_service.py b/prowler/providers/azure/services/entra/entra_service.py index 45f0c0128c..ae6fbb3428 100644 --- a/prowler/providers/azure/services/entra/entra_service.py +++ b/prowler/providers/azure/services/entra/entra_service.py @@ -3,7 +3,7 @@ from typing import List, Optional from uuid import UUID from msgraph import GraphServiceClient -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.azure.azure_provider import AzureProvider @@ -45,38 +45,45 @@ class Entra(AzureService): for tenant, client in self.clients.items(): users_list = await client.users.get() users.update({tenant: {}}) - for user in users_list.value: - users[tenant].update( - { - user.id: User( - id=user.id, - name=user.display_name, - authentication_methods=[ - AuthMethod( - id=auth_method.id, - type=getattr(auth_method, "odata_type", None), - ) - for auth_method in ( - await client.users.by_user_id( - user.id - ).authentication.methods.get() - ).value - ], - ) - } - ) + try: + for user in users_list.value: + users[tenant].update( + { + user.id: User( + id=user.id, + name=user.display_name, + authentication_methods=[ + AuthMethod( + id=auth_method.id, + type=getattr( + auth_method, "odata_type", None + ), + ) + for auth_method in ( + await client.users.by_user_id( + user.id + ).authentication.methods.get() + ).value + ], + ) + } + ) + except Exception as error: + if ( + error.__class__.__name__ == "ODataError" + and error.__dict__.get("response_status_code", None) == 403 + ): + logger.error( + "You need 'UserAuthenticationMethod.Read.All' permission to access this information. It only can be granted through Service Principal authentication." + ) + else: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) except Exception as error: - if ( - error.__class__.__name__ == "ODataError" - and error.__dict__.get("response_status_code", None) == 403 - ): - logger.error( - "You need 'UserAuthenticationMethod.Read.All' permission to access this information. It only can be granted through Service Principal authentication." - ) - else: - logger.error( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) return users @@ -367,12 +374,12 @@ class User(BaseModel): class DefaultUserRolePermissions(BaseModel): - allowed_to_create_apps: Optional[bool] - allowed_to_create_security_groups: Optional[bool] - allowed_to_create_tenants: Optional[bool] - allowed_to_read_bitlocker_keys_for_owned_device: Optional[bool] - allowed_to_read_other_users: Optional[bool] - odata_type: Optional[str] + allowed_to_create_apps: Optional[bool] = None + allowed_to_create_security_groups: Optional[bool] = None + allowed_to_create_tenants: Optional[bool] = None + allowed_to_read_bitlocker_keys_for_owned_device: Optional[bool] = None + allowed_to_read_other_users: Optional[bool] = None + odata_type: Optional[str] = None permission_grant_policies_assigned: Optional[List[str]] = None @@ -380,20 +387,20 @@ class AuthorizationPolicy(BaseModel): id: str name: str description: str - default_user_role_permissions: Optional[DefaultUserRolePermissions] + default_user_role_permissions: Optional[DefaultUserRolePermissions] = None guest_invite_settings: str guest_user_role_id: UUID class SettingValue(BaseModel): - name: Optional[str] - odata_type: Optional[str] - value: Optional[str] + name: Optional[str] = None + odata_type: Optional[str] = None + value: Optional[str] = None class GroupSetting(BaseModel): - name: Optional[str] - template_id: Optional[str] + name: Optional[str] = None + template_id: Optional[str] = None settings: List[SettingValue] diff --git a/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.py b/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.py index 9a6283e1c6..ad3b6819ae 100644 --- a/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.py +++ b/prowler/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa.py @@ -17,7 +17,7 @@ class entra_user_with_vm_access_has_mfa(Check): findings = [] for users in entra_client.users.values(): - for user_domain_name, user in users.items(): + for user in users.values(): for ( subscription_name, role_assigns, diff --git a/prowler/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks.py b/prowler/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks.py index 2abbf40510..c6c16326a3 100644 --- a/prowler/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks.py +++ b/prowler/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks.py @@ -10,7 +10,7 @@ class iam_custom_role_has_permissions_to_administer_resource_locks(Check): for subscription, roles in iam_client.custom_roles.items(): exits_role_with_permission_over_locks = False - for custom_role in roles: + for custom_role in roles.values(): if exits_role_with_permission_over_locks: break report = Check_Report_Azure( diff --git a/prowler/providers/azure/services/iam/iam_role_user_access_admin_restricted/__init__.py b/prowler/providers/azure/services/iam/iam_role_user_access_admin_restricted/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted.metadata.json b/prowler/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted.metadata.json new file mode 100644 index 0000000000..ff5825e971 --- /dev/null +++ b/prowler/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "iam_role_user_access_admin_restricted", + "CheckTitle": "Ensure 'User Access Administrator' role is restricted", + "CheckType": [], + "ServiceName": "iam", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AzureIAMRoleassignment", + "Description": "Checks for active assignments of the highly privileged 'User Access Administrator' role in Azure subscriptions.", + "Risk": "Persistent assignment of this role can lead to privilege escalation and unauthorized access, increasing the risk of security breaches.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged#user-access-administrator", + "Remediation": { + "Code": { + "CLI": "az role assignment delete --role 'User Access Administrator' --scope '/subscriptions/'", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remove 'User Access Administrator' role assignments immediately after use to minimize security risks.", + "Url": "https://learn.microsoft.com/en-us/azure/role-based-access-control/elevate-access-global-admin?tabs=azure-portal%2Centra-audit-logs" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted.py b/prowler/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted.py new file mode 100644 index 0000000000..4880880cb0 --- /dev/null +++ b/prowler/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted.py @@ -0,0 +1,29 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.iam.iam_client import iam_client + + +class iam_role_user_access_admin_restricted(Check): + def execute(self): + findings = [] + + for subscription_name, assignments in iam_client.role_assignments.items(): + for assignment in assignments.values(): + role_assignment_name = getattr( + iam_client.roles[subscription_name].get( + f"/subscriptions/{iam_client.subscriptions[subscription_name]}/providers/Microsoft.Authorization/roleDefinitions/{assignment.role_id}" + ), + "name", + "", + ) + report = Check_Report_Azure( + metadata=self.metadata(), resource=assignment + ) + report.subscription = subscription_name + if role_assignment_name == "User Access Administrator": + report.status = "FAIL" + report.status_extended = f"Role assignment {assignment.name} in subscription {subscription_name} grants User Access Administrator role to {getattr(assignment, 'agent_type', '')} {getattr(assignment, 'agent_id', '')}." + else: + report.status = "PASS" + report.status_extended = f"Role assignment {assignment.name} in subscription {subscription_name} does not grant User Access Administrator role." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/iam/iam_service.py b/prowler/providers/azure/services/iam/iam_service.py index 657921eb4f..55f1eb7e71 100644 --- a/prowler/providers/azure/services/iam/iam_service.py +++ b/prowler/providers/azure/services/iam/iam_service.py @@ -20,45 +20,40 @@ class IAM(AzureService): custom_roles = {} for subscription, client in self.clients.items(): try: - builtin_roles.update({subscription: []}) - custom_roles.update({subscription: []}) + builtin_roles.update({subscription: {}}) + custom_roles.update({subscription: {}}) all_roles = client.role_definitions.list( scope=f"/subscriptions/{self.subscriptions[subscription]}", ) for role in all_roles: if role.role_type == "CustomRole": - custom_roles[subscription].append( - Role( - id=role.id, - name=role.role_name, - type=role.role_type, - assignable_scopes=role.assignable_scopes, - permissions=[ - Permission( - condition=getattr(permission, "condition", ""), - condition_version=getattr( - permission, "condition_version", "" - ), - actions=getattr(permission, "actions", []), - ) - for permission in getattr(role, "permissions", []) - ], - ) + custom_roles[subscription][role.id] = Role( + id=role.id, + name=role.role_name, + type=role.role_type, + assignable_scopes=role.assignable_scopes, + permissions=[ + Permission( + condition=getattr(permission, "condition", ""), + condition_version=getattr( + permission, "condition_version", "" + ), + actions=getattr(permission, "actions", []), + ) + for permission in getattr(role, "permissions", []) + ], ) else: - builtin_roles[subscription].append( - Role( - id=role.id, - name=role.role_name, - type=role.role_type, - assignable_scopes=role.assignable_scopes, - permissions=role.permissions, - ) + builtin_roles[subscription][role.id] = Role( + id=role.id, + name=role.role_name, + type=role.role_type, + assignable_scopes=role.assignable_scopes, + permissions=role.permissions, ) except Exception as error: - logger.error(f"Subscription name: {subscription}") logger.error( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return builtin_roles, custom_roles @@ -75,6 +70,9 @@ class IAM(AzureService): role_assignments[subscription].update( { role_assignment.id: RoleAssignment( + id=role_assignment.id, + name=role_assignment.name, + scope=role_assignment.scope, agent_id=role_assignment.principal_id, agent_type=role_assignment.principal_type, role_id=role_assignment.role_definition_id.split("/")[ @@ -84,9 +82,8 @@ class IAM(AzureService): } ) except Exception as error: - logger.error(f"Subscription name: {subscription}") logger.error( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) return role_assignments @@ -109,6 +106,21 @@ class Role: @dataclass class RoleAssignment: + """ + Represents an Azure Role Assignment. + + Attributes: + id: The unique identifier of the role assignment. + name: The name of the role assignment. + scope: The scope at which the role assignment applies. + agent_id: The principal (user, group, service principal, etc.) ID assigned the role. + agent_type: The type of the principal. Known values: "User", "Group", "ServicePrincipal", "ForeignGroup", and "Device". + role_id: The ID of the role definition assigned. + """ + + id: str + name: str + scope: str agent_id: str agent_type: str role_id: str diff --git a/prowler/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created.py b/prowler/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created.py index df73516252..8580a3aab7 100644 --- a/prowler/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created.py +++ b/prowler/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created.py @@ -8,7 +8,7 @@ class iam_subscription_roles_owner_custom_not_created(Check): def execute(self) -> Check_Report_Azure: findings = [] for subscription, roles in iam_client.custom_roles.items(): - for custom_role in roles: + for custom_role in roles.values(): report = Check_Report_Azure( metadata=self.metadata(), resource=custom_role ) diff --git a/prowler/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/__init__.py b/prowler/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints.metadata.json b/prowler/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints.metadata.json new file mode 100644 index 0000000000..2e1fe0e59c --- /dev/null +++ b/prowler/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "keyvault_access_only_through_private_endpoints", + "CheckTitle": "Ensure that public network access when using private endpoint is disabled.", + "CheckType": [], + "ServiceName": "keyvault", + "SubServiceName": "", + "ResourceIdTemplate": "/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.KeyVault/vaults/{vault_name}", + "Severity": "high", + "ResourceType": "KeyVault", + "Description": "Checks if Key Vaults with private endpoints have public network access disabled.", + "Risk": "Allowing public network access to Key Vault when using private endpoint can expose sensitive data to unauthorized access.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/key-vault/general/network-security", + "Remediation": { + "Code": { + "CLI": "az keyvault update --resource-group --name --public-network-access disabled", + "NativeIaC": "{\n \"type\": \"Microsoft.KeyVault/vaults\",\n \"apiVersion\": \"2022-07-01\",\n \"properties\": {\n \"publicNetworkAccess\": \"disabled\"\n }\n}", + "Terraform": "resource \"azurerm_key_vault\" \"example\" {\n # ... other configuration ...\n\n public_network_access_enabled = false\n}", + "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/KeyVault/use-private-endpoints.html" + }, + "Recommendation": { + "Text": "Disable public network access for Key Vaults that use private endpoint to ensure network traffic only flows through the private endpoint.", + "Url": "https://learn.microsoft.com/en-us/azure/private-link/private-endpoint-overview" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints.py b/prowler/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints.py new file mode 100644 index 0000000000..1a363f2d61 --- /dev/null +++ b/prowler/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints.py @@ -0,0 +1,37 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.keyvault.keyvault_client import keyvault_client + + +class keyvault_access_only_through_private_endpoints(Check): + """ + Ensure that Public Network Access when using Private Endpoint is disabled. + + This check evaluates whether Azure Key Vaults with private endpoints configured have + public network access disabled. Disabling public network access enhances security by + isolating the Key Vault from the public internet, thereby reducing its exposure. + + - PASS: The Key Vault has private endpoints and public network access is disabled. + - FAIL: The Key Vault has private endpoints and public network access is enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for subscription, key_vaults in keyvault_client.key_vaults.items(): + for keyvault in key_vaults: + if ( + keyvault.properties + and keyvault.properties.private_endpoint_connections + ): + report = Check_Report_Azure( + metadata=self.metadata(), resource=keyvault + ) + report.subscription = subscription + + if keyvault.properties.public_network_access_disabled: + report.status = "PASS" + report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has public network access disabled and is using private endpoints." + else: + report.status = "FAIL" + report.status_extended = f"Keyvault {keyvault.name} from subscription {subscription} has public network access enabled while using private endpoints." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/keyvault/keyvault_service.py b/prowler/providers/azure/services/keyvault/keyvault_service.py index ccee275282..e63e799017 100644 --- a/prowler/providers/azure/services/keyvault/keyvault_service.py +++ b/prowler/providers/azure/services/keyvault/keyvault_service.py @@ -70,6 +70,14 @@ class KeyVault(AzureService): "enable_purge_protection", False, ), + public_network_access_disabled=( + getattr( + keyvault_properties, + "public_network_access", + "Enabled", + ) + == "Disabled" + ), ), keys=keys, secrets=secrets, @@ -247,6 +255,7 @@ class VaultProperties: private_endpoint_connections: List[PrivateEndpointConnection] enable_soft_delete: bool enable_purge_protection: bool + public_network_access_disabled: bool = False @dataclass diff --git a/prowler/providers/azure/services/monitor/monitor_alert_service_health_exists/__init__.py b/prowler/providers/azure/services/monitor/monitor_alert_service_health_exists/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists.metadata.json b/prowler/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists.metadata.json new file mode 100644 index 0000000000..4354005d98 --- /dev/null +++ b/prowler/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "monitor_alert_service_health_exists", + "CheckTitle": "Ensure that an Activity Log Alert exists for Service Health", + "CheckType": [], + "ServiceName": "monitor", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Monitor", + "Description": "Ensure that an Azure activity log alert is configured to trigger when Service Health events occur within your Microsoft Azure cloud account. The alert should activate when new events match the specified conditions in the alert rule configuration.", + "Risk": "Lack of monitoring for Service Health events may result in missing critical service issues, planned maintenance, security advisories, or other changes that could impact Azure services and regions in use.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/service-health/overview", + "Remediation": { + "Code": { + "CLI": "az monitor activity-log alert create --subscription --resource-group --name --condition category=ServiceHealth and properties.incidentType=Incident --scope /subscriptions/ --action-group ", + "NativeIaC": "", + "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActivityLog/service-health-alert.html", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create an activity log alert for Service Health events and configure an action group to notify appropriate personnel.", + "Url": "https://learn.microsoft.com/en-us/azure/service-health/alerts-activity-log-service-notifications-portal" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "By default, in your Azure subscription there will not be any activity log alerts configured for Service Health events." +} diff --git a/prowler/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists.py b/prowler/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists.py new file mode 100644 index 0000000000..94dc9747c3 --- /dev/null +++ b/prowler/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists.py @@ -0,0 +1,48 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.monitor.monitor_client import monitor_client + + +class monitor_alert_service_health_exists(Check): + def execute(self) -> list[Check_Report_Azure]: + findings = [] + + for ( + subscription_name, + activity_log_alerts, + ) in monitor_client.alert_rules.items(): + for alert_rule in activity_log_alerts: + # Check if alert rule is enabled and has required Service Health conditions + if alert_rule.enabled: + has_service_health_category = False + has_incident_type_incident = False + for element in alert_rule.condition.all_of: + if ( + element.field == "category" + and element.equals == "ServiceHealth" + ): + has_service_health_category = True + if ( + element.field == "properties.incidentType" + and element.equals == "Incident" + ): + has_incident_type_incident = True + + if has_service_health_category and has_incident_type_incident: + report = Check_Report_Azure( + metadata=self.metadata(), resource=alert_rule + ) + report.subscription = subscription_name + report.status = "PASS" + report.status_extended = f"There is an activity log alert for Service Health in subscription {subscription_name}." + break + else: + report = Check_Report_Azure(metadata=self.metadata(), resource={}) + report.subscription = subscription_name + report.resource_name = "Monitor" + report.resource_id = "Monitor" + report.status = "FAIL" + report.status_extended = f"There is no activity log alert for Service Health in subscription {subscription_name}." + + findings.append(report) + + return findings diff --git a/prowler/providers/azure/services/monitor/monitor_service.py b/prowler/providers/azure/services/monitor/monitor_service.py index 7f5b9c8232..b4542a7b14 100644 --- a/prowler/providers/azure/services/monitor/monitor_service.py +++ b/prowler/providers/azure/services/monitor/monitor_service.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List +from typing import List, Optional from azure.mgmt.monitor import MonitorManagementClient @@ -131,4 +131,4 @@ class AlertRule: name: str condition: AlertRuleAllOfCondition enabled: bool - description: str + description: Optional[str] diff --git a/prowler/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days.metadata.json b/prowler/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days.metadata.json index d4407f1f02..4cb5a2ef58 100644 --- a/prowler/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days.metadata.json +++ b/prowler/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days.metadata.json @@ -1,7 +1,7 @@ { "Provider": "azure", "CheckID": "network_flow_log_more_than_90_days", - "CheckTitle": "Ensure that Network Security Group Flow Log retention period is 'greater than 90 days'", + "CheckTitle": "Ensure that Network Security Group Flow Log retention period is 0, 90 days or greater", "CheckType": [], "ServiceName": "network", "SubServiceName": "", diff --git a/prowler/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days.py b/prowler/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days.py index f073bb570d..69d17b5e0a 100644 --- a/prowler/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days.py +++ b/prowler/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days.py @@ -21,7 +21,10 @@ class network_flow_log_more_than_90_days(Check): report.status = "FAIL" report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription} has flow logs disabled" has_failed = True - elif flow_log.retention_policy.days < 90 and not has_failed: + elif ( + flow_log.retention_policy.days < 90 + and flow_log.retention_policy.days != 0 + ) and not has_failed: report.status = "FAIL" report.status_extended = f"Network Watcher {network_watcher.name} from subscription {subscription} flow logs retention policy is less than 90 days" has_failed = True diff --git a/prowler/providers/azure/services/storage/storage_blob_versioning_is_enabled/__init__.py b/prowler/providers/azure/services/storage/storage_blob_versioning_is_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled.metadata.json b/prowler/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled.metadata.json new file mode 100644 index 0000000000..d4f91b34f3 --- /dev/null +++ b/prowler/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "storage_blob_versioning_is_enabled", + "CheckTitle": "Ensure Blob Versioning is Enabled on Azure Blob Storage Accounts", + "CheckType": [], + "ServiceName": "storage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "AzureStorageAccount", + "Description": "Ensure that blob versioning is enabled on Azure Blob Storage accounts to automatically retain previous versions of objects.", + "Risk": "Without blob versioning, accidental or malicious changes to blobs cannot be easily recovered, leading to potential data loss.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/storage/blobs/versioning-enable", + "Remediation": { + "Code": { + "CLI": "az storage account blob-service-properties update --resource-group --account-name --enable-versioning true", + "NativeIaC": "", + "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/StorageAccounts/enable-versioning-for-blobs.html", + "Terraform": "resource \"azurerm_storage_account\" \"example\" {\n name = \"examplestorageacct\"\n resource_group_name = azurerm_resource_group.example.name\n location = azurerm_resource_group.example.location\n account_tier = \"Standard\"\n account_replication_type = \"LRS\"\n\n blob_properties {\n versioning_enabled = true\n }\n}\n" + }, + "Recommendation": { + "Text": "Enable blob versioning for all Azure Storage accounts that store critical or sensitive data.", + "Url": "https://learn.microsoft.com/en-us/azure/storage/blobs/versioning-enable" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled.py b/prowler/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled.py new file mode 100644 index 0000000000..cf55d6f830 --- /dev/null +++ b/prowler/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled.py @@ -0,0 +1,24 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.storage.storage_client import storage_client + + +class storage_blob_versioning_is_enabled(Check): + def execute(self) -> Check_Report_Azure: + findings = [] + for subscription, storage_accounts in storage_client.storage_accounts.items(): + for storage_account in storage_accounts: + if storage_account.blob_properties: + report = Check_Report_Azure( + metadata=self.metadata(), resource=storage_account + ) + report.subscription = subscription + if getattr( + storage_account.blob_properties, "versioning_enabled", False + ): + report.status = "PASS" + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has blob versioning enabled." + else: + report.status = "FAIL" + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} does not have blob versioning enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/storage/storage_cross_tenant_replication_disabled/__init__.py b/prowler/providers/azure/services/storage/storage_cross_tenant_replication_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled.metadata.json b/prowler/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled.metadata.json new file mode 100644 index 0000000000..0ece0d3823 --- /dev/null +++ b/prowler/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "storage_cross_tenant_replication_disabled", + "CheckTitle": "Ensure cross-tenant replication is disabled", + "CheckType": [], + "ServiceName": "storage", + "SubServiceName": "account", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AzureStorageAccount", + "Description": "Ensure that cross-tenant replication is not enabled on Azure Storage Accounts to prevent unintended replication of data across tenant boundaries.", + "Risk": "If cross-tenant replication is enabled, sensitive data could be inadvertently replicated across tenants, increasing the risk of data leakage, unauthorized access, or non-compliance with data governance and privacy policies.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/storage/blobs/object-replication-prevent-cross-tenant-policies?tabs=portal", + "Remediation": { + "Code": { + "CLI": "az storage account update --name --resource-group --default-to-oauth-authentication true --allow-cross-tenant-replication false", + "NativeIaC": "", + "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/StorageAccounts/disable-cross-tenant-replication.html", + "Terraform": "" + }, + "Recommendation": { + "Text": "Disable Cross Tenant Replication on storage accounts to ensure that data remains within tenant boundaries unless explicitly shared, reducing the risk of data leakage and unauthorized access.", + "Url": "https://learn.microsoft.com/en-us/azure/storage/blobs/object-replication-prevent-cross-tenant-policies?tabs=portal" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled.py b/prowler/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled.py new file mode 100644 index 0000000000..65ed50545e --- /dev/null +++ b/prowler/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_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_cross_tenant_replication_disabled(Check): + """Check if cross-tenant replication is disabled. + + Attributes: + metadata: Metadata associated with the check (inherited from Check). + """ + + def execute(self) -> Check_Report_Azure: + """Execute the check for cross-tenant replication. + + This method checks if cross-tenant replication 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_cross_tenant_replication: + report.status = "PASS" + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has cross-tenant replication disabled." + else: + report.status = "FAIL" + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has cross-tenant replication enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/__init__.py b/prowler/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled.metadata.json b/prowler/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled.metadata.json new file mode 100644 index 0000000000..00c1f2623b --- /dev/null +++ b/prowler/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "storage_default_to_entra_authorization_enabled", + "CheckTitle": "Ensure Microsoft Entra authorization is enabled by default for Azure Storage Accounts", + "CheckType": [], + "ServiceName": "storage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AzureStorageAccount", + "Description": "Ensure that the Azure Storage Account setting 'Default to Microsoft Entra authorization in the Azure portal' is enabled to enforce the use of Microsoft Entra ID for accessing blobs, files, queues, and tables.", + "Risk": "If this setting is not enabled, the Azure portal may authorize access using less secure methods such as Shared Key, increasing the risk of unauthorized data access.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/storage/blobs/authorize-access-azure-active-directory", + "Remediation": { + "Code": { + "CLI": "az storage account update --name --resource-group --default-to-AzAd-auth true", + "NativeIaC": "", + "Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/StorageAccounts/enable-microsoft-entra-authorization-by-default.html", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable Microsoft Entra authorization by default in the Azure portal to enhance security and avoid reliance on Shared Key authentication.", + "Url": "https://learn.microsoft.com/en-us/azure/storage/blobs/authorize-access-azure-active-directory" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled.py b/prowler/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled.py new file mode 100644 index 0000000000..4b82c363e5 --- /dev/null +++ b/prowler/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled.py @@ -0,0 +1,38 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.storage.storage_client import storage_client + + +class storage_default_to_entra_authorization_enabled(Check): + """Check if the default to Microsoft Entra authorization is enabled for the storage account. + + Attributes: + metadata: Metadata associated with the check (inherited from Check). + + """ + + def execute(self) -> Check_Report_Azure: + """Execute the check for the default to Microsoft Entra authorization. + + This method checks if the default to Microsoft Entra authorization is enabled for the storage account. + + 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 + report.resource_name = storage_account.name + report.resource_id = storage_account.id + report.status = "FAIL" + report.status_extended = f"Default to Microsoft Entra authorization is not enabled for storage account {storage_account.name}." + + if storage_account.default_to_entra_authorization: + report.status = "PASS" + report.status_extended = f"Default to Microsoft Entra authorization is enabled for storage account {storage_account.name}." + + findings.append(report) + return findings 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 index 6746a0c63d..efdf786e0b 100644 --- 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 @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "", + "ResourceType": "AzureStorageAccount", "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", diff --git a/prowler/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled.py b/prowler/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled.py index 9a08809c8e..cf92ee25f3 100644 --- a/prowler/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled.py +++ b/prowler/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled.py @@ -7,29 +7,24 @@ class storage_ensure_file_shares_soft_delete_is_enabled(Check): 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) + if getattr(storage_account, "file_service_properties", None): + report = Check_Report_Azure( + metadata=self.metadata(), + resource=storage_account.file_service_properties, + ) + report.subscription = subscription + report.resource_name = storage_account.name + report.location = storage_account.location + + if ( + storage_account.file_service_properties.share_delete_retention_policy.enabled + ): + report.status = "PASS" + report.status_extended = f"File share soft delete is enabled for storage account {storage_account.name} with a retention period of {storage_account.file_service_properties.share_delete_retention_policy.days} days." + else: + report.status = "FAIL" + report.status_extended = f"File share soft delete is not enabled for storage account {storage_account.name}." + + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/storage/storage_geo_redundant_enabled/__init__.py b/prowler/providers/azure/services/storage/storage_geo_redundant_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled.metadata.json b/prowler/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled.metadata.json new file mode 100644 index 0000000000..35dc796130 --- /dev/null +++ b/prowler/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "storage_geo_redundant_enabled", + "CheckTitle": "Ensure geo-redundant storage (GRS) is enabled on critical Azure Storage Accounts", + "CheckType": [], + "ServiceName": "storage", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "AzureStorageAccount", + "Description": "Geo-redundant storage (GRS) must be enabled on critical Azure Storage Accounts to ensure data durability and availability in the event of a regional outage. GRS replicates data within the primary region and asynchronously to a secondary region, offering enhanced resilience and supporting disaster recovery strategies.", + "Risk": "Without GRS, critical data may be lost or become unavailable during a regional outage, compromising data durability and disaster recovery efforts.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/storage/common/storage-redundancy", + "Remediation": { + "Code": { + "CLI": "az storage account update --name --resource-group --sku Standard_GRS", + "NativeIaC": "", + "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/StorageAccounts/enable-geo-redundant-storage.html", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable geo-redundant storage (GRS) for critical Azure Storage Accounts to ensure data durability and availability across regional failures.", + "Url": "https://learn.microsoft.com/en-us/azure/storage/common/storage-redundancy" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled.py b/prowler/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled.py new file mode 100644 index 0000000000..971b440b0e --- /dev/null +++ b/prowler/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.storage.storage_client import storage_client +from prowler.providers.azure.services.storage.storage_service import ReplicationSettings + + +class storage_geo_redundant_enabled(Check): + """Check if geo-redundant storage (GRS) is enabled on critical Azure Storage Accounts. + + Attributes: + metadata: Metadata associated with the check (inherited from Check). + """ + + def execute(self) -> Check_Report_Azure: + """Execute the check for geo-redundant storage (GRS). + + This method checks if geo-redundant storage (GRS) is enabled on critical Azure Storage Accounts. + + 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 ( + storage_account.replication_settings + == ReplicationSettings.STANDARD_GRS + ): + report.status = "PASS" + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} has Geo-redundant storage (GRS) enabled." + else: + report.status = "FAIL" + report.status_extended = f"Storage account {storage_account.name} from subscription {subscription} does not have Geo-redundant storage (GRS) enabled." + + 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 02533b503e..34c5ff1143 100644 --- a/prowler/providers/azure/services/storage/storage_service.py +++ b/prowler/providers/azure/services/storage/storage_service.py @@ -1,7 +1,9 @@ from dataclasses import dataclass +from enum import Enum from typing import List, Optional from azure.mgmt.storage import StorageManagementClient +from pydantic import BaseModel from prowler.lib.logger import logger from prowler.providers.azure.azure_provider import AzureProvider @@ -34,6 +36,7 @@ class Storage(AzureService): key_expiration_period_in_days = ( storage_account.key_policy.key_expiration_period_in_days ) + replication_settings = ReplicationSettings(storage_account.sku.name) storage_accounts[subscription].append( Account( id=storage_account.id, @@ -68,6 +71,15 @@ class Storage(AzureService): ], key_expiration_period_in_days=key_expiration_period_in_days, location=storage_account.location, + default_to_entra_authorization=getattr( + storage_account, + "default_to_o_auth_authentication", + False, + ), + replication_settings=replication_settings, + allow_cross_tenant_replication=getattr( + storage_account, "allow_cross_tenant_replication", True + ), allow_shared_key_access=getattr( storage_account, "allow_shared_key_access", True ), @@ -92,6 +104,9 @@ class Storage(AzureService): container_delete_retention_policy = getattr( properties, "container_delete_retention_policy", None ) + versioning_enabled = getattr( + properties, "is_versioning_enabled", False + ) account.blob_properties = BlobProperties( id=properties.id, name=properties.name, @@ -107,6 +122,7 @@ class Storage(AzureService): container_delete_retention_policy, "days", 0 ), ), + versioning_enabled=versioning_enabled, ) except Exception as error: if ( @@ -132,43 +148,33 @@ class Storage(AzureService): 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, + file_service_properties = ( + client.file_services.get_service_properties( + account.resouce_group_name, account.name ) - retention_days = ( - getattr( - service_properties.share_delete_retention_policy, + ) + share_delete_retention_policy = getattr( + file_service_properties, + "share_delete_retention_policy", + None, + ) + account.file_service_properties = FileServiceProperties( + id=file_service_properties.id, + name=file_service_properties.name, + type=file_service_properties.type, + share_delete_retention_policy=DeleteRetentionPolicy( + enabled=getattr( + share_delete_retention_policy, + "enabled", + False, + ), + days=getattr( + 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}" @@ -188,6 +194,7 @@ class BlobProperties: type: str default_service_version: str container_delete_retention_policy: DeleteRetentionPolicy + versioning_enabled: bool = False @dataclass @@ -203,6 +210,24 @@ class PrivateEndpointConnection: type: str +class ReplicationSettings(Enum): + STANDARD_LRS = "Standard_LRS" + STANDARD_GRS = "Standard_GRS" + STANDARD_RAGRS = "Standard_RAGRS" + STANDARD_ZRS = "Standard_ZRS" + PREMIUM_LRS = "Premium_LRS" + PREMIUM_ZRS = "Premium_ZRS" + STANDARD_GZRS = "Standard_GZRS" + STANDARD_RAGZRS = "Standard_RAGZRS" + + +class FileServiceProperties(BaseModel): + id: str + name: str + type: str + share_delete_retention_policy: DeleteRetentionPolicy + + @dataclass class Account: id: str @@ -217,14 +242,9 @@ class Account: private_endpoint_connections: List[PrivateEndpointConnection] key_expiration_period_in_days: str location: str + replication_settings: ReplicationSettings = ReplicationSettings.STANDARD_LRS + allow_cross_tenant_replication: bool = True 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 + default_to_entra_authorization: bool = False + file_service_properties: Optional[FileServiceProperties] = None diff --git a/prowler/providers/common/models.py b/prowler/providers/common/models.py index 47c9ebd2fe..ea70252f0a 100644 --- a/prowler/providers/common/models.py +++ b/prowler/providers/common/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from os import makedirs from os.path import isdir -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.providers.common.provider import Provider diff --git a/prowler/providers/common/provider.py b/prowler/providers/common/provider.py index 9c97d63fbc..841bd0f1ed 100644 --- a/prowler/providers/common/provider.py +++ b/prowler/providers/common/provider.py @@ -243,6 +243,14 @@ class Provider(ABC): mutelist_path=arguments.mutelist_file, config_path=arguments.config_file, ) + elif "iac" in provider_class_name.lower(): + provider_class( + scan_path=arguments.scan_path, + frameworks=arguments.frameworks, + exclude_path=arguments.exclude_path, + config_path=arguments.config_file, + fixer_config=fixer_config, + ) except TypeError as error: logger.critical( diff --git a/prowler/providers/gcp/models.py b/prowler/providers/gcp/models.py index 3505293fd8..e1688b7973 100644 --- a/prowler/providers/gcp/models.py +++ b/prowler/providers/gcp/models.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.config.config import output_file_timestamp from prowler.providers.common.models import ProviderOutputOptions @@ -14,14 +14,14 @@ class GCPOrganization(BaseModel): id: str name: str # TODO: the name needs to be retrieved from another API - display_name: Optional[str] + display_name: Optional[str] = None class GCPProject(BaseModel): - number: str + number: int id: str name: str - organization: Optional[GCPOrganization] + organization: Optional[GCPOrganization] = None labels: dict lifecycle_state: str diff --git a/prowler/providers/gcp/services/apikeys/apikeys_service.py b/prowler/providers/gcp/services/apikeys/apikeys_service.py index 73dc504ede..65379c761f 100644 --- a/prowler/providers/gcp/services/apikeys/apikeys_service.py +++ b/prowler/providers/gcp/services/apikeys/apikeys_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.gcp_provider import GcpProvider diff --git a/prowler/providers/gcp/services/bigquery/bigquery_service.py b/prowler/providers/gcp/services/bigquery/bigquery_service.py index e06b976dc0..c61e56250e 100644 --- a/prowler/providers/gcp/services/bigquery/bigquery_service.py +++ b/prowler/providers/gcp/services/bigquery/bigquery_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.gcp_provider import GcpProvider diff --git a/prowler/providers/gcp/services/cloudresourcemanager/cloudresourcemanager_service.py b/prowler/providers/gcp/services/cloudresourcemanager/cloudresourcemanager_service.py index 28e2f4d832..7687066b95 100644 --- a/prowler/providers/gcp/services/cloudresourcemanager/cloudresourcemanager_service.py +++ b/prowler/providers/gcp/services/cloudresourcemanager/cloudresourcemanager_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.gcp_provider import GcpProvider diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_service.py b/prowler/providers/gcp/services/cloudsql/cloudsql_service.py index d0dbe5137d..1a2df51cee 100644 --- a/prowler/providers/gcp/services/cloudsql/cloudsql_service.py +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.gcp_provider import GcpProvider diff --git a/prowler/providers/gcp/services/cloudstorage/cloudstorage_service.py b/prowler/providers/gcp/services/cloudstorage/cloudstorage_service.py index 0e2e74d27c..65e13370fd 100644 --- a/prowler/providers/gcp/services/cloudstorage/cloudstorage_service.py +++ b/prowler/providers/gcp/services/cloudstorage/cloudstorage_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.gcp_provider import GcpProvider @@ -60,4 +60,4 @@ class Bucket(BaseModel): uniform_bucket_level_access: bool public: bool project_id: str - retention_policy: Optional[dict] + retention_policy: Optional[dict] = None diff --git a/prowler/providers/gcp/services/compute/compute_instance_block_project_wide_ssh_keys_disabled/compute_instance_block_project_wide_ssh_keys_disabled.py b/prowler/providers/gcp/services/compute/compute_instance_block_project_wide_ssh_keys_disabled/compute_instance_block_project_wide_ssh_keys_disabled.py index 4ee3c9f029..d49aed45bd 100644 --- a/prowler/providers/gcp/services/compute/compute_instance_block_project_wide_ssh_keys_disabled/compute_instance_block_project_wide_ssh_keys_disabled.py +++ b/prowler/providers/gcp/services/compute/compute_instance_block_project_wide_ssh_keys_disabled/compute_instance_block_project_wide_ssh_keys_disabled.py @@ -13,7 +13,7 @@ class compute_instance_block_project_wide_ssh_keys_disabled(Check): for item in instance.metadata["items"]: if ( item["key"] == "block-project-ssh-keys" - and item["value"] == "true" + and item["value"].lower() == "true" ): report.status = "PASS" report.status_extended = f"The VM Instance {instance.name} is not making use of common/shared project-wide SSH key(s)." diff --git a/prowler/providers/gcp/services/compute/compute_project_os_login_enabled/compute_project_os_login_enabled.metadata.json b/prowler/providers/gcp/services/compute/compute_project_os_login_enabled/compute_project_os_login_enabled.metadata.json index 4e6edde752..1362070efe 100644 --- a/prowler/providers/gcp/services/compute/compute_project_os_login_enabled/compute_project_os_login_enabled.metadata.json +++ b/prowler/providers/gcp/services/compute/compute_project_os_login_enabled/compute_project_os_login_enabled.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "", "ResourceIdTemplate": "", "Severity": "low", - "ResourceType": "Project", + "ResourceType": "GCPProject", "Description": "Ensure that the OS Login feature is enabled at the Google Cloud Platform (GCP) project level in order to provide you with centralized and automated SSH key pair management.", "Risk": "Enabling OS Login feature ensures that the SSH keys used to connect to VM instances are mapped with Google Cloud IAM users. Revoking access to corresponding IAM users will revoke all the SSH keys associated with these users, therefore it facilitates centralized SSH key pair management, which is extremely useful in handling compromised or stolen SSH key pairs and/or revocation of external/third-party/vendor users.", "RelatedUrl": "", diff --git a/prowler/providers/gcp/services/compute/compute_service.py b/prowler/providers/gcp/services/compute/compute_service.py index e3e571ab0f..3eca63c7c9 100644 --- a/prowler/providers/gcp/services/compute/compute_service.py +++ b/prowler/providers/gcp/services/compute/compute_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.gcp_provider import GcpProvider diff --git a/prowler/providers/gcp/services/dataproc/dataproc_service.py b/prowler/providers/gcp/services/dataproc/dataproc_service.py index 68c05e407a..5b7825ceca 100644 --- a/prowler/providers/gcp/services/dataproc/dataproc_service.py +++ b/prowler/providers/gcp/services/dataproc/dataproc_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.gcp_provider import GcpProvider diff --git a/prowler/providers/gcp/services/dns/dns_service.py b/prowler/providers/gcp/services/dns/dns_service.py index dc01c5cf95..2e4ac74639 100644 --- a/prowler/providers/gcp/services/dns/dns_service.py +++ b/prowler/providers/gcp/services/dns/dns_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.gcp_provider import GcpProvider diff --git a/prowler/providers/gcp/services/gke/gke_service.py b/prowler/providers/gcp/services/gke/gke_service.py index a9b42ca046..7bd7b3214c 100644 --- a/prowler/providers/gcp/services/gke/gke_service.py +++ b/prowler/providers/gcp/services/gke/gke_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.gcp_provider import GcpProvider diff --git a/prowler/providers/gcp/services/iam/iam_audit_logs_enabled/iam_audit_logs_enabled.metadata.json b/prowler/providers/gcp/services/iam/iam_audit_logs_enabled/iam_audit_logs_enabled.metadata.json index e3d448c257..3e7589c783 100644 --- a/prowler/providers/gcp/services/iam/iam_audit_logs_enabled/iam_audit_logs_enabled.metadata.json +++ b/prowler/providers/gcp/services/iam/iam_audit_logs_enabled/iam_audit_logs_enabled.metadata.json @@ -7,7 +7,7 @@ "SubServiceName": "Audit Logs", "ResourceIdTemplate": "", "Severity": "medium", - "ResourceType": "", + "ResourceType": "GCPProject", "Description": "Ensure that Google Cloud Audit Logs feature is configured to track Data Access logs for all Google Cloud Platform (GCP) services and users, in order to enhance overall access security and meet compliance requirements. Once configured, the feature can record all admin related activities, as well as all the read and write access requests to user data.", "Risk": "In order to maintain an effective Google Cloud audit configuration for your project, folder, and organization, all 3 types of Data Access logs (ADMIN_READ, DATA_READ and DATA_WRITE) must be enabled for all supported GCP services. Also, Data Access logs should be captured for all IAM users, without exempting any of them. Exemptions let you control which users generate audit logs. When you add an exempted user to your log configuration, audit logs are not created for that user, for the selected log type(s). Data Access audit logs are disabled by default and must be explicitly enabled based on your business requirements.", "RelatedUrl": "", diff --git a/prowler/providers/gcp/services/iam/iam_service.py b/prowler/providers/gcp/services/iam/iam_service.py index 08e1ee4c3a..33287aa1ac 100644 --- a/prowler/providers/gcp/services/iam/iam_service.py +++ b/prowler/providers/gcp/services/iam/iam_service.py @@ -1,6 +1,6 @@ from datetime import datetime -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.gcp_provider import GcpProvider diff --git a/prowler/providers/gcp/services/kms/kms_service.py b/prowler/providers/gcp/services/kms/kms_service.py index 3ac498d2ba..a4ec01f292 100644 --- a/prowler/providers/gcp/services/kms/kms_service.py +++ b/prowler/providers/gcp/services/kms/kms_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.gcp_provider import GcpProvider @@ -143,8 +143,8 @@ class CriptoKey(BaseModel): id: str name: str location: str - rotation_period: Optional[str] - next_rotation_time: Optional[str] + rotation_period: Optional[str] = None + next_rotation_time: Optional[str] = None key_ring: str members: list = [] project_id: str diff --git a/prowler/providers/gcp/services/logging/logging_service.py b/prowler/providers/gcp/services/logging/logging_service.py index 98f0fd1fba..e4e7d4c866 100644 --- a/prowler/providers/gcp/services/logging/logging_service.py +++ b/prowler/providers/gcp/services/logging/logging_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.gcp_provider import GcpProvider diff --git a/prowler/providers/gcp/services/monitoring/monitoring_service.py b/prowler/providers/gcp/services/monitoring/monitoring_service.py index 575434a441..d6da6caa4d 100644 --- a/prowler/providers/gcp/services/monitoring/monitoring_service.py +++ b/prowler/providers/gcp/services/monitoring/monitoring_service.py @@ -1,6 +1,6 @@ import datetime -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.gcp_provider import GcpProvider diff --git a/prowler/providers/gcp/services/serviceusage/serviceusage_service.py b/prowler/providers/gcp/services/serviceusage/serviceusage_service.py index f4e8ab8732..2f65da32f5 100644 --- a/prowler/providers/gcp/services/serviceusage/serviceusage_service.py +++ b/prowler/providers/gcp/services/serviceusage/serviceusage_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.gcp.gcp_provider import GcpProvider diff --git a/prowler/providers/github/models.py b/prowler/providers/github/models.py index a4fb2fdc36..b133dc9a69 100644 --- a/prowler/providers/github/models.py +++ b/prowler/providers/github/models.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.config.config import output_file_timestamp from prowler.providers.common.models import ProviderOutputOptions diff --git a/prowler/providers/github/services/organization/organization_service.py b/prowler/providers/github/services/organization/organization_service.py index 3f8f8dca95..51654fc43f 100644 --- a/prowler/providers/github/services/organization/organization_service.py +++ b/prowler/providers/github/services/organization/organization_service.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.github.lib.service.service import GithubService diff --git a/prowler/providers/github/services/repository/repository_secret_scanning_enabled/__init__.py b/prowler/providers/github/services/repository/repository_secret_scanning_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/github/services/repository/repository_service.py b/prowler/providers/github/services/repository/repository_service.py index 6c0101503b..dee6f35900 100644 --- a/prowler/providers/github/services/repository/repository_service.py +++ b/prowler/providers/github/services/repository/repository_service.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.github.lib.service.service import GithubService diff --git a/prowler/providers/iac/__init__.py b/prowler/providers/iac/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/iac/iac_provider.py b/prowler/providers/iac/iac_provider.py new file mode 100644 index 0000000000..1abd00f4c5 --- /dev/null +++ b/prowler/providers/iac/iac_provider.py @@ -0,0 +1,263 @@ +import json +import sys +from typing import List + +from checkov.ansible.runner import Runner as AnsibleRunner +from checkov.argo_workflows.runner import Runner as ArgoWorkflowsRunner +from checkov.arm.runner import Runner as ArmRunner +from checkov.azure_pipelines.runner import Runner as AzurePipelinesRunner +from checkov.bicep.runner import Runner as BicepRunner +from checkov.bitbucket.runner import Runner as BitbucketRunner +from checkov.bitbucket_pipelines.runner import Runner as BitbucketPipelinesRunner +from checkov.cdk.runner import CdkRunner +from checkov.circleci_pipelines.runner import Runner as CircleciPipelinesRunner +from checkov.cloudformation.runner import Runner as CfnRunner +from checkov.common.output.record import Record +from checkov.common.output.report import Report +from checkov.common.runners.runner_registry import RunnerRegistry +from checkov.dockerfile.runner import Runner as DockerfileRunner +from checkov.github.runner import Runner as GithubRunner +from checkov.github_actions.runner import Runner as GithubActionsRunner +from checkov.gitlab.runner import Runner as GitlabRunner +from checkov.gitlab_ci.runner import Runner as GitlabCiRunner +from checkov.helm.runner import Runner as HelmRunner +from checkov.json_doc.runner import Runner as JsonDocRunner +from checkov.kubernetes.runner import Runner as K8sRunner +from checkov.kustomize.runner import Runner as KustomizeRunner +from checkov.openapi.runner import Runner as OpenapiRunner +from checkov.runner_filter import RunnerFilter +from checkov.sast.runner import Runner as SastRunner +from checkov.sca_image.runner import Runner as ScaImageRunner +from checkov.sca_package_2.runner import Runner as ScaPackage2Runner +from checkov.secrets.runner import Runner as SecretsRunner +from checkov.serverless.runner import Runner as ServerlessRunner +from checkov.terraform.runner import Runner as TerraformRunner +from checkov.terraform_json.runner import TerraformJsonRunner +from checkov.yaml_doc.runner import Runner as YamlDocRunner +from colorama import Fore, Style + +from prowler.config.config import ( + default_config_file_path, + load_and_validate_config_file, +) +from prowler.lib.check.models import CheckReportIAC +from prowler.lib.logger import logger +from prowler.lib.utils.utils import print_boxes +from prowler.providers.common.models import Audit_Metadata +from prowler.providers.common.provider import Provider + + +class IacProvider(Provider): + _type: str = "iac" + audit_metadata: Audit_Metadata + + def __init__( + self, + scan_path: str = ".", + frameworks: list[str] = ["all"], + exclude_path: list[str] = [], + config_path: str = None, + config_content: dict = None, + fixer_config: dict = {}, + ): + logger.info("Instantiating IAC Provider...") + + self.scan_path = scan_path + self.frameworks = frameworks + self.exclude_path = exclude_path + self.region = "global" + self.audited_account = "local-iac" + self._session = None + self._identity = "prowler" + + # Audit Config + if config_content: + self._audit_config = config_content + else: + if not config_path: + config_path = default_config_file_path + self._audit_config = load_and_validate_config_file(self._type, config_path) + + # Fixer Config + self._fixer_config = fixer_config + + # Mutelist (not needed for IAC since Checkov has its own mutelist logic) + self._mutelist = None + + self.audit_metadata = Audit_Metadata( + provider=self._type, + account_id=self.audited_account, + account_name="iac", + region=self.region, + services_scanned=0, # IAC doesn't use services + expected_checks=[], # IAC doesn't use checks + completed_checks=0, # IAC doesn't use checks + audit_progress=0, # IAC doesn't use progress tracking + ) + + Provider.set_global_provider(self) + + @property + def type(self): + return self._type + + @property + def identity(self): + return self._identity + + @property + def session(self): + return self._session + + @property + def audit_config(self): + return self._audit_config + + @property + def fixer_config(self): + return self._fixer_config + + def setup_session(self): + """IAC provider doesn't need a session since it uses Checkov directly""" + return None + + def _process_check( + self, finding: Report, check: Record, status: str + ) -> CheckReportIAC: + """ + Process a single check (failed or passed) and create a CheckReportIAC object. + + Args: + finding: The finding object from Checkov output + check: The individual check data (failed_check or passed_check) + status: The status of the check ("FAIL" or "PASS") + + Returns: + CheckReportIAC: The processed check report + """ + try: + metadata_dict = { + "Provider": "iac", + "CheckID": check.check_id, + "CheckTitle": check.check_name, + "CheckType": ["Infrastructure as Code"], + "ServiceName": finding.check_type, + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": (check.severity.lower() if check.severity else "low"), + "ResourceType": finding.check_type, + "Description": check.check_name, + "Risk": "", + "RelatedUrl": (check.guideline if check.guideline else ""), + "Remediation": { + "Code": { + "NativeIaC": "", + "Terraform": "", + "CLI": "", + "Other": "", + }, + "Recommendation": { + "Text": "", + "Url": (check.guideline if check.guideline else ""), + }, + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "", + } + + # Convert metadata dict to JSON string + metadata = json.dumps(metadata_dict) + + report = CheckReportIAC(metadata=metadata, resource=check) + report.status = status + report.resource_tags = check.entity_tags + report.status_extended = check.check_name + if status == "MUTED": + report.muted = True + return report + except Exception as error: + logger.critical( + f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" + ) + sys.exit(1) + + def run(self) -> List[CheckReportIAC]: + return self.run_scan(self.scan_path, self.frameworks, self.exclude_path) + + def run_scan( + self, directory: str, frameworks: list[str], exclude_path: list[str] + ) -> List[CheckReportIAC]: + try: + logger.info(f"Running IaC scan on {directory}...") + runners = [ + TerraformRunner(), + CfnRunner(), + K8sRunner(), + ArmRunner(), + ServerlessRunner(), + DockerfileRunner(), + YamlDocRunner(), + OpenapiRunner(), + SastRunner(), + ScaImageRunner(), + ScaPackage2Runner(), + SecretsRunner(), + AnsibleRunner(), + ArgoWorkflowsRunner(), + BitbucketRunner(), + BitbucketPipelinesRunner(), + CdkRunner(), + CircleciPipelinesRunner(), + GithubRunner(), + GithubActionsRunner(), + GitlabRunner(), + GitlabCiRunner(), + HelmRunner(), + JsonDocRunner(), + TerraformJsonRunner(), + KustomizeRunner(), + AzurePipelinesRunner(), + BicepRunner(), + ] + runner_filter = RunnerFilter( + framework=frameworks, excluded_paths=exclude_path + ) + + registry = RunnerRegistry("", runner_filter, *runners) + checkov_reports = registry.run(root_folder=directory) + + reports: List[CheckReportIAC] = [] + for report in checkov_reports: + + for failed in report.failed_checks: + reports.append(self._process_check(report, failed, "FAIL")) + + for passed in report.passed_checks: + reports.append(self._process_check(report, passed, "PASS")) + + for skipped in report.skipped_checks: + reports.append(self._process_check(report, skipped, "MUTED")) + + return reports + + except Exception as error: + logger.critical( + f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}" + ) + sys.exit(1) + + def print_credentials(self): + report_lines = [ + f"Directory: {Fore.YELLOW}{self.scan_path}{Style.RESET_ALL}", + ] + if self.exclude_path: + report_lines.append( + f"Excluded paths: {Fore.YELLOW}{', '.join(self.exclude_path)}{Style.RESET_ALL}" + ) + report_lines.append( + f"Frameworks: {Fore.YELLOW}{', '.join(self.frameworks)}{Style.RESET_ALL}" + ) + report_title = f"{Style.BRIGHT}Scanning local IaC directory:{Style.RESET_ALL}" + print_boxes(report_lines, report_title) diff --git a/prowler/providers/iac/lib/__init__.py b/prowler/providers/iac/lib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/iac/lib/arguments/__init__.py b/prowler/providers/iac/lib/arguments/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/iac/lib/arguments/arguments.py b/prowler/providers/iac/lib/arguments/arguments.py new file mode 100644 index 0000000000..bc478bc4d1 --- /dev/null +++ b/prowler/providers/iac/lib/arguments/arguments.py @@ -0,0 +1,65 @@ +FRAMEWORK_CHOICES = [ + "ansible", + "argo_workflows", + "arm", + "azure_pipelines", + "bicep", + "bitbucket", + "bitbucket_pipelines", + "cdk", + "circleci_pipelines", + "cloudformation", + "dockerfile", + "github", + "github_actions", + "gitlab", + "gitlab_ci", + "helm", + "json_doc", + "kubernetes", + "kustomize", + "openapi", + "policies_3d", + "sast", + "sca_image", + "sca_package_2", + "secrets", + "serverless", + "terraform", + "terraform_json", + "yaml_doc", +] + + +def init_parser(self): + """Init the IAC Provider CLI parser""" + iac_parser = self.subparsers.add_parser( + "iac", parents=[self.common_providers_parser], help="IaC Provider" + ) + + # Scan Path + iac_scan_subparser = iac_parser.add_argument_group("Scan Path") + iac_scan_subparser.add_argument( + "--scan-path", + "-P", + dest="scan_path", + default=".", + help="Path to the folder containing your infrastructure-as-code files. Default: current directory", + ) + iac_scan_subparser.add_argument( + "--frameworks", + "-f", + "--framework", + dest="frameworks", + nargs="+", + default=["all"], + choices=FRAMEWORK_CHOICES, + help="Comma-separated list of frameworks to scan. Default: all", + ) + iac_scan_subparser.add_argument( + "--exclude-path", + dest="exclude_path", + nargs="+", + default=[], + help="Comma-separated list of paths to exclude from the scan. Default: none", + ) diff --git a/prowler/providers/iac/models.py b/prowler/providers/iac/models.py new file mode 100644 index 0000000000..3c9c04f849 --- /dev/null +++ b/prowler/providers/iac/models.py @@ -0,0 +1,27 @@ +from prowler.config.config import output_file_timestamp +from prowler.providers.common.models import ProviderOutputOptions + + +class IACOutputOptions(ProviderOutputOptions): + """ + IACOutputOptions overrides ProviderOutputOptions for IAC-specific output logic. + For example, generating a filename that includes the IAC tenant_id. + + Attributes inherited from ProviderOutputOptions: + - output_filename (str): The base filename used for generated reports. + - output_directory (str): The directory to store the output files. + - ... see ProviderOutputOptions for more details. + + Methods: + - __init__: Customizes the output filename logic for IAC. + """ + + def __init__(self, arguments, bulk_checks_metadata): + super().__init__(arguments, bulk_checks_metadata) + + # If --output-filename is not specified, build a default name. + if not getattr(arguments, "output_filename", None): + self.output_filename = f"prowler-output-iac-{output_file_timestamp}" + # If --output-filename was explicitly given, respect that + else: + self.output_filename = arguments.output_filename diff --git a/prowler/providers/kubernetes/services/core/core_service.py b/prowler/providers/kubernetes/services/core/core_service.py index ec6fb48ca8..e685140a87 100644 --- a/prowler/providers/kubernetes/services/core/core_service.py +++ b/prowler/providers/kubernetes/services/core/core_service.py @@ -1,7 +1,7 @@ import socket from typing import List, Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from kubernetes import client from prowler.lib.logger import logger @@ -171,7 +171,7 @@ class Pod(BaseModel): host_ip: Optional[str] host_pid: Optional[str] host_ipc: Optional[str] - host_network: Optional[str] + host_network: Optional[bool] security_context: Optional[dict] containers: Optional[dict] diff --git a/prowler/providers/kubernetes/services/rbac/rbac_service.py b/prowler/providers/kubernetes/services/rbac/rbac_service.py index db8550c318..5b5fa2afc6 100644 --- a/prowler/providers/kubernetes/services/rbac/rbac_service.py +++ b/prowler/providers/kubernetes/services/rbac/rbac_service.py @@ -1,6 +1,6 @@ from typing import Any, List, Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from kubernetes import client from prowler.lib.logger import logger @@ -162,9 +162,9 @@ class RoleBinding(BaseModel): class Rule(BaseModel): - apiGroups: Optional[List[str]] - resources: Optional[List[str]] - verbs: Optional[List[str]] + apiGroups: Optional[List[str]] = None + resources: Optional[List[str]] = None + verbs: Optional[List[str]] = None class Role(BaseModel): diff --git a/prowler/providers/m365/lib/powershell/m365_powershell.py b/prowler/providers/m365/lib/powershell/m365_powershell.py index fede39c401..d09e3543f9 100644 --- a/prowler/providers/m365/lib/powershell/m365_powershell.py +++ b/prowler/providers/m365/lib/powershell/m365_powershell.py @@ -161,10 +161,12 @@ class M365PowerShell(PowerShellSession): ) if result is None: - return False + raise Exception( + "Unexpected error: Acquiring token in behalf of user did not return a result." + ) if "access_token" not in result: - return False + raise Exception(f"MsGraph Error {result.get('error_description')}") return True diff --git a/prowler/providers/m365/m365_provider.py b/prowler/providers/m365/m365_provider.py index cf647ce978..ea6cd5a5c0 100644 --- a/prowler/providers/m365/m365_provider.py +++ b/prowler/providers/m365/m365_provider.py @@ -196,6 +196,8 @@ class M365Provider(Provider): self._identity = self.setup_identity( sp_env_auth, env_auth, + browser_auth, + az_cli_auth, self._session, ) @@ -350,7 +352,6 @@ class M365Provider(Provider): """ try: config = get_regions_config(region) - return M365RegionConfig( name=region, authority=config["authority"], @@ -505,6 +506,7 @@ class M365Provider(Provider): Exception: If failed to retrieve M365 credentials. """ + logger.info("M365 provider: Setting up session...") if not browser_auth: if sp_env_auth or env_auth: try: @@ -717,6 +719,8 @@ class M365Provider(Provider): identity = M365Provider.setup_identity( sp_env_auth, env_auth, + browser_auth, + az_cli_auth, session, ) @@ -732,6 +736,8 @@ class M365Provider(Provider): message=f"The provider ID {provider_id} does not match any of the service principal tenant domains: {', '.join(identity.tenant_domains)}", ) + logger.info("M365 provider: Identity retrieved successfully") + # Set up PowerShell credentials if user and password: M365Provider.setup_powershell( @@ -739,13 +745,12 @@ class M365Provider(Provider): m365_credentials, identity, ) + logger.info("M365 provider: Connection to PowerShell successful") else: logger.info( "M365 provider: Connection to PowerShell has not been requested" ) - logger.info("M365 provider: Connection to PowerShell successful") - return Connection(is_connected=True) # Exceptions from setup_region_config @@ -876,6 +881,8 @@ class M365Provider(Provider): def setup_identity( sp_env_auth, env_auth, + browser_auth, + az_cli_auth, session, ): """ @@ -891,7 +898,7 @@ class M365Provider(Provider): Returns: M365IdentityInfo: An instance of M365IdentityInfo containing the identity information. """ - logger.info("M365 provider: Setting up identity ...") + logger.info("M365 provider: Setting up identity...") # TODO: fill this object with real values not default and set to none identity = M365IdentityInfo() @@ -943,7 +950,7 @@ class M365Provider(Provider): identity.identity_type = "Service Principal" elif env_auth: identity.identity_type = "Service Principal and User Credentials" - else: + elif browser_auth or az_cli_auth: identity.identity_type = "User" try: logger.info( diff --git a/prowler/providers/m365/models.py b/prowler/providers/m365/models.py index f77bc8ee4c..6f9dc0e8c8 100644 --- a/prowler/providers/m365/models.py +++ b/prowler/providers/m365/models.py @@ -1,4 +1,6 @@ -from pydantic import BaseModel +from typing import Optional + +from pydantic.v1 import BaseModel from prowler.config.config import output_file_timestamp from prowler.providers.common.models import ProviderOutputOptions @@ -16,7 +18,7 @@ class M365IdentityInfo(BaseModel): class M365RegionConfig(BaseModel): name: str = "" - authority: str = None + authority: Optional[str] = None base_url: str = "" credential_scopes: list = [] diff --git a/prowler/providers/m365/services/admincenter/admincenter_service.py b/prowler/providers/m365/services/admincenter/admincenter_service.py index 32b2b12805..7c28ff15a2 100644 --- a/prowler/providers/m365/services/admincenter/admincenter_service.py +++ b/prowler/providers/m365/services/admincenter/admincenter_service.py @@ -1,7 +1,7 @@ from asyncio import gather, get_event_loop from typing import List, Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.m365.lib.service.service import M365Service diff --git a/prowler/providers/m365/services/defender/defender_service.py b/prowler/providers/m365/services/defender/defender_service.py index 9ea4a64afd..433d5b5422 100644 --- a/prowler/providers/m365/services/defender/defender_service.py +++ b/prowler/providers/m365/services/defender/defender_service.py @@ -1,6 +1,6 @@ from typing import List, Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.m365.lib.service.service import M365Service diff --git a/prowler/providers/m365/services/entra/entra_service.py b/prowler/providers/m365/services/entra/entra_service.py index 05e152bf2d..e7159e1787 100644 --- a/prowler/providers/m365/services/entra/entra_service.py +++ b/prowler/providers/m365/services/entra/entra_service.py @@ -4,7 +4,7 @@ from enum import Enum from typing import List, Optional from uuid import UUID -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.m365.lib.service.service import M365Service diff --git a/prowler/providers/m365/services/exchange/exchange_service.py b/prowler/providers/m365/services/exchange/exchange_service.py index 40dc8c3893..d8b0fbfa3a 100644 --- a/prowler/providers/m365/services/exchange/exchange_service.py +++ b/prowler/providers/m365/services/exchange/exchange_service.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Optional -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.m365.lib.service.service import M365Service diff --git a/prowler/providers/m365/services/purview/purview_service.py b/prowler/providers/m365/services/purview/purview_service.py index 138305c899..c950b9e5cc 100644 --- a/prowler/providers/m365/services/purview/purview_service.py +++ b/prowler/providers/m365/services/purview/purview_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.m365.lib.service.service import M365Service diff --git a/prowler/providers/m365/services/sharepoint/sharepoint_service.py b/prowler/providers/m365/services/sharepoint/sharepoint_service.py index 244f27521f..b47176d396 100644 --- a/prowler/providers/m365/services/sharepoint/sharepoint_service.py +++ b/prowler/providers/m365/services/sharepoint/sharepoint_service.py @@ -3,7 +3,7 @@ from asyncio import gather, get_event_loop from typing import List, Optional from msgraph.generated.models.o_data_errors.o_data_error import ODataError -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.m365.lib.service.service import M365Service diff --git a/prowler/providers/m365/services/teams/teams_service.py b/prowler/providers/m365/services/teams/teams_service.py index f17f22d327..66970b0ec7 100644 --- a/prowler/providers/m365/services/teams/teams_service.py +++ b/prowler/providers/m365/services/teams/teams_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.m365.lib.service.service import M365Service diff --git a/prowler/providers/nhn/models.py b/prowler/providers/nhn/models.py index 0288e1be5d..53176524ac 100644 --- a/prowler/providers/nhn/models.py +++ b/prowler/providers/nhn/models.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.config.config import output_file_timestamp from prowler.providers.common.models import ProviderOutputOptions diff --git a/prowler/providers/nhn/services/compute/compute_service.py b/prowler/providers/nhn/services/compute/compute_service.py index 082c04bc96..efc42f8fcb 100644 --- a/prowler/providers/nhn/services/compute/compute_service.py +++ b/prowler/providers/nhn/services/compute/compute_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.nhn.nhn_provider import NhnProvider diff --git a/prowler/providers/nhn/services/network/network_service.py b/prowler/providers/nhn/services/network/network_service.py index 140e1d2815..26a805e3df 100644 --- a/prowler/providers/nhn/services/network/network_service.py +++ b/prowler/providers/nhn/services/network/network_service.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic.v1 import BaseModel from prowler.lib.logger import logger from prowler.providers.nhn.nhn_provider import NhnProvider diff --git a/pyproject.toml b/pyproject.toml index 0c0cb57661..75e614a5b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "azure-mgmt-containerregistry==12.0.0", "azure-mgmt-containerservice==34.1.0", "azure-mgmt-cosmosdb==9.7.0", + "azure-mgmt-databricks==2.0.0", "azure-mgmt-keyvault==10.3.1", "azure-mgmt-monitor==6.0.2", "azure-mgmt-network==28.1.0", @@ -33,7 +34,7 @@ dependencies = [ "azure-mgmt-subscription==3.1.1", "azure-mgmt-web==8.0.0", "azure-storage-blob==12.24.1", - "boto3==1.35.99", + "boto3==1.35.49", "botocore==1.35.99", "colorama==0.4.6", "cryptography==44.0.1", @@ -48,17 +49,19 @@ dependencies = [ "msgraph-sdk==1.23.0", "numpy==2.0.2", "pandas==2.2.3", - "py-ocsf-models==0.3.1", - "pydantic==1.10.21", + "py-ocsf-models==0.5.0", + "pydantic (>=2.0,<3.0)", "pygithub==2.5.0", "python-dateutil (>=2.9.0.post0,<3.0.0)", "pytz==2025.1", - "schema==0.7.7", + "schema==0.7.5", "shodan==1.31.0", "slack-sdk==3.34.0", "tabulate==0.9.0", "tzlocal==5.3.1", "reportlab (>=4.4.1,<5.0.0)" + "checkov==3.2.445", + "py-iam-expand==0.1.0" ] description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks." license = "Apache-2.0" @@ -96,6 +99,7 @@ mock = "5.2.0" moto = {extras = ["all"], version = "5.0.28"} openapi-schema-validator = "0.6.3" openapi-spec-validator = "0.7.1" +pre-commit = "4.2.0" pylint = "3.3.4" pytest = "8.3.5" pytest-cov = "6.0.0" diff --git a/tests/lib/check/check_loader_test.py b/tests/lib/check/check_loader_test.py index 06c1aa1c16..69ea5715f3 100644 --- a/tests/lib/check/check_loader_test.py +++ b/tests/lib/check/check_loader_test.py @@ -180,6 +180,9 @@ class TestCheckLoader: def test_load_checks_to_execute_with_compliance_frameworks( self, ): + bulk_checks_metatada = { + S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME: self.get_custom_check_s3_metadata() + } bulk_compliance_frameworks = { "soc2_aws": Compliance( Framework="SOC2", @@ -199,6 +202,7 @@ class TestCheckLoader: compliance_frameworks = ["soc2_aws"] assert {S3_BUCKET_LEVEL_PUBLIC_ACCESS_BLOCK_NAME} == load_checks_to_execute( + bulk_checks_metadata=bulk_checks_metatada, bulk_compliance_frameworks=bulk_compliance_frameworks, compliance_frameworks=compliance_frameworks, provider=self.provider, diff --git a/tests/lib/check/compliance_check_test.py b/tests/lib/check/compliance_check_test.py index 6994d00c29..b6789b3832 100644 --- a/tests/lib/check/compliance_check_test.py +++ b/tests/lib/check/compliance_check_test.py @@ -94,6 +94,93 @@ custom_compliance_metadata = { ) ], ), + "framework1_gcp": Compliance( + Framework="Framework1", + Provider="gcp", + Version="1.0", + Description="Framework 2 Description", + Requirements=[ + Compliance_Requirement( + Id="1.1.1", + Description="description", + Attributes=[ + CIS_Requirement_Attribute( + Section="1. Identity", + Profile=CIS_Requirement_Attribute_Profile("Level 1"), + AssessmentStatus=CIS_Requirement_Attribute_AssessmentStatus( + "Manual" + ), + Description="Description", + RationaleStatement="Rationale", + ImpactStatement="Impact", + RemediationProcedure="Remediation", + AuditProcedure="Audit", + AdditionalInformation="Additional", + References="References", + ) + ], + Checks=[], + ) + ], + ), + "framework1_k8s": Compliance( + Framework="Framework1", + Provider="Kubernetes", + Version="1.0", + Description="Framework 2 Description", + Requirements=[ + Compliance_Requirement( + Id="1.1.1", + Description="description", + Attributes=[ + CIS_Requirement_Attribute( + Section="1. Identity", + Profile=CIS_Requirement_Attribute_Profile("Level 1"), + AssessmentStatus=CIS_Requirement_Attribute_AssessmentStatus( + "Manual" + ), + Description="Description", + RationaleStatement="Rationale", + ImpactStatement="Impact", + RemediationProcedure="Remediation", + AuditProcedure="Audit", + AdditionalInformation="Additional", + References="References", + ) + ], + Checks=[], + ) + ], + ), + "framework1_m365": Compliance( + Framework="Framework1", + Provider="m365", + Version="1.0", + Description="Framework 2 Description", + Requirements=[ + Compliance_Requirement( + Id="1.1.1", + Description="description", + Attributes=[ + CIS_Requirement_Attribute( + Section="1. Identity", + Profile=CIS_Requirement_Attribute_Profile("E3 Level 1"), + AssessmentStatus=CIS_Requirement_Attribute_AssessmentStatus( + "Manual" + ), + Description="Description", + RationaleStatement="Rationale", + ImpactStatement="Impact", + RemediationProcedure="Remediation", + AuditProcedure="Audit", + AdditionalInformation="Additional", + References="References", + ) + ], + Checks=[], + ) + ], + ), } @@ -209,9 +296,12 @@ class TestCompliance: list_compliance = Compliance.list(bulk_compliance_frameworks) - assert len(list_compliance) == 2 + assert len(list_compliance) == 5 assert list_compliance[0] == "framework1_aws" assert list_compliance[1] == "framework1_azure" + assert list_compliance[2] == "framework1_gcp" + assert list_compliance[3] == "framework1_k8s" + assert list_compliance[4] == "framework1_m365" def test_list_with_provider_aws(self): bulk_compliance_frameworks = custom_compliance_metadata @@ -229,6 +319,30 @@ class TestCompliance: assert len(list_compliance) == 1 assert list_compliance[0] == "framework1_azure" + def test_list_with_provider_gcp(self): + bulk_compliance_frameworks = custom_compliance_metadata + + list_compliance = Compliance.list(bulk_compliance_frameworks, provider="gcp") + + assert len(list_compliance) == 1 + assert list_compliance[0] == "framework1_gcp" + + def test_list_with_provider_k8s(self): + bulk_compliance_frameworks = custom_compliance_metadata + + list_compliance = Compliance.list(bulk_compliance_frameworks, provider="k8s") + + assert len(list_compliance) == 1 + assert list_compliance[0] == "framework1_k8s" + + def test_list_with_provider_m365(self): + bulk_compliance_frameworks = custom_compliance_metadata + + list_compliance = Compliance.list(bulk_compliance_frameworks, provider="m365") + + assert len(list_compliance) == 1 + assert list_compliance[0] == "framework1_m365" + def test_get_compliance_frameworks(self): bulk_compliance_frameworks = custom_compliance_metadata @@ -252,6 +366,76 @@ class TestCompliance: assert compliance_framework.Description == "Framework 2 Description" assert len(compliance_framework.Requirements) == 1 + compliance_framework = Compliance.get( + bulk_compliance_frameworks, compliance_framework_name="framework1_gcp" + ) + + assert compliance_framework.Framework == "Framework1" + assert compliance_framework.Provider == "gcp" + assert compliance_framework.Version == "1.0" + assert compliance_framework.Description == "Framework 2 Description" + assert len(compliance_framework.Requirements) == 1 + + compliance_framework = Compliance.get( + bulk_compliance_frameworks, compliance_framework_name="framework1_k8s" + ) + + assert compliance_framework.Framework == "Framework1" + assert compliance_framework.Provider == "Kubernetes" + assert compliance_framework.Version == "1.0" + assert compliance_framework.Description == "Framework 2 Description" + assert len(compliance_framework.Requirements) == 1 + + compliance_framework = Compliance.get( + bulk_compliance_frameworks, compliance_framework_name="framework1_m365" + ) + + assert compliance_framework.Framework == "Framework1" + assert compliance_framework.Provider == "m365" + assert compliance_framework.Version == "1.0" + assert compliance_framework.Description == "Framework 2 Description" + assert len(compliance_framework.Requirements) == 1 + assert compliance_framework.Requirements[0].Id == "1.1.1" + assert compliance_framework.Requirements[0].Description == "description" + assert len(compliance_framework.Requirements[0].Attributes) == 1 + assert ( + compliance_framework.Requirements[0].Attributes[0].Section == "1. Identity" + ) + assert ( + compliance_framework.Requirements[0].Attributes[0].Profile == "E3 Level 1" + ) + assert ( + compliance_framework.Requirements[0].Attributes[0].AssessmentStatus + == "Manual" + ) + assert ( + compliance_framework.Requirements[0].Attributes[0].Description + == "Description" + ) + assert ( + compliance_framework.Requirements[0].Attributes[0].RationaleStatement + == "Rationale" + ) + assert ( + compliance_framework.Requirements[0].Attributes[0].ImpactStatement + == "Impact" + ) + assert ( + compliance_framework.Requirements[0].Attributes[0].RemediationProcedure + == "Remediation" + ) + assert ( + compliance_framework.Requirements[0].Attributes[0].AuditProcedure == "Audit" + ) + assert ( + compliance_framework.Requirements[0].Attributes[0].AdditionalInformation + == "Additional" + ) + assert ( + compliance_framework.Requirements[0].Attributes[0].References + == "References" + ) + def test_get_non_existent_framework(self): bulk_compliance_frameworks = custom_compliance_metadata diff --git a/tests/lib/check/models_test.py b/tests/lib/check/models_test.py index fdbb8b6d70..70be6795ca 100644 --- a/tests/lib/check/models_test.py +++ b/tests/lib/check/models_test.py @@ -304,13 +304,14 @@ class TestCheckMetada: # Assertions assert result == {"accessanalyzer_enabled"} - def test_list_by_compliance_empty(self): + @mock.patch("prowler.lib.check.models.CheckMetadata.get_bulk") + def test_list_by_compliance_empty(self, mock_get_bulk): + mock_get_bulk.return_value = {} bulk_compliance_frameworks = custom_compliance_metadata result = CheckMetadata.list( bulk_compliance_frameworks=bulk_compliance_frameworks, compliance_framework="framework1_azure", ) - # Assertions assert result == set() diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py index 2707d180af..75ff688718 100644 --- a/tests/lib/cli/parser_test.py +++ b/tests/lib/cli/parser_test.py @@ -17,11 +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,github,nhn,dashboard} ..." +prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,dashboard,iac} ..." def mock_get_available_providers(): - return ["aws", "azure", "gcp", "kubernetes", "m365", "github", "nhn"] + return ["aws", "azure", "gcp", "kubernetes", "m365", "github", "iac", "nhn"] @pytest.mark.arg_parser diff --git a/tests/lib/outputs/compliance/aws_well_architected/aws_well_architected_test.py b/tests/lib/outputs/compliance/aws_well_architected/aws_well_architected_test.py index 104c127d6e..b4a1856ac6 100644 --- a/tests/lib/outputs/compliance/aws_well_architected/aws_well_architected_test.py +++ b/tests/lib/outputs/compliance/aws_well_architected/aws_well_architected_test.py @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -148,7 +149,11 @@ class TestAWSWellArchitected: assert output_data_manual.CheckId == "manual" assert output_data_manual.Muted is False - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.aws_well_architected.aws_well_architected.timestamp", + "2025-01-01 00:00:00", + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [ diff --git a/tests/lib/outputs/compliance/cis/cis_aws_test.py b/tests/lib/outputs/compliance/cis/cis_aws_test.py index dbc8d155ea..0951bd053d 100644 --- a/tests/lib/outputs/compliance/cis/cis_aws_test.py +++ b/tests/lib/outputs/compliance/cis/cis_aws_test.py @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -139,7 +140,10 @@ class TestAWSCIS: assert output_data_manual.CheckId == "manual" assert output_data_manual.Muted is False - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.cis.cis_aws.timestamp", "2025-01-01 00:00:00" + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [generate_finding_output(compliance={"CIS-1.4": "2.1.3"})] diff --git a/tests/lib/outputs/compliance/cis/cis_azure_test.py b/tests/lib/outputs/compliance/cis/cis_azure_test.py index f2b3719aa5..10489f1d29 100644 --- a/tests/lib/outputs/compliance/cis/cis_azure_test.py +++ b/tests/lib/outputs/compliance/cis/cis_azure_test.py @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -158,7 +159,10 @@ class TestAzureCIS: assert output_data_manual.CheckId == "manual" assert output_data_manual.Muted is False - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.cis.cis_azure.timestamp", "2025-01-01 00:00:00" + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [ diff --git a/tests/lib/outputs/compliance/cis/cis_gcp_test.py b/tests/lib/outputs/compliance/cis/cis_gcp_test.py index e6473d9495..1cb07ee684 100644 --- a/tests/lib/outputs/compliance/cis/cis_gcp_test.py +++ b/tests/lib/outputs/compliance/cis/cis_gcp_test.py @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -150,7 +151,10 @@ class TestGCPCIS: assert output_data_manual.CheckId == "manual" assert output_data_manual.Muted is False - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.cis.cis_gcp.timestamp", "2025-01-01 00:00:00" + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [ diff --git a/tests/lib/outputs/compliance/cis/cis_kubernetes_test.py b/tests/lib/outputs/compliance/cis/cis_kubernetes_test.py index b6e4a4d4f9..0873f2beeb 100644 --- a/tests/lib/outputs/compliance/cis/cis_kubernetes_test.py +++ b/tests/lib/outputs/compliance/cis/cis_kubernetes_test.py @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -160,7 +161,11 @@ class TestKubernetesCIS: assert output_data_manual.CheckId == "manual" assert output_data_manual.Muted is False - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.cis.cis_kubernetes.timestamp", + "2025-01-01 00:00:00", + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [ @@ -181,5 +186,5 @@ class TestKubernetesCIS: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;CONTEXT;NAMESPACE;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_REFERENCES;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\nkubernetes;This CIS Kubernetes Benchmark provides prescriptive guidance for establishing a secure configuration posture for Kubernetes v1.27.;test-cluster;test-namespace;{datetime.now()};1.1.3;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1. Control Plane;1.1 Control Plane Node Configuration Files;Level 1 - Master Node;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;https://kubernetes.io/docs/admin/kube-apiserver/;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;PASS;;;;test-check-id;False\r\nkubernetes;This CIS Kubernetes Benchmark provides prescriptive guidance for establishing a secure configuration posture for Kubernetes v1.27.;;;{datetime.now()};1.1.4;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1.1 Control Plane Node Configuration Files;;Level 1 - Master Node;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;https://kubernetes.io/docs/admin/kube-apiserver/;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;CONTEXT;NAMESPACE;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_REFERENCES;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\nkubernetes;This CIS Kubernetes Benchmark provides prescriptive guidance for establishing a secure configuration posture for Kubernetes v1.27.;test-cluster;test-namespace;{datetime.now()};1.1.3;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1. Control Plane;1.1 Control Plane Node Configuration Files;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;https://kubernetes.io/docs/admin/kube-apiserver/;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;PASS;;;;test-check-id;False\r\nkubernetes;This CIS Kubernetes Benchmark provides prescriptive guidance for establishing a secure configuration posture for Kubernetes v1.27.;;;{datetime.now()};1.1.4;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1.1 Control Plane Node Configuration Files;;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;https://kubernetes.io/docs/admin/kube-apiserver/;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/cis/cis_m365_test.py b/tests/lib/outputs/compliance/cis/cis_m365_test.py index 1c8232d73a..31b6fb0a1c 100644 --- a/tests/lib/outputs/compliance/cis/cis_m365_test.py +++ b/tests/lib/outputs/compliance/cis/cis_m365_test.py @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -155,7 +156,10 @@ class TestM365CIS: assert output_data_manual.CheckId == "manual" assert output_data_manual.Muted is False - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.cis.cis_m365.timestamp", "2025-01-01 00:00:00" + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [ @@ -177,6 +181,6 @@ class TestM365CIS: mock_file.seek(0) content = mock_file.read() - expected_csv = f"PROVIDER;DESCRIPTION;TENANTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;REQUIREMENTS_ATTRIBUTES_REFERENCES;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\nm365;The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for configuring security options for Microsoft 365 with an emphasis on foundational, testable, and architecture agnostic settings.;00000000-0000-0000-0000-000000000000;global;{datetime.now()};2.1.3;Ensure MFA Delete is enabled on S3 buckets;2.1. Simple Storage Service (S3);;Level 1;Automated;Once MFA Delete is enabled on your sensitive and classified S3 bucket it requires the user to have two forms of authentication.;Adding MFA delete to an S3 bucket, requires additional authentication when you change the version state of your bucket or you delete and object version adding another layer of security in the event your security credentials are compromised or unauthorized access is granted.;;Perform the steps below to enable MFA delete on an S3 bucket.Note:-You cannot enable MFA Delete using the AWS Management Console. You must use the AWS CLI or API.-You must use your 'root' account to enable MFA Delete on S3 buckets.**From Command line:**1. Run the s3api put-bucket-versioning command aws s3api put-bucket-versioning --profile my-root-profile --bucket Bucket_Name --versioning-configuration Status=Enabled,MFADelete=Enabled --mfa arn:aws:iam::aws_account_id:mfa/root-account-mfa-device passcode;Perform the steps below to confirm MFA delete is configured on an S3 Bucket**From Console:**1. Login to the S3 console at `https://console.aws.amazon.com/s3/`2. Click the `Check` box next to the Bucket name you want to confirm3. In the window under `Properties`4. Confirm that Versioning is `Enabled`5. Confirm that MFA Delete is `Enabled`**From Command Line:**1. Run the `get-bucket-versioning aws s3api get-bucket-versioning --bucket my-bucket Output example: Enabled Enabled\ If the Console or the CLI output does not show Versioning and MFA Delete `enabled` refer to the remediation below.;;By default, MFA Delete is not enabled on S3 buckets.;https://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html#MultiFactorAuthenticationDelete:https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMFADelete.html:https://aws.amazon.com/blogs/security/securing-access-to-aws-using-mfa-part-3/:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_lost-or-broken.html;PASS;;;;test-check-id;False\r\nm365;The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for configuring security options for Microsoft 365 with an emphasis on foundational, testable, and architecture agnostic settings.;00000000-0000-0000-0000-000000000000;global;{datetime.now()};2.1.4;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1.1 Control Plane Node Configuration Files;;Level 1 - Master Node;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;https://kubernetes.io/docs/admin/kube-apiserver/;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" + expected_csv = f"PROVIDER;DESCRIPTION;TENANTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;REQUIREMENTS_ATTRIBUTES_REFERENCES;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED\r\nm365;The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for configuring security options for Microsoft 365 with an emphasis on foundational, testable, and architecture agnostic settings.;00000000-0000-0000-0000-000000000000;global;{datetime.now()};2.1.3;Ensure MFA Delete is enabled on S3 buckets;2.1. Simple Storage Service (S3);;Level 1;Automated;Once MFA Delete is enabled on your sensitive and classified S3 bucket it requires the user to have two forms of authentication.;Adding MFA delete to an S3 bucket, requires additional authentication when you change the version state of your bucket or you delete and object version adding another layer of security in the event your security credentials are compromised or unauthorized access is granted.;;Perform the steps below to enable MFA delete on an S3 bucket.Note:-You cannot enable MFA Delete using the AWS Management Console. You must use the AWS CLI or API.-You must use your 'root' account to enable MFA Delete on S3 buckets.**From Command line:**1. Run the s3api put-bucket-versioning command aws s3api put-bucket-versioning --profile my-root-profile --bucket Bucket_Name --versioning-configuration Status=Enabled,MFADelete=Enabled --mfa arn:aws:iam::aws_account_id:mfa/root-account-mfa-device passcode;Perform the steps below to confirm MFA delete is configured on an S3 Bucket**From Console:**1. Login to the S3 console at `https://console.aws.amazon.com/s3/`2. Click the `Check` box next to the Bucket name you want to confirm3. In the window under `Properties`4. Confirm that Versioning is `Enabled`5. Confirm that MFA Delete is `Enabled`**From Command Line:**1. Run the `get-bucket-versioning aws s3api get-bucket-versioning --bucket my-bucket Output example: Enabled Enabled\ If the Console or the CLI output does not show Versioning and MFA Delete `enabled` refer to the remediation below.;;By default, MFA Delete is not enabled on S3 buckets.;https://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html#MultiFactorAuthenticationDelete:https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMFADelete.html:https://aws.amazon.com/blogs/security/securing-access-to-aws-using-mfa-part-3/:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_lost-or-broken.html;PASS;;;;test-check-id;False\r\nm365;The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for configuring security options for Microsoft 365 with an emphasis on foundational, testable, and architecture agnostic settings.;00000000-0000-0000-0000-000000000000;global;{datetime.now()};2.1.4;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1.1 Control Plane Node Configuration Files;;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;https://kubernetes.io/docs/admin/kube-apiserver/;MANUAL;Manual check;manual_check;Manual check;manual;False\r\n" assert content == expected_csv diff --git a/tests/lib/outputs/compliance/ens/ens_aws_test.py b/tests/lib/outputs/compliance/ens/ens_aws_test.py index dcdf0fe34e..0e7ac4e88e 100644 --- a/tests/lib/outputs/compliance/ens/ens_aws_test.py +++ b/tests/lib/outputs/compliance/ens/ens_aws_test.py @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -115,7 +116,10 @@ class TestAWSENS: assert output_data_manual.CheckId == "manual" assert output_data_manual.Muted is False - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.ens.ens_aws.timestamp", "2025-01-01 00:00:00" + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [ diff --git a/tests/lib/outputs/compliance/ens/ens_azure_test.py b/tests/lib/outputs/compliance/ens/ens_azure_test.py index 1ea4e00e60..7b138758cd 100644 --- a/tests/lib/outputs/compliance/ens/ens_azure_test.py +++ b/tests/lib/outputs/compliance/ens/ens_azure_test.py @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -118,7 +119,10 @@ class TestAzureENS: assert output_data_manual.CheckId == "manual" assert output_data_manual.Muted is False - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.ens.ens_azure.timestamp", "2025-01-01 00:00:00" + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [ diff --git a/tests/lib/outputs/compliance/ens/ens_gcp_test.py b/tests/lib/outputs/compliance/ens/ens_gcp_test.py index a2b92efb2c..8082553738 100644 --- a/tests/lib/outputs/compliance/ens/ens_gcp_test.py +++ b/tests/lib/outputs/compliance/ens/ens_gcp_test.py @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -118,7 +119,10 @@ class TestGCPENS: assert output_data_manual.CheckId == "manual" assert output_data_manual.Muted is False - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.ens.ens_gcp.timestamp", "2025-01-01 00:00:00" + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [ diff --git a/tests/lib/outputs/compliance/fixtures.py b/tests/lib/outputs/compliance/fixtures.py index c656165195..3dcf26f2c4 100644 --- a/tests/lib/outputs/compliance/fixtures.py +++ b/tests/lib/outputs/compliance/fixtures.py @@ -179,7 +179,7 @@ CIS_1_8_KUBERNETES = Compliance( CIS_Requirement_Attribute( Section="1. Control Plane", SubSection="1.1 Control Plane Node Configuration Files", - Profile="Level 1 - Master Node", + Profile="Level 1", AssessmentStatus="Automated", Description="Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.", RationaleStatement="The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -199,7 +199,7 @@ CIS_1_8_KUBERNETES = Compliance( Attributes=[ CIS_Requirement_Attribute( Section="1.1 Control Plane Node Configuration Files", - Profile="Level 1 - Master Node", + Profile="Level 1", AssessmentStatus="Automated", Description="Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.", RationaleStatement="The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", @@ -279,7 +279,7 @@ CIS_4_0_M365 = Compliance( Attributes=[ CIS_Requirement_Attribute( Section="1.1 Control Plane Node Configuration Files", - Profile="Level 1 - Master Node", + Profile="Level 1", AssessmentStatus="Automated", Description="Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.", RationaleStatement="The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.", diff --git a/tests/lib/outputs/compliance/generic/generic_aws_test.py b/tests/lib/outputs/compliance/generic/generic_aws_test.py index b231dc72e1..c9c037e5ca 100644 --- a/tests/lib/outputs/compliance/generic/generic_aws_test.py +++ b/tests/lib/outputs/compliance/generic/generic_aws_test.py @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -98,7 +99,11 @@ class TestAWSGenericCompliance: assert output_data_manual.CheckId == "manual" assert output_data_manual.Muted is False - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.generic.generic.timestamp", + "2025-01-01 00:00:00", + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [ diff --git a/tests/lib/outputs/compliance/iso27001/iso27001_aws_test.py b/tests/lib/outputs/compliance/iso27001/iso27001_aws_test.py index b1a75d03e0..068ea69120 100644 --- a/tests/lib/outputs/compliance/iso27001/iso27001_aws_test.py +++ b/tests/lib/outputs/compliance/iso27001/iso27001_aws_test.py @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -73,7 +74,11 @@ class TestAWSISO27001: assert output_data_manual.CheckId == "manual" assert output_data_manual.Muted is False - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.iso27001.iso27001_aws.timestamp", + "2025-01-01 00:00:00", + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [generate_finding_output(compliance={"ISO27001-2013": "A.10.1"})] diff --git a/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws_test.py b/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws_test.py index 8e64325f2e..95fcb63998 100644 --- a/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws_test.py +++ b/tests/lib/outputs/compliance/kisa_ismsp/kisa_ismsp_aws_test.py @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -106,7 +107,11 @@ class TestAWSKISAISMSP: assert output_data_manual.CheckId == "manual" assert output_data_manual.Muted is False - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.kisa_ismsp.kisa_ismsp_aws.timestamp", + "2025-01-01 00:00:00", + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [generate_finding_output(compliance={"KISA-ISMS-P-2023": ["2.5.3"]})] diff --git a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_aws_test.py b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_aws_test.py index fbc4105201..327643490c 100644 --- a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_aws_test.py +++ b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_aws_test.py @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -112,7 +113,11 @@ class TestAWSMITREAttack: assert output_data_manual.CheckId == "manual" assert output_data_manual.Muted is False - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.mitre_attack.mitre_attack_aws.timestamp", + "2025-01-01 00:00:00", + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [generate_finding_output(compliance={"MITRE-ATTACK": "T1190"})] diff --git a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_azure_test.py b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_azure_test.py index fd93f57aa8..fb9992382e 100644 --- a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_azure_test.py +++ b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_azure_test.py @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -129,7 +130,11 @@ class TestAzureMITREAttack: assert output_data_manual.CheckId == "manual" assert output_data_manual.Muted is False - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure.timestamp", + "2025-01-01 00:00:00", + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [ diff --git a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_gcp_test.py b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_gcp_test.py index ca9a314ba9..86b167262b 100644 --- a/tests/lib/outputs/compliance/mitre_attack/mitre_attack_gcp_test.py +++ b/tests/lib/outputs/compliance/mitre_attack/mitre_attack_gcp_test.py @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -120,7 +121,11 @@ class TestGCPMITREAttack: assert output_data_manual.CheckId == "manual" assert output_data_manual.Muted is False - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp.timestamp", + "2025-01-01 00:00:00", + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [ 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 bae12554ee..b256d349b0 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 @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -123,7 +124,11 @@ class TestProwlerThreatScoreAWS: assert output_data_manual.CheckId == "manual" assert not output_data_manual.Muted - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_aws.timestamp", + "2025-01-01 00:00:00", + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [ 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 d259641967..6583b66007 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 @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -133,7 +134,11 @@ class TestProwlerThreatScoreAzure: assert output_data_manual.ResourceName == "Manual check" assert output_data_manual.CheckId == "manual" - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_azure.timestamp", + "2025-01-01 00:00:00", + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [ 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 5318392ea3..878c8f8bf6 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 @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -129,7 +130,11 @@ class TestProwlerThreatScoreGCP: assert output_data_manual.CheckId == "manual" assert not output_data_manual.Muted - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_gcp.timestamp", + "2025-01-01 00:00:00", + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [ diff --git a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365_test.py b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365_test.py index fa43a336ec..b8ade916d0 100644 --- a/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365_test.py +++ b/tests/lib/outputs/compliance/prowler_threatscore/prowler_threatscore_m365_test.py @@ -1,5 +1,6 @@ from datetime import datetime from io import StringIO +from unittest import mock from freezegun import freeze_time from mock import patch @@ -131,7 +132,11 @@ class TestProwlerThreatScoreM365: assert output_data_manual.CheckId == "manual" assert not output_data_manual.Muted - @freeze_time(datetime.now()) + @freeze_time("2025-01-01 00:00:00") + @mock.patch( + "prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_m365.timestamp", + "2025-01-01 00:00:00", + ) def test_batch_write_data_to_file(self): mock_file = StringIO() findings = [ diff --git a/tests/lib/outputs/finding_test.py b/tests/lib/outputs/finding_test.py index 3dea2f614b..cdd0be701a 100644 --- a/tests/lib/outputs/finding_test.py +++ b/tests/lib/outputs/finding_test.py @@ -3,7 +3,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest -from pydantic import ValidationError +from pydantic.v1 import ValidationError from prowler.lib.check.models import ( CheckMetadata, @@ -28,7 +28,7 @@ def mock_check_metadata(provider): SubServiceName="", ResourceIdTemplate="", Severity="high", - ResourceType="", + ResourceType="mock_resource_type", Description="", Risk="", RelatedUrl="", @@ -209,7 +209,7 @@ class TestFinding: assert finding_output.metadata.SubServiceName == "" assert finding_output.metadata.ResourceIdTemplate == "" assert finding_output.metadata.Severity == Severity.high - assert finding_output.metadata.ResourceType == "" + assert finding_output.metadata.ResourceType == "mock_resource_type" assert finding_output.metadata.Description == "" assert finding_output.metadata.Risk == "" assert finding_output.metadata.RelatedUrl == "" @@ -230,7 +230,7 @@ class TestFinding: assert finding_output.check_id == "mock_check_id" assert finding_output.severity == Severity.high.value assert finding_output.status == Status.PASS.value - assert finding_output.resource_type == "" + assert finding_output.resource_type == "mock_resource_type" assert finding_output.service_name == "mock_service_name" assert finding_output.raw == {} @@ -310,7 +310,7 @@ class TestFinding: assert finding_output.metadata.SubServiceName == "" assert finding_output.metadata.ResourceIdTemplate == "" assert finding_output.metadata.Severity == Severity.high - assert finding_output.metadata.ResourceType == "" + assert finding_output.metadata.ResourceType == "mock_resource_type" assert finding_output.metadata.Description == "" assert finding_output.metadata.Risk == "" assert finding_output.metadata.RelatedUrl == "" @@ -405,7 +405,7 @@ class TestFinding: assert finding_output.metadata.SubServiceName == "" assert finding_output.metadata.ResourceIdTemplate == "" assert finding_output.metadata.Severity == Severity.high - assert finding_output.metadata.ResourceType == "" + assert finding_output.metadata.ResourceType == "mock_resource_type" assert finding_output.metadata.Description == "" assert finding_output.metadata.Risk == "" assert finding_output.metadata.RelatedUrl == "" @@ -490,7 +490,7 @@ class TestFinding: assert finding_output.metadata.SubServiceName == "" assert finding_output.metadata.ResourceIdTemplate == "" assert finding_output.metadata.Severity == Severity.high - assert finding_output.metadata.ResourceType == "" + assert finding_output.metadata.ResourceType == "mock_resource_type" assert finding_output.metadata.Description == "" assert finding_output.metadata.Risk == "" assert finding_output.metadata.RelatedUrl == "" @@ -573,6 +573,16 @@ class TestFinding: inserted_at = 1234567890 provider = DummyProvider(uid="account123") provider.type = "aws" + provider.organizations_metadata = SimpleNamespace( + account_name="test-account", + account_email="test@example.com", + organization_arn="arn:aws:organizations::123456789012:organization/o-abcdef123456", + organization_id="o-abcdef123456", + account_tags={"Environment": "prod", "Project": "test"}, + ) + provider.identity = SimpleNamespace( + account="123456789012", partition="aws", profile="default" + ) scan = DummyScan(provider=provider) # Create a dummy resource with one tag @@ -655,7 +665,10 @@ class TestFinding: assert meta.Notes == "Some notes" # Check other Finding fields - assert finding_obj.uid == "prowler-aws-check-001--us-east-1-ResourceName1" + assert ( + finding_obj.uid + == "prowler-aws-check-001-123456789012-us-east-1-ResourceName1" + ) assert finding_obj.status == Status("FAIL") assert finding_obj.status_extended == "extended" # From the dummy resource diff --git a/tests/lib/outputs/ocsf/ocsf_test.py b/tests/lib/outputs/ocsf/ocsf_test.py index 9f8d493069..bf48938471 100644 --- a/tests/lib/outputs/ocsf/ocsf_test.py +++ b/tests/lib/outputs/ocsf/ocsf_test.py @@ -6,9 +6,9 @@ import requests from freezegun import freeze_time from mock import patch from py_ocsf_models.events.base_event import SeverityID, StatusID -from py_ocsf_models.events.findings.detection_finding import DetectionFinding from py_ocsf_models.events.findings.detection_finding import ( - TypeID as DetectionFindingTypeID, + DetectionFinding, + DetectionFindingTypeID, ) from py_ocsf_models.events.findings.finding import ActivityID, FindingInformation from py_ocsf_models.objects.account import Account, TypeID @@ -174,7 +174,7 @@ class TestOCSF: "vendor_name": "Prowler", "version": prowler_version, }, - "version": "1.4.0", + "version": "1.5.0", "profiles": ["cloud", "datetime"], "tenant_uid": "test-organization-id", }, diff --git a/tests/lib/scan/scan_test.py b/tests/lib/scan/scan_test.py index d51d26c3a3..1699902e12 100644 --- a/tests/lib/scan/scan_test.py +++ b/tests/lib/scan/scan_test.py @@ -117,6 +117,7 @@ def mock_load_check_metadata(): ) as mock_load: mock_metadata = MagicMock() mock_metadata.CheckID = "accessanalyzer_enabled" + mock_metadata.ResourceType = "AWS::IAM::AccessAnalyzer" mock_load.return_value = mock_metadata yield mock_load @@ -130,8 +131,22 @@ def mock_load_checks_to_execute(): yield mock_load +@pytest.fixture +def mock_check_metadata_get_bulk(): + with mock.patch( + "prowler.lib.check.models.CheckMetadata.get_bulk", autospec=True + ) as mock_get_bulk: + mock_metadata = MagicMock() + mock_metadata.CheckID = "accessanalyzer_enabled" + mock_metadata.ResourceType = "AWS::IAM::AccessAnalyzer" + mock_get_bulk.return_value = {"accessanalyzer_enabled": mock_metadata} + yield mock_get_bulk + + class TestScan: - def test_init(mock_provider): + def test_init( + mock_provider, + ): checks_to_execute = { "workspaces_vpc_2private_1public_subnets_nat", "workspaces_vpc_2private_1public_subnets_nat", @@ -194,102 +209,56 @@ class TestScan: "config_recorder_all_regions_enabled", } mock_provider.type = "aws" - scan = Scan(mock_provider, checks=checks_to_execute) + # Patch get_bulk to return all these checks + with mock.patch( + "prowler.lib.check.models.CheckMetadata.get_bulk" + ) as mock_get_bulk: + mock_metadata = MagicMock() + mock_metadata.ResourceType = "AWS::IAM::AccessAnalyzer" + mock_metadata.Categories = [] + mock_get_bulk.return_value = { + check: mock_metadata for check in checks_to_execute + } + scan = Scan(mock_provider, checks=checks_to_execute) - assert scan.provider == mock_provider - # Check that the checks to execute are sorted and without duplicates - assert scan.checks_to_execute == [ - "accessanalyzer_enabled", - "accessanalyzer_enabled_without_findings", - "account_maintain_current_contact_details", - "account_maintain_different_contact_details_to_security_billing_and_operations", - "account_security_contact_information_is_registered", - "account_security_questions_are_registered_in_the_aws_account", - "acm_certificates_expiration_check", - "acm_certificates_transparency_logs_enabled", - "apigateway_restapi_authorizers_enabled", - "apigateway_restapi_client_certificate_enabled", - "apigateway_restapi_logging_enabled", - "apigateway_restapi_public", - "awslambda_function_not_publicly_accessible", - "awslambda_function_url_cors_policy", - "awslambda_function_url_public", - "awslambda_function_using_supported_runtimes", - "backup_plans_exist", - "backup_reportplans_exist", - "backup_vaults_encrypted", - "backup_vaults_exist", - "cloudformation_stack_outputs_find_secrets", - "cloudformation_stacks_termination_protection_enabled", - "cloudwatch_cross_account_sharing_disabled", - "cloudwatch_log_group_kms_encryption_enabled", - "cloudwatch_log_group_no_secrets_in_logs", - "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", - "codeartifact_packages_external_public_publishing_disabled", - "codebuild_project_older_90_days", - "codebuild_project_user_controlled_buildspec", - "cognito_identity_pool_guest_access_disabled", - "cognito_user_pool_advanced_security_enabled", - "cognito_user_pool_blocks_compromised_credentials_sign_in_attempts", - "cognito_user_pool_blocks_potential_malicious_sign_in_attempts", - "cognito_user_pool_client_prevent_user_existence_errors", - "cognito_user_pool_client_token_revocation_enabled", - "cognito_user_pool_deletion_protection_enabled", - "cognito_user_pool_mfa_enabled", - "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", - "cognito_user_pool_self_registration_disabled", - "cognito_user_pool_temporary_password_expiration", - "cognito_user_pool_waf_acl_attached", - "config_recorder_all_regions_enabled", - "workspaces_vpc_2private_1public_subnets_nat", - ] - assert scan.service_checks_to_execute == get_service_checks_to_execute( - checks_to_execute - ) - assert scan.service_checks_completed == {} - assert scan.progress == 0 - assert scan.duration == 0 - assert scan.get_completed_services() == set() - assert scan.get_completed_checks() == set() + assert scan.provider == mock_provider + # Check that the checks to execute are sorted and without duplicates + assert scan.checks_to_execute == sorted(list(checks_to_execute)) + assert scan.service_checks_to_execute == get_service_checks_to_execute( + checks_to_execute + ) + assert scan.service_checks_completed == {} + assert scan.progress == 0 + assert scan.duration == 0 + assert scan.get_completed_services() == set() + assert scan.get_completed_checks() == set() def test_init_with_no_checks( mock_provider, mock_recover_checks_from_provider, mock_load_check_metadata, - mock_load_checks_to_execute, ): checks_to_execute = set() mock_provider.type = "aws" - - scan = Scan(mock_provider, checks=checks_to_execute) - mock_load_check_metadata.assert_called_once() - mock_load_checks_to_execute.assert_called_once() - mock_recover_checks_from_provider.assert_called_once_with("aws") - - assert scan.provider == mock_provider - assert scan.checks_to_execute == ["accessanalyzer_enabled"] - assert scan.service_checks_to_execute == get_service_checks_to_execute( - ["accessanalyzer_enabled"] - ) - assert scan.service_checks_completed == {} - assert scan.progress == 0 - assert scan.get_completed_services() == set() - assert scan.get_completed_checks() == set() + # Patch get_bulk to return only accessanalyzer_enabled + with mock.patch( + "prowler.lib.check.models.CheckMetadata.get_bulk" + ) as mock_get_bulk: + mock_metadata = MagicMock() + mock_metadata.ResourceType = "AWS::IAM::AccessAnalyzer" + mock_metadata.Categories = [] + mock_get_bulk.return_value = {"accessanalyzer_enabled": mock_metadata} + scan = Scan(mock_provider, checks=checks_to_execute) + # Remove assertion for mock_load_check_metadata + assert scan.provider == mock_provider + assert scan.checks_to_execute == ["accessanalyzer_enabled"] + assert scan.service_checks_to_execute == get_service_checks_to_execute( + ["accessanalyzer_enabled"] + ) + assert scan.service_checks_completed == {} + assert scan.progress == 0 + assert scan.get_completed_services() == set() + assert scan.get_completed_checks() == set() @patch("importlib.import_module") def test_scan( diff --git a/tests/providers/aws/aws_provider_test.py b/tests/providers/aws/aws_provider_test.py index 56804de4bd..ad528ba0c6 100644 --- a/tests/providers/aws/aws_provider_test.py +++ b/tests/providers/aws/aws_provider_test.py @@ -243,6 +243,27 @@ def mock_recover_checks_from_aws_provider_cognito_service(*_): return [] +def mock_recover_checks_from_aws_provider_eks_service(*_): + return [ + ( + "eks_cluster_not_publicly_accessible", + "/root_dir/fake_path/eks/eks_cluster_not_publicly_accessible", + ), + ( + "eks_cluster_uses_a_supported_version", + "/root_dir/fake_path/eks/eks_cluster_uses_a_supported_version", + ), + ( + "eks_cluster_network_policy_enabled", + "/root_dir/fake_path/eks/eks_cluster_network_policy_enabled", + ), + ( + "eks_control_plane_logging_all_types_enabled", + "/root_dir/fake_path/eks/eks_control_plane_logging_all_types_enabled", + ), + ] + + class TestAWSProvider: @mock_aws def test_aws_provider_default(self): @@ -1604,6 +1625,27 @@ aws: assert recovered_checks == expected_checks + @mock_aws + @patch( + "prowler.lib.check.utils.recover_checks_from_provider", + new=mock_recover_checks_from_aws_provider_eks_service, + ) + def test_get_checks_from_input_arn_eks(self): + expected_checks = [ + "eks_cluster_not_publicly_accessible", + "eks_cluster_uses_a_supported_version", + "eks_cluster_network_policy_enabled", + "eks_control_plane_logging_all_types_enabled", + ] + + aws_provider = AwsProvider() + aws_provider._audit_resources = [ + f"arn:aws:eks:us-east-1:{AWS_ACCOUNT_NUMBER}:cluster/test-eks" + ] + recovered_checks = aws_provider.get_checks_from_input_arn() + + assert set(recovered_checks) == set(expected_checks) + @mock_aws @patch( "prowler.lib.check.utils.recover_checks_from_provider", diff --git a/tests/providers/aws/lib/mutelist/aws_mutelist_test.py b/tests/providers/aws/lib/mutelist/aws_mutelist_test.py index 29a987e752..3e3afd98e9 100644 --- a/tests/providers/aws/lib/mutelist/aws_mutelist_test.py +++ b/tests/providers/aws/lib/mutelist/aws_mutelist_test.py @@ -7,6 +7,7 @@ import yaml from boto3 import client, resource from mock import MagicMock, patch from moto import mock_aws +import pytest from prowler.config.config import encoding_format_utf_8 from prowler.providers.aws.lib.mutelist.mutelist import AWSMutelist @@ -321,6 +322,30 @@ class TestAWSMutelist: assert mutelist.mutelist == {} assert mutelist.mutelist_file_path is None + def test_validate_mutelist_raise_on_exception(self): + mutelist_path = MUTELIST_FIXTURE_PATH + with open(mutelist_path) as f: + mutelist_fixture = yaml.safe_load(f)["Mutelist"] + + # Create an invalid mutelist by adding an invalid key + invalid_mutelist = mutelist_fixture.copy() + invalid_mutelist["Accounts1"] = invalid_mutelist["Accounts"] + del invalid_mutelist["Accounts"] + + mutelist = AWSMutelist(mutelist_content=mutelist_fixture) + + # Test that it raises an exception when raise_on_exception=True + with pytest.raises(Exception): + mutelist.validate_mutelist(invalid_mutelist, raise_on_exception=True) + + # Test that it doesn't raise an exception when raise_on_exception=False (default) + result = mutelist.validate_mutelist(invalid_mutelist, raise_on_exception=False) + assert result == {} + + # Test that it doesn't raise an exception when raise_on_exception is not specified + result = mutelist.validate_mutelist(invalid_mutelist) + assert result == {} + def test_mutelist_findings_only_wildcard(self): # Mutelist mutelist_content = { diff --git a/tests/providers/aws/lib/s3/s3_test.py b/tests/providers/aws/lib/s3/s3_test.py index e32b7ad1ec..d6797bb1b9 100644 --- a/tests/providers/aws/lib/s3/s3_test.py +++ b/tests/providers/aws/lib/s3/s3_test.py @@ -11,6 +11,7 @@ from prowler.lib.outputs.html.html import HTML from prowler.lib.outputs.ocsf.ocsf import OCSF from prowler.providers.aws.lib.s3.exceptions.exceptions import S3InvalidBucketNameError from prowler.providers.aws.lib.s3.s3 import S3 +from prowler.providers.common.models import Connection from tests.lib.outputs.compliance.fixtures import ISO27001_2013_AWS from tests.lib.outputs.fixtures.fixtures import generate_finding_output from tests.providers.aws.utils import AWS_REGION_US_EAST_1 @@ -318,32 +319,59 @@ class TestS3: @mock_aws def test_test_connection_S3(self): - current_session = boto3.session.Session(region_name=AWS_REGION_US_EAST_1) + # Create a mock IAM user + iam_client = boto3.client("iam", region_name=AWS_REGION_US_EAST_1) + iam_user = iam_client.create_user(UserName="test-user")["User"] + # Create a mock IAM access keys + access_key = iam_client.create_access_key(UserName=iam_user["UserName"])[ + "AccessKey" + ] + + # Create bucket + current_session = boto3.session.Session( + aws_access_key_id=access_key["AccessKeyId"], + aws_secret_access_key=access_key["SecretAccessKey"], + region_name=AWS_REGION_US_EAST_1, + ) s3_client = current_session.client("s3") s3_client.create_bucket(Bucket=S3_BUCKET_NAME) - s3 = S3.test_connection( - session=current_session, + + connection = S3.test_connection( + aws_region=AWS_REGION_US_EAST_1, bucket_name=S3_BUCKET_NAME, + aws_access_key_id=access_key["AccessKeyId"], + aws_secret_access_key=access_key["SecretAccessKey"], ) - assert s3 is not None - assert s3.is_connected is True - assert s3.error is None + assert isinstance(connection, Connection) + assert connection.is_connected is True + assert connection.error is None @mock_aws def test_test_connection_S3_bucket_invalid_name(self): - current_session = boto3.session.Session(region_name=AWS_REGION_US_EAST_1) + # Create a mock IAM user + iam_client = boto3.client("iam", region_name=AWS_REGION_US_EAST_1) + iam_user = iam_client.create_user(UserName="test-user")["User"] + # Create a mock IAM access keys + access_key = iam_client.create_access_key(UserName=iam_user["UserName"])[ + "AccessKey" + ] + + # Create bucket (with valid name) + current_session = boto3.session.Session( + aws_access_key_id=access_key["AccessKeyId"], + aws_secret_access_key=access_key["SecretAccessKey"], + region_name=AWS_REGION_US_EAST_1, + ) s3_client = current_session.client("s3") - s3_client.create_bucket(Bucket=S3_BUCKET_NAME) - with pytest.raises(S3InvalidBucketNameError): - s3 = S3.test_connection( - session=current_session, - bucket_name="invalid_bucket", - ) - assert s3 is not None - assert s3.is_connected is False - assert s3.error is not None + with pytest.raises(S3InvalidBucketNameError): + S3.test_connection( + aws_region=AWS_REGION_US_EAST_1, + bucket_name="invalid_bucket", + aws_access_key_id=access_key["AccessKeyId"], + aws_secret_access_key=access_key["SecretAccessKey"], + ) @mock_aws def test_init_without_session(self): diff --git a/tests/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations_test.py b/tests/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations_test.py new file mode 100644 index 0000000000..bdabea03fa --- /dev/null +++ b/tests/providers/aws/services/codebuild/codebuild_project_uses_allowed_github_organizations/codebuild_project_uses_allowed_github_organizations_test.py @@ -0,0 +1,393 @@ +from unittest.mock import patch + +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import AWS_REGION_EU_WEST_1, set_mocked_aws_provider + +AWS_ACCOUNT_NUMBER = "123456789012" + + +class Test_codebuild_project_uses_allowed_github_organizations: + @mock_aws + def test_no_projects(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.codebuild.codebuild_service import Codebuild + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.codebuild_client", + new=Codebuild(aws_provider), + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.codebuild_client.audit_config", + {"codebuild_github_allowed_organizations": ["allowed-org"]}, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations import ( + codebuild_project_uses_allowed_github_organizations, + ) + + check = codebuild_project_uses_allowed_github_organizations() + result = check.execute() + + assert len(result) == 0 + + @mock_aws + def test_project_github_allowed_organization(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + codebuild_client = client("codebuild", region_name=AWS_REGION_EU_WEST_1) + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + project_name = "test-project-github-allowed" + role_name = "codebuild-test-role" + role_arn = iam_client.create_role( + RoleName=role_name, + AssumeRolePolicyDocument="""{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "codebuild.amazonaws.com"}, + "Action": "sts:AssumeRole" + } + ] + }""", + )["Role"]["Arn"] + project_arn = codebuild_client.create_project( + name=project_name, + source={ + "type": "GITHUB", + "location": "https://github.com/allowed-org/repo", + }, + artifacts={"type": "NO_ARTIFACTS"}, + environment={ + "type": "LINUX_CONTAINER", + "image": "aws/codebuild/standard:4.0", + "computeType": "BUILD_GENERAL1_SMALL", + "environmentVariables": [], + }, + serviceRole=role_arn, + tags=[{"key": "Name", "value": "test"}], + )["project"]["arn"] + + from prowler.providers.aws.services.codebuild.codebuild_service import Codebuild + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.codebuild_client", + new=Codebuild(aws_provider), + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.iam_client", + new=IAM(aws_provider), + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.codebuild_client.audit_config", + {"codebuild_github_allowed_organizations": ["allowed-org"]}, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations import ( + codebuild_project_uses_allowed_github_organizations, + ) + + check = codebuild_project_uses_allowed_github_organizations() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == project_name + assert result[0].resource_arn == project_arn + assert "which is in the allowed organizations" in result[0].status_extended + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + def test_project_github_not_allowed_organization(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + codebuild_client = client("codebuild", region_name=AWS_REGION_EU_WEST_1) + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + project_name = "test-project-github-not-allowed" + role_name = "codebuild-test-role" + role_arn = iam_client.create_role( + RoleName=role_name, + AssumeRolePolicyDocument="""{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "codebuild.amazonaws.com"}, + "Action": "sts:AssumeRole" + } + ] + }""", + )["Role"]["Arn"] + project_arn = codebuild_client.create_project( + name=project_name, + source={ + "type": "GITHUB", + "location": "https://github.com/not-allowed-org/repo", + }, + artifacts={"type": "NO_ARTIFACTS"}, + environment={ + "type": "LINUX_CONTAINER", + "image": "aws/codebuild/standard:4.0", + "computeType": "BUILD_GENERAL1_SMALL", + "environmentVariables": [], + }, + serviceRole=role_arn, + tags=[{"key": "Name", "value": "test"}], + )["project"]["arn"] + + from prowler.providers.aws.services.codebuild.codebuild_service import Codebuild + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.codebuild_client", + new=Codebuild(aws_provider), + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.iam_client", + new=IAM(aws_provider), + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.codebuild_client.audit_config", + {"codebuild_github_allowed_organizations": ["allowed-org"]}, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations import ( + codebuild_project_uses_allowed_github_organizations, + ) + + check = codebuild_project_uses_allowed_github_organizations() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == project_name + assert result[0].resource_arn == project_arn + assert ( + "which is not in the allowed organizations" in result[0].status_extended + ) + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + def test_project_github_no_codebuild_trusted_principal(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.codebuild.codebuild_service import Codebuild + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.codebuild_client", + new=Codebuild(aws_provider), + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.iam_client", + new=IAM(aws_provider), + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.codebuild_client.audit_config", + {"codebuild_github_allowed_organizations": ["allowed-org"]}, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations import ( + codebuild_project_uses_allowed_github_organizations, + ) + + check = codebuild_project_uses_allowed_github_organizations() + result = check.execute() + assert len(result) == 0 + + @mock_aws + def test_project_github_enterprise_allowed_organization(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + codebuild_client = client("codebuild", region_name=AWS_REGION_EU_WEST_1) + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + project_name = "test-project-github-enterprise-allowed" + role_name = "codebuild-test-role" + role_arn = iam_client.create_role( + RoleName=role_name, + AssumeRolePolicyDocument="""{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "codebuild.amazonaws.com"}, + "Action": "sts:AssumeRole" + } + ] + }""", + )["Role"]["Arn"] + project_arn = codebuild_client.create_project( + name=project_name, + source={ + "type": "GITHUB_ENTERPRISE", + "location": "https://github.enterprise.com/allowed-org/repo", + }, + artifacts={"type": "NO_ARTIFACTS"}, + environment={ + "type": "LINUX_CONTAINER", + "image": "aws/codebuild/standard:4.0", + "computeType": "BUILD_GENERAL1_SMALL", + "environmentVariables": [], + }, + serviceRole=role_arn, + tags=[{"key": "Name", "value": "test"}], + )["project"]["arn"] + + from prowler.providers.aws.services.codebuild.codebuild_service import Codebuild + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.codebuild_client", + new=Codebuild(aws_provider), + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.iam_client", + new=IAM(aws_provider), + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.codebuild_client.audit_config", + {"codebuild_github_allowed_organizations": ["allowed-org"]}, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations import ( + codebuild_project_uses_allowed_github_organizations, + ) + + check = codebuild_project_uses_allowed_github_organizations() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == project_name + assert result[0].resource_arn == project_arn + assert "which is in the allowed organizations" in result[0].status_extended + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + def test_project_github_enterprise_not_allowed_organization(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + codebuild_client = client("codebuild", region_name=AWS_REGION_EU_WEST_1) + iam_client = client("iam", region_name=AWS_REGION_EU_WEST_1) + project_name = "test-project-github-enterprise-not-allowed" + role_name = "codebuild-test-role" + role_arn = iam_client.create_role( + RoleName=role_name, + AssumeRolePolicyDocument="""{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "codebuild.amazonaws.com"}, + "Action": "sts:AssumeRole" + } + ] + }""", + )["Role"]["Arn"] + project_arn = codebuild_client.create_project( + name=project_name, + source={ + "type": "GITHUB_ENTERPRISE", + "location": "https://github.enterprise.com/not-allowed-org/repo", + }, + artifacts={"type": "NO_ARTIFACTS"}, + environment={ + "type": "LINUX_CONTAINER", + "image": "aws/codebuild/standard:4.0", + "computeType": "BUILD_GENERAL1_SMALL", + "environmentVariables": [], + }, + serviceRole=role_arn, + tags=[{"key": "Name", "value": "test"}], + )["project"]["arn"] + + from prowler.providers.aws.services.codebuild.codebuild_service import Codebuild + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.codebuild_client", + new=Codebuild(aws_provider), + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.iam_client", + new=IAM(aws_provider), + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.codebuild_client.audit_config", + {"codebuild_github_allowed_organizations": ["allowed-org"]}, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations import ( + codebuild_project_uses_allowed_github_organizations, + ) + + check = codebuild_project_uses_allowed_github_organizations() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == project_name + assert result[0].resource_arn == project_arn + assert ( + "which is not in the allowed organizations" in result[0].status_extended + ) + assert result[0].region == AWS_REGION_EU_WEST_1 + + @mock_aws + def test_project_github_enterprise_no_codebuild_trusted_principal(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.codebuild.codebuild_service import Codebuild + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.codebuild_client", + new=Codebuild(aws_provider), + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.iam_client", + new=IAM(aws_provider), + ), + patch( + "prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations.codebuild_client.audit_config", + {"codebuild_github_allowed_organizations": ["allowed-org"]}, + ), + ): + from prowler.providers.aws.services.codebuild.codebuild_project_uses_allowed_github_organizations.codebuild_project_uses_allowed_github_organizations import ( + codebuild_project_uses_allowed_github_organizations, + ) + + check = codebuild_project_uses_allowed_github_organizations() + result = check.execute() + assert len(result) == 0 diff --git a/tests/providers/aws/services/ecs/ecs_cluster_container_insights_enabled/ecs_cluster_container_insights_enabled_test.py b/tests/providers/aws/services/ecs/ecs_cluster_container_insights_enabled/ecs_cluster_container_insights_enabled_test.py index 45483453c7..7f3fc68add 100644 --- a/tests/providers/aws/services/ecs/ecs_cluster_container_insights_enabled/ecs_cluster_container_insights_enabled_test.py +++ b/tests/providers/aws/services/ecs/ecs_cluster_container_insights_enabled/ecs_cluster_container_insights_enabled_test.py @@ -109,6 +109,45 @@ class Test_ecs_clusters_container_insights_enabled: == f"ECS cluster {CLUSTER_NAME} has container insights enabled." ) + @mock_aws + def test_cluster_enhanced_container_insights(self): + ecs_client = client("ecs", region_name=AWS_REGION_US_EAST_1) + cluster_settings = [ + {"name": "containerInsights", "value": "enhanced"}, + ] + cluster_arn = ecs_client.create_cluster( + clusterName=CLUSTER_NAME, + settings=cluster_settings, + )["cluster"]["clusterArn"] + + from prowler.providers.aws.services.ecs.ecs_service import ECS + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.ecs.ecs_cluster_container_insights_enabled.ecs_cluster_container_insights_enabled.ecs_client", + new=ECS(aws_provider), + ), + ): + from prowler.providers.aws.services.ecs.ecs_cluster_container_insights_enabled.ecs_cluster_container_insights_enabled import ( + ecs_cluster_container_insights_enabled, + ) + + check = ecs_cluster_container_insights_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_arn == cluster_arn + assert ( + result[0].status_extended + == f"ECS cluster {CLUSTER_NAME} has container insights enhanced." + ) + @mock_aws def test_cluster_disabled_container_insights(self): ecs_client = client("ecs", region_name=AWS_REGION_US_EAST_1) diff --git a/tests/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation_test.py b/tests/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation_test.py index 6dbe167e47..d1a636a009 100644 --- a/tests/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation_test.py +++ b/tests/providers/aws/services/iam/iam_policy_allows_privilege_escalation/iam_policy_allows_privilege_escalation_test.py @@ -334,6 +334,59 @@ class Test_iam_policy_allows_privilege_escalation: ) assert search("iam:PassRole", result[0].status_extended) + @mock_aws + def test_iam_policy_allows_privilege_escalation_iam_PassRole_using_wildcard( + self, + ): + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + policy_name = "policy1" + policy_document = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "iam:Pass*", + "Resource": f"arn:aws:iam::{AWS_ACCOUNT_NUMBER}:role/ecs", + } + ], + } + policy_arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document) + )["Policy"]["Arn"] + + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client", + new=IAM(aws_provider), + ), + ): + # Test Check + from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import ( + iam_policy_allows_privilege_escalation, + ) + + check = iam_policy_allows_privilege_escalation() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == policy_name + assert result[0].resource_arn == policy_arn + assert result[0].region == AWS_REGION_US_EAST_1 + assert result[0].resource_tags == [] + + assert search( + f"Custom Policy {policy_arn} allows privilege escalation using the following actions: ", + result[0].status_extended, + ) + assert search("iam:PassRole", result[0].status_extended) + @mock_aws def test_iam_policy_allows_privilege_escalation_two_combinations( self, @@ -918,6 +971,62 @@ class Test_iam_policy_allows_privilege_escalation: ) assert search("iam:Put*", finding.status_extended) + @mock_aws + def test_iam_policy_allows_privilege_escalation_iam_put_using_wildcard( + self, + ): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam", region_name=AWS_REGION_US_EAST_1) + policy_name_1 = "privileged_policy_1" + policy_document_1 = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "Statement01", + "Effect": "Allow", + "Action": ["iam:Put*y"], + "Resource": "*", + } + ], + } + + policy_arn_1 = iam_client.create_policy( + PolicyName=policy_name_1, PolicyDocument=dumps(policy_document_1) + )["Policy"]["Arn"] + + from prowler.providers.aws.services.iam.iam_service import IAM + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), + mock.patch( + "prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client", + new=IAM(aws_provider), + ), + ): + # Test Check + from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import ( + iam_policy_allows_privilege_escalation, + ) + + check = iam_policy_allows_privilege_escalation() + result = check.execute() + assert len(result) == 1 + for finding in result: + if finding.resource_id == policy_name_1: + assert finding.status == "FAIL" + assert finding.resource_id == policy_name_1 + assert finding.resource_arn == policy_arn_1 + assert finding.region == AWS_REGION_US_EAST_1 + assert finding.resource_tags == [] + assert search( + f"Custom Policy {policy_arn_1} allows privilege escalation using the following actions:", + finding.status_extended, + ) + assert search("iam:Put*", finding.status_extended) + @mock_aws def test_iam_policy_allows_privilege_escalation_iam_wildcard( self, diff --git a/tests/providers/aws/services/iam/iam_policy_no_full_access_to_kms/iam_policy_no_full_access_to_kms_test.py b/tests/providers/aws/services/iam/iam_policy_no_full_access_to_kms/iam_policy_no_full_access_to_kms_test.py index ed9991acaf..514a83b935 100644 --- a/tests/providers/aws/services/iam/iam_policy_no_full_access_to_kms/iam_policy_no_full_access_to_kms_test.py +++ b/tests/providers/aws/services/iam/iam_policy_no_full_access_to_kms/iam_policy_no_full_access_to_kms_test.py @@ -48,6 +48,88 @@ class Test_iam_policy_no_full_access_to_kms: assert result[0].resource_arn == arn assert result[0].region == "us-east-1" + +class Test_iam_policy_no_full_access_to_kms_with_double_start: + @mock_aws + def test_policy_full_access_to_kms(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam") + policy_name = "policy_kms_full" + policy_document_full_access = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "kms:**", "Resource": "*"}, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document_full_access) + )["Policy"]["Arn"] + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.iam.iam_policy_no_full_access_to_kms.iam_policy_no_full_access_to_kms.iam_client", + new=IAM(aws_provider), + ): + # Test Check + from prowler.providers.aws.services.iam.iam_policy_no_full_access_to_kms.iam_policy_no_full_access_to_kms import ( + iam_policy_no_full_access_to_kms, + ) + + check = iam_policy_no_full_access_to_kms() + result = check.execute() + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Custom Policy {policy_name} allows 'kms:*' privileges." + ) + assert result[0].resource_id == "policy_kms_full" + assert result[0].resource_arn == arn + assert result[0].region == "us-east-1" + + +class Test_iam_policy_no_full_access_to_kms_with_unicode: + @mock_aws + def test_policy_full_access_to_kms(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam") + policy_name = "policy_kms_full" + policy_document_full_access = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "\u0041llow", "Action": "km\u0073:*", "Resource": "*"}, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document_full_access) + )["Policy"]["Arn"] + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.iam.iam_policy_no_full_access_to_kms.iam_policy_no_full_access_to_kms.iam_client", + new=IAM(aws_provider), + ): + # Test Check + from prowler.providers.aws.services.iam.iam_policy_no_full_access_to_kms.iam_policy_no_full_access_to_kms import ( + iam_policy_no_full_access_to_kms, + ) + + check = iam_policy_no_full_access_to_kms() + result = check.execute() + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Custom Policy {policy_name} allows 'kms:*' privileges." + ) + assert result[0].resource_id == "policy_kms_full" + assert result[0].resource_arn == arn + assert result[0].region == "us-east-1" + @mock_aws def test_policy_no_full_access_to_kms(self): aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) @@ -165,6 +247,49 @@ class Test_iam_policy_no_full_access_to_kms: assert result[0].resource_arn == arn assert result[0].region == "us-east-1" + @mock_aws + def test_policy_full_access_to_limited_kms_key(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam") + policy_name = "policy_dev_kms" + policy_document_no_full_access = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["kms:*"], + "Resource": f"arn:aws:kms:{AWS_REGION_US_EAST_1}:123456789012:alias/dev-key", + }, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document_no_full_access) + )["Policy"]["Arn"] + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.iam.iam_policy_no_full_access_to_kms.iam_policy_no_full_access_to_kms.iam_client", + new=IAM(aws_provider), + ): + # Test Check + from prowler.providers.aws.services.iam.iam_policy_no_full_access_to_kms.iam_policy_no_full_access_to_kms import ( + iam_policy_no_full_access_to_kms, + ) + + check = iam_policy_no_full_access_to_kms() + result = check.execute() + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Custom Policy {policy_name} does not allow 'kms:*' privileges." + ) + assert result[0].resource_id == "policy_dev_kms" + assert result[0].resource_arn == arn + assert result[0].region == "us-east-1" + @mock_aws def test_policy_no_full_access_to_kms_through_no_actions(self): aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) @@ -203,3 +328,43 @@ class Test_iam_policy_no_full_access_to_kms: assert result[0].resource_id == "policy_no_kms_full" assert result[0].resource_arn == arn assert result[0].region == "us-east-1" + + @mock_aws + def test_policy_full_access_and_full_deny_to_kms(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1]) + iam_client = client("iam") + policy_name = "policy_no_kms_full" + policy_document_full_access = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": "kms:*", "Resource": "*"}, + {"Effect": "Deny", "Action": "kms:*", "Resource": "*"}, + ], + } + arn = iam_client.create_policy( + PolicyName=policy_name, PolicyDocument=dumps(policy_document_full_access) + )["Policy"]["Arn"] + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.iam.iam_policy_no_full_access_to_kms.iam_policy_no_full_access_to_kms.iam_client", + new=IAM(aws_provider), + ): + # Test Check + from prowler.providers.aws.services.iam.iam_policy_no_full_access_to_kms.iam_policy_no_full_access_to_kms import ( + iam_policy_no_full_access_to_kms, + ) + + check = iam_policy_no_full_access_to_kms() + result = check.execute() + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Custom Policy {policy_name} does not allow 'kms:*' privileges." + ) + assert result[0].resource_id == "policy_no_kms_full" + assert result[0].resource_arn == arn + assert result[0].region == "us-east-1" diff --git a/tests/providers/aws/services/iam/lib/policy_test.py b/tests/providers/aws/services/iam/lib/policy_test.py index 02e88739ec..f8022d6f77 100644 --- a/tests/providers/aws/services/iam/lib/policy_test.py +++ b/tests/providers/aws/services/iam/lib/policy_test.py @@ -1,13 +1,18 @@ +import pytest + from prowler.providers.aws.services.iam.lib.policy import ( + _get_patterns_from_standard_value, check_admin_access, check_full_service_access, + get_effective_actions, + has_codebuild_trusted_principal, + is_codebuild_using_allowed_github_org, is_condition_block_restrictive, is_condition_block_restrictive_organization, is_condition_block_restrictive_sns_endpoint, is_condition_restricting_from_private_ip, is_policy_public, ) -import pytest TRUSTED_AWS_ACCOUNT_NUMBER = "123456789012" NON_TRUSTED_AWS_ACCOUNT_NUMBER = "111222333444" @@ -19,6 +24,115 @@ ALL_ORGS = "*" class Test_Policy: + def test_get_patterns_from_standard_value_string(self): + """Test _get_patterns_from_standard_value with a string input""" + result = _get_patterns_from_standard_value("s3:GetObject") + assert result == {"s3:GetObject"} + + result = _get_patterns_from_standard_value("") + assert result == {""} + + def test_get_patterns_from_standard_value_list(self): + """Test _get_patterns_from_standard_value with a list input""" + result = _get_patterns_from_standard_value(["s3:GetObject", "s3:PutObject"]) + assert result == {"s3:GetObject", "s3:PutObject"} + + result = _get_patterns_from_standard_value([]) + assert result == set() + + result = _get_patterns_from_standard_value(["s3:GetObject", 123, None]) + assert result == {"s3:GetObject"} + + def test_get_patterns_from_standard_value_invalid_input(self): + """Test _get_patterns_from_standard_value with invalid inputs""" + result = _get_patterns_from_standard_value(None) + assert result == set() + + result = _get_patterns_from_standard_value(123) + assert result == set() + + def test_get_effective_actions_empty_policy(self): + """Test get_effective_actions with an empty policy""" + result = get_effective_actions({}) + assert result == set() + + result = get_effective_actions({"Version": "2012-10-17"}) + assert result == set() + + def test_get_effective_actions_simple_allow(self): + """Test get_effective_actions with a simple Allow statement""" + policy = { + "Version": "2012-10-17", + "Statement": {"Effect": "Allow", "Action": "s3:GetObject"}, + } + result = get_effective_actions(policy) + assert result == {"s3:GetObject"} + + def test_get_effective_actions_simple_deny(self): + """Test get_effective_actions with a simple Deny statement""" + policy = { + "Version": "2012-10-17", + "Statement": {"Effect": "Deny", "Action": "s3:GetObject"}, + } + result = get_effective_actions(policy) + assert result == set() + + def test_get_effective_actions_allow_and_deny(self): + """Test get_effective_actions with both Allow and Deny statements""" + policy = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"]}, + {"Effect": "Deny", "Action": "s3:GetObject"}, + ], + } + result = get_effective_actions(policy) + assert result == {"s3:PutObject"} + + def test_get_effective_actions_with_not_action(self): + """Test get_effective_actions with NotAction statements""" + policy = { + "Version": "2012-10-17", + "Statement": {"Effect": "Allow", "NotAction": "s3:GetObject"}, + } + result = get_effective_actions(policy) + assert "s3:GetObject" not in result + assert "s3:PutObject" in result + + def test_get_effective_actions_with_wildcards(self): + """Test get_effective_actions with wildcard actions""" + policy = { + "Version": "2012-10-17", + "Statement": {"Effect": "Allow", "Action": "s3:*"}, + } + result = get_effective_actions(policy) + assert "s3:GetObject" in result + assert "s3:PutObject" in result + assert "s3:ListBucket" in result + + def test_get_effective_actions_with_invalid_effect(self): + """Test get_effective_actions with invalid Effect value""" + policy = { + "Version": "2012-10-17", + "Statement": {"Effect": "Invalid", "Action": "s3:GetObject"}, + } + result = get_effective_actions(policy) + assert result == set() + + def test_get_effective_actions_with_multiple_statements(self): + """Test get_effective_actions with multiple statements""" + policy = { + "Version": "2012-10-17", + "Statement": [ + {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"]}, + {"Effect": "Allow", "Action": "s3:ListBucket"}, + {"Effect": "Deny", "Action": "s3:PutObject"}, + ], + } + result = get_effective_actions(policy) + assert result == {"s3:GetObject", "s3:ListBucket"} + assert "s3:PutObject" not in result + # Test lowercase context key name --> aws def test_condition_parser_string_equals_aws_SourceAccount_list(self): condition_statement = { @@ -1618,6 +1732,38 @@ class Test_Policy: "s3", policy_allow_wildcard_action_and_resource ) + def test_policy_allows_full_service_access_with_wildcard_action_and_resource_using_unicode( + self, + ): + policy_allow_wildcard_action_and_resource = { + "Statement": [ + { + "Effect": "\u0041llow", + "Action": "\u00733:*", + "Resource": "*", + } + ] + } + assert check_full_service_access( + "s3", policy_allow_wildcard_action_and_resource + ) + + def test_policy_allows_full_service_access_with_wildcard_action_and_resource_using_double_start( + self, + ): + policy_allow_wildcard_action_and_resource = { + "Statement": [ + { + "Effect": "Allow", + "Action": "s3:**", + "Resource": "*", + } + ] + } + assert check_full_service_access( + "s3", policy_allow_wildcard_action_and_resource + ) + def test_policy_does_not_allow_full_service_access_with_specific_get_action(self): policy_allow_specific_get_action = { "Statement": [ @@ -1672,6 +1818,22 @@ class Test_Policy: "s3", policy_allow_not_action_excluding_other_service ) + def test_policy_allows_full_service_access_with_invalid_service_as_not_action( + self, + ): + policy_allow_not_action_excluding_other_service = { + "Statement": [ + { + "Effect": "Allow", + "NotAction": "prowler:check", + "Resource": "*", + } + ] + } + assert check_full_service_access( + "s3", policy_allow_not_action_excluding_other_service + ) + def test_policy_does_not_allow_full_service_access_with_not_action_including_service( self, ): @@ -2108,3 +2270,184 @@ class Test_Policy: ], } assert check_admin_access(policy) + + +def test_is_codebuild_using_allowed_github_org_allows(): + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "codebuild.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + github_repo_url = "https://github.com/allowed-org/repo" + allowed_organizations = ["allowed-org"] + is_allowed, org_name = is_codebuild_using_allowed_github_org( + trust_policy, github_repo_url, allowed_organizations + ) + assert is_allowed is True + assert org_name == "allowed-org" + + +def test_is_codebuild_using_allowed_github_org_denies(): + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "codebuild.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + github_repo_url = "https://github.com/not-allowed-org/repo" + allowed_organizations = ["allowed-org"] + is_allowed, org_name = is_codebuild_using_allowed_github_org( + trust_policy, github_repo_url, allowed_organizations + ) + assert is_allowed is False + assert org_name == "not-allowed-org" + + +def test_is_codebuild_using_allowed_github_org_no_codebuild_principal(): + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + github_repo_url = "https://github.com/allowed-org/repo" + allowed_organizations = ["allowed-org"] + is_allowed, org_name = is_codebuild_using_allowed_github_org( + trust_policy, github_repo_url, allowed_organizations + ) + assert is_allowed is False + assert org_name is None + + +def test_is_codebuild_using_allowed_github_org_invalid_url(): + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "codebuild.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + github_repo_url = "https://github.com//test" # Malformed, no org + allowed_organizations = ["allowed-org"] + is_allowed, org_name = is_codebuild_using_allowed_github_org( + trust_policy, github_repo_url, allowed_organizations + ) + assert is_allowed is False + assert org_name is None + + +def test_has_codebuild_trusted_principal_true(): + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "codebuild.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + assert has_codebuild_trusted_principal(trust_policy) is True + + +def test_has_codebuild_trusted_principal_false(): + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"Service": "lambda.amazonaws.com"}, + "Action": "sts:AssumeRole", + } + ], + } + assert has_codebuild_trusted_principal(trust_policy) is False + + +def test_has_codebuild_trusted_principal_empty(): + trust_policy = {} + assert has_codebuild_trusted_principal(trust_policy) is False + + +def test_is_codebuild_using_allowed_github_org_principal_string(): + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "codebuild.amazonaws.com", + "Action": "sts:AssumeRole", + } + ], + } + github_repo_url = "https://github.com/allowed-org/repo" + allowed_organizations = ["allowed-org"] + is_allowed, org_name = is_codebuild_using_allowed_github_org( + trust_policy, github_repo_url, allowed_organizations + ) + assert is_allowed is True + assert org_name == "allowed-org" + + +def test_is_codebuild_using_allowed_github_org_principal_list(): + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": ["codebuild.amazonaws.com", "lambda.amazonaws.com"], + "Action": "sts:AssumeRole", + } + ], + } + github_repo_url = "https://github.com/allowed-org/repo" + allowed_organizations = ["allowed-org"] + is_allowed, org_name = is_codebuild_using_allowed_github_org( + trust_policy, github_repo_url, allowed_organizations + ) + assert is_allowed is True + assert org_name == "allowed-org" + + +def test_has_codebuild_trusted_principal_string(): + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "codebuild.amazonaws.com", + "Action": "sts:AssumeRole", + } + ], + } + assert has_codebuild_trusted_principal(trust_policy) is True + + +def test_has_codebuild_trusted_principal_list(): + trust_policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": ["codebuild.amazonaws.com", "lambda.amazonaws.com"], + "Action": "sts:AssumeRole", + } + ], + } + assert has_codebuild_trusted_principal(trust_policy) is True diff --git a/tests/providers/aws/services/iam/lib/privilege_escalation_test.py b/tests/providers/aws/services/iam/lib/privilege_escalation_test.py index fbd55742d5..3b3123709f 100644 --- a/tests/providers/aws/services/iam/lib/privilege_escalation_test.py +++ b/tests/providers/aws/services/iam/lib/privilege_escalation_test.py @@ -1,88 +1,38 @@ from prowler.providers.aws.services.iam.lib.privilege_escalation import ( check_privilege_escalation, - find_privilege_escalation_combinations, + privilege_escalation_policies_combination, ) +# Helper function to parse the output string into a set for easier comparison +def parse_result_string(result_str: str) -> set: + """Helper to parse the output string back into a set.""" + if not result_str: + return set() + # Removes the single quotes around each action and splits by comma+space + return set(part.strip("'") for part in result_str.split(", ")) + + class Test_PrivilegeEscalation: - def test_find_privilege_escalation_combinations_no_priv_escalation(self): - allowed_actions = set() - denied_actions = set() - allowed_not_actions = set() - denied_not_actions = set() - - allowed_actions.add("s3:GetObject") - denied_actions.add("s3:PutObject") - denied_not_actions.add("s3:DeleteObject") - - assert ( - find_privilege_escalation_combinations( - allowed_actions, denied_actions, allowed_not_actions, denied_not_actions - ) - == set() - ) - - def test_find_privilege_escalation_combinations_priv_escalation_iam_all_and_ec2_RunInstances( - self, - ): - allowed_actions = set() - denied_actions = set() - allowed_not_actions = set() - denied_not_actions = set() - - allowed_actions.add("iam:*") - denied_actions.add("ec2:RunInstances") - - assert find_privilege_escalation_combinations( - allowed_actions, denied_actions, allowed_not_actions, denied_not_actions - ) == { - "iam:Put*", - "iam:AddUserToGroup", - "iam:AttachRolePolicy", - "iam:PassRole", - "iam:CreateLoginProfile", - "iam:CreateAccessKey", - "iam:AttachGroupPolicy", - "iam:SetDefaultPolicyVersion", - "iam:PutRolePolicy", - "iam:UpdateAssumeRolePolicy", - "iam:*", - "iam:PutGroupPolicy", - "iam:PutUserPolicy", - "iam:CreatePolicyVersion", - "iam:AttachUserPolicy", - "iam:UpdateLoginProfile", - } - - def test_find_privilege_escalation_combinations_priv_escalation_iam_PassRole(self): - allowed_actions = set() - allowed_not_actions = set() - denied_actions = set() - denied_not_actions = set() - - allowed_actions.add("iam:PassRole") - - assert find_privilege_escalation_combinations( - allowed_actions, denied_actions, allowed_not_actions, denied_not_actions - ) == {"iam:PassRole"} - def test_check_privilege_escalation_no_priv_escalation(self): policy = { + "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::example_bucket/*"], } - ] + ], } - - assert check_privilege_escalation(policy) == "" + expected_result = "" + assert check_privilege_escalation(policy) == expected_result def test_check_privilege_escalation_priv_escalation_iam_all_and_ec2_RunInstances( self, ): policy = { + "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", @@ -91,14 +41,14 @@ class Test_PrivilegeEscalation: }, { "Effect": "Deny", - "Action": ["ec2:RunInstances"], + "Action": ["ec2:RunInstances"], # This denies one part of a combo "Resource": ["*"], }, - ] + ], } - + # Should match all IAM combos, but NOT PassRole+EC2 result = check_privilege_escalation(policy) - + assert "ec2:RunInstances" not in result assert "iam:Put*" in result assert "iam:AddUserToGroup" in result assert "iam:AttachRolePolicy" in result @@ -118,17 +68,32 @@ class Test_PrivilegeEscalation: def test_check_privilege_escalation_priv_escalation_iam_PassRole(self): policy = { + "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["iam:PassRole"], "Resource": ["*"], } - ] + ], } - result = check_privilege_escalation(policy) + assert "iam:PassRole" in result + def test_check_privilege_escalation_priv_escalation_iam_PassRole_using_wildcard( + self, + ): + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["iam:*Role"], # Should expand to include PassRole + "Resource": ["*"], + } + ], + } + result = check_privilege_escalation(policy) assert "iam:PassRole" in result def test_check_privilege_escalation_priv_escalation_not_action( @@ -140,14 +105,16 @@ class Test_PrivilegeEscalation: { "Sid": "Statement1", "Effect": "Allow", - "NotAction": "iam:Put*", + "NotAction": "iam:Put*", # Allows everything EXCEPT iam:Put* actions "Resource": "*", } ], } - + # Should match all combos EXCEPT those requiring iam:Put* result = check_privilege_escalation(policy) + assert "iam:*" not in result assert "iam:Put*" not in result + assert "'iam:PutGroupPolicy'" not in result assert "iam:AddUserToGroup" in result assert "iam:AttachRolePolicy" in result assert "iam:PassRole" in result @@ -155,11 +122,7 @@ class Test_PrivilegeEscalation: assert "iam:CreateAccessKey" in result assert "iam:AttachGroupPolicy" in result assert "iam:SetDefaultPolicyVersion" in result - assert "iam:PutRolePolicy" in result assert "iam:UpdateAssumeRolePolicy" in result - assert "iam:*" in result - assert "iam:PutGroupPolicy" in result - assert "iam:PutUserPolicy" in result assert "iam:CreatePolicyVersion" in result assert "iam:AttachUserPolicy" in result assert "iam:UpdateLoginProfile" in result @@ -173,27 +136,35 @@ class Test_PrivilegeEscalation: { "Sid": "Statement1", "Effect": "Allow", - "NotAction": "prowler:action", + "NotAction": "prowler:action", # Invalid action -> Allows ALL "Resource": "*", } ], } - + # Since it allows ALL, expect all original patterns from ALL combos result = check_privilege_escalation(policy) - assert "proler:action" not in result - assert "iam:Put*" in result - assert "iam:AddUserToGroup" in result - assert "iam:AttachRolePolicy" in result - assert "iam:PassRole" in result - assert "iam:CreateLoginProfile" in result - assert "iam:CreateAccessKey" in result - assert "iam:AttachGroupPolicy" in result - assert "iam:SetDefaultPolicyVersion" in result - assert "iam:PutRolePolicy" in result - assert "iam:UpdateAssumeRolePolicy" in result - assert "iam:*" in result - assert "iam:PutGroupPolicy" in result - assert "iam:PutUserPolicy" in result - assert "iam:CreatePolicyVersion" in result - assert "iam:AttachUserPolicy" in result - assert "iam:UpdateLoginProfile" in result + for combo_patterns in privilege_escalation_policies_combination.values(): + for pattern in combo_patterns: + assert ( + f"'{pattern}'" in result + ), f"Expected pattern '{pattern}' not found in result: {result}" + + def test_check_privilege_escalation_administrator_policy(self): + policy_document_admin = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "Statement01", + "Effect": "Allow", + "Action": ["*"], # Admin policy + "Resource": "*", + } + ], + } + # Admin policy should match ALL combos, so expect all original patterns + result = check_privilege_escalation(policy_document_admin) + for combo_patterns in privilege_escalation_policies_combination.values(): + for pattern in combo_patterns: + assert ( + f"'{pattern}'" in result + ), f"Expected pattern '{pattern}' not found in result: {result}" diff --git a/tests/providers/aws/services/organizations/organizations_scp_check_deny_regions/organizations_scp_check_deny_regions_test.py b/tests/providers/aws/services/organizations/organizations_scp_check_deny_regions/organizations_scp_check_deny_regions_test.py index c575e4abfd..d6d9941960 100644 --- a/tests/providers/aws/services/organizations/organizations_scp_check_deny_regions/organizations_scp_check_deny_regions_test.py +++ b/tests/providers/aws/services/organizations/organizations_scp_check_deny_regions/organizations_scp_check_deny_regions_test.py @@ -17,6 +17,10 @@ def scp_restrict_regions_with_deny(): return '{"Version":"2012-10-17","Statement":{"Effect":"Deny","NotAction":"s3:*","Resource":"*","Condition":{"StringNotEquals":{"aws:RequestedRegion":["eu-central-1","eu-west-1"]}}}}' +def scp_restrict_regions_without_statement(): + return '{"Version":"2012-10-17"}' + + class Test_organizations_scp_check_deny_regions: @mock_aws def test_no_organization(self): @@ -277,3 +281,74 @@ class Test_organizations_scp_check_deny_regions: result = check.execute() assert len(result) == 0 + + @mock_aws + def test_organizations_scp_check_deny_regions_without_statement(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + # Create Organization + conn = client("organizations", region_name=AWS_REGION_EU_WEST_1) + response = conn.describe_organization() + # Delete the default FullAWSAccess policy created by Moto + policies = conn.list_policies(Filter="SERVICE_CONTROL_POLICY")["Policies"] + for policy in policies: + if policy["Name"] == "FullAWSAccess": + policy_id = policy["Id"] + # Detach from all roots + roots = conn.list_roots()["Roots"] + for root in roots: + conn.detach_policy(PolicyId=policy_id, TargetId=root["Id"]) + # Detach from all OUs + ous = conn.list_organizational_units_for_parent( + ParentId=roots[0]["Id"] + )["OrganizationalUnits"] + for ou in ous: + conn.detach_policy(PolicyId=policy_id, TargetId=ou["Id"]) + # Detach from all accounts + accounts = conn.list_accounts()["Accounts"] + for account in accounts: + conn.detach_policy(PolicyId=policy_id, TargetId=account["Id"]) + # Now delete + conn.delete_policy(PolicyId=policy_id) + break + # Create Policy + response_policy = conn.create_policy( + Content=scp_restrict_regions_without_statement(), + Description="Test", + Name="Test", + Type="SERVICE_CONTROL_POLICY", + ) + org_id = response["Organization"]["Id"] + policy_id = response_policy["Policy"]["PolicySummary"]["Id"] + + # Set config variable + aws_provider._audit_config = {"organizations_enabled_regions": ["us-east-1"]} + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ): + with mock.patch( + "prowler.providers.aws.services.organizations.organizations_scp_check_deny_regions.organizations_scp_check_deny_regions.organizations_client", + new=Organizations(aws_provider), + ): + # Test Check + from prowler.providers.aws.services.organizations.organizations_scp_check_deny_regions.organizations_scp_check_deny_regions import ( + organizations_scp_check_deny_regions, + ) + + check = organizations_scp_check_deny_regions() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == response["Organization"]["Id"] + assert ( + "arn:aws:organizations::123456789012:organization/o-" + in result[0].resource_arn + ) + assert ( + result[0].status_extended + == f"AWS Organization {org_id} has SCP policies but don't restrict AWS Regions." + ) + assert result[0].region == AWS_REGION_EU_WEST_1 diff --git a/tests/providers/aws/services/vpc/vpc_endpoint_multi_az_enabled/vpc_endpoint_multi_az_enabled_test.py b/tests/providers/aws/services/vpc/vpc_endpoint_multi_az_enabled/vpc_endpoint_multi_az_enabled_test.py index 9a2e0f2b1b..61e40de6c0 100644 --- a/tests/providers/aws/services/vpc/vpc_endpoint_multi_az_enabled/vpc_endpoint_multi_az_enabled_test.py +++ b/tests/providers/aws/services/vpc/vpc_endpoint_multi_az_enabled/vpc_endpoint_multi_az_enabled_test.py @@ -87,7 +87,7 @@ class Test_vpc_endpoint_for_multi_az: assert result[0].status == "FAIL" assert ( result[0].status_extended - == f"VPC Endpoint {vpc_endpoint['VpcEndpointId']} in VPC {vpc['VpcId']} has subnets in different AZs." + == f"VPC Endpoint {vpc_endpoint['VpcEndpointId']} in VPC {vpc['VpcId']} does not have subnets in different AZs." ) assert ( result[0].resource_arn @@ -158,7 +158,7 @@ class Test_vpc_endpoint_for_multi_az: assert result[0].status == "PASS" assert ( result[0].status_extended - == f"VPC Endpoint {vpc_endpoint['VpcEndpointId']} in VPC {vpc['VpcId']} does not have subnets in different AZs." + == f"VPC Endpoint {vpc_endpoint['VpcEndpointId']} in VPC {vpc['VpcId']} has subnets in different AZs." ) assert ( result[0].resource_arn diff --git a/tests/providers/azure/lib/mutelist/azure_mutelist_test.py b/tests/providers/azure/lib/mutelist/azure_mutelist_test.py index 7852aa54d2..83a981cbd2 100644 --- a/tests/providers/azure/lib/mutelist/azure_mutelist_test.py +++ b/tests/providers/azure/lib/mutelist/azure_mutelist_test.py @@ -63,7 +63,7 @@ class TestAzureMutelist: finding.location = "West Europe" finding.status = "FAIL" finding.resource_name = "test_resource" - finding.resource_tags = [] + finding.resource_tags = {} finding.subscription = "subscription_1" assert mutelist.is_finding_muted(finding) @@ -91,7 +91,7 @@ class TestAzureMutelist: account_uid="subscription_1", region="subscription_1", resource_uid="test_resource", - resource_tags=[], + resource_tags={}, muted=False, ) diff --git a/tests/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled_test.py b/tests/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled_test.py index 4f934f1977..4ea444157c 100644 --- a/tests/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled_test.py +++ b/tests/providers/azure/services/app/app_function_application_insights_enabled/app_function_application_insights_enabled_test.py @@ -56,7 +56,6 @@ class Test_app_function_application_insights_enabled: def test_app_function_no_app_insights(self): app_client = mock.MagicMock - app_insights = mock.MagicMock with ( mock.patch( @@ -67,18 +66,11 @@ class Test_app_function_application_insights_enabled: "prowler.providers.azure.services.app.app_function_application_insights_enabled.app_function_application_insights_enabled.app_client", new=app_client, ), - mock.patch( - "prowler.providers.azure.services.app.app_function_application_insights_enabled.app_function_application_insights_enabled.appinsights_client", - new=app_insights, - ), ): from prowler.providers.azure.services.app.app_function_application_insights_enabled.app_function_application_insights_enabled import ( app_function_application_insights_enabled, ) from prowler.providers.azure.services.app.app_service import FunctionApp - from prowler.providers.azure.services.appinsights.appinsights_service import ( - Component, - ) function_id = str(uuid4()) @@ -99,17 +91,6 @@ class Test_app_function_application_insights_enabled: } } - app_insights.components = { - AZURE_SUBSCRIPTION_ID: { - "app_id-1": Component( - resource_id="component_id", - resource_name="component_name", - location="West Europe", - instrumentation_key="1234", - ) - } - } - check = app_function_application_insights_enabled() result = check.execute() assert len(result) == 1 @@ -125,7 +106,6 @@ class Test_app_function_application_insights_enabled: def test_app_function_using_app_insights(self): app_client = mock.MagicMock - app_insights = mock.MagicMock with ( mock.patch( @@ -136,18 +116,11 @@ class Test_app_function_application_insights_enabled: "prowler.providers.azure.services.app.app_function_application_insights_enabled.app_function_application_insights_enabled.app_client", new=app_client, ), - mock.patch( - "prowler.providers.azure.services.app.app_function_application_insights_enabled.app_function_application_insights_enabled.appinsights_client", - new=app_insights, - ), ): from prowler.providers.azure.services.app.app_function_application_insights_enabled.app_function_application_insights_enabled import ( app_function_application_insights_enabled, ) from prowler.providers.azure.services.app.app_service import FunctionApp - from prowler.providers.azure.services.appinsights.appinsights_service import ( - Component, - ) function_id = str(uuid4()) @@ -168,17 +141,6 @@ class Test_app_function_application_insights_enabled: } } - app_insights.components = { - AZURE_SUBSCRIPTION_ID: { - "app_id-1": Component( - resource_id="component_id", - resource_name="component_name", - location="West Europe", - instrumentation_key="1234", - ) - } - } - check = app_function_application_insights_enabled() result = check.execute() assert len(result) == 1 @@ -194,7 +156,6 @@ class Test_app_function_application_insights_enabled: def test_app_function_using_app_insights_different_key(self): app_client = mock.MagicMock - app_insights = mock.MagicMock with ( mock.patch( @@ -205,18 +166,11 @@ class Test_app_function_application_insights_enabled: "prowler.providers.azure.services.app.app_function_application_insights_enabled.app_function_application_insights_enabled.app_client", new=app_client, ), - mock.patch( - "prowler.providers.azure.services.app.app_function_application_insights_enabled.app_function_application_insights_enabled.appinsights_client", - new=app_insights, - ), ): from prowler.providers.azure.services.app.app_function_application_insights_enabled.app_function_application_insights_enabled import ( app_function_application_insights_enabled, ) from prowler.providers.azure.services.app.app_service import FunctionApp - from prowler.providers.azure.services.appinsights.appinsights_service import ( - Component, - ) function_id = str(uuid4()) @@ -237,24 +191,13 @@ class Test_app_function_application_insights_enabled: } } - app_insights.components = { - AZURE_SUBSCRIPTION_ID: { - "app_id-1": Component( - resource_id="component_id", - resource_name="component_name", - location="West Europe", - instrumentation_key="5678", - ) - } - } - check = app_function_application_insights_enabled() result = check.execute() assert len(result) == 1 - assert result[0].status == "FAIL" + assert result[0].status == "PASS" assert ( result[0].status_extended - == "Function function1 is not using Application Insights." + == "Function function1 is using Application Insights." ) assert result[0].resource_id == function_id assert result[0].resource_name == "function1" @@ -263,7 +206,6 @@ class Test_app_function_application_insights_enabled: def test_app_function_with_app_insights_no_key(self): app_client = mock.MagicMock - app_insights = mock.MagicMock with ( mock.patch( @@ -274,18 +216,11 @@ class Test_app_function_application_insights_enabled: "prowler.providers.azure.services.app.app_function_application_insights_enabled.app_function_application_insights_enabled.app_client", new=app_client, ), - mock.patch( - "prowler.providers.azure.services.app.app_function_application_insights_enabled.app_function_application_insights_enabled.appinsights_client", - new=app_insights, - ), ): from prowler.providers.azure.services.app.app_function_application_insights_enabled.app_function_application_insights_enabled import ( app_function_application_insights_enabled, ) from prowler.providers.azure.services.app.app_service import FunctionApp - from prowler.providers.azure.services.appinsights.appinsights_service import ( - Component, - ) function_id = str(uuid4()) @@ -306,17 +241,6 @@ class Test_app_function_application_insights_enabled: } } - app_insights.components = { - AZURE_SUBSCRIPTION_ID: { - "app_id-1": Component( - resource_id="component_id", - resource_name="component_name", - location="West Europe", - instrumentation_key="Not Found", - ) - } - } - check = app_function_application_insights_enabled() result = check.execute() assert len(result) == 1 diff --git a/tests/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges_test.py b/tests/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges_test.py index 68dc5293a7..c4761099b9 100644 --- a/tests/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges_test.py +++ b/tests/providers/azure/services/app/app_function_identity_without_admin_privileges/app_function_identity_without_admin_privileges_test.py @@ -144,24 +144,29 @@ class Test_app_function_identity_without_admin_privileges: iam_client.role_assignments = { AZURE_SUBSCRIPTION_ID: { - "1": RoleAssignment( - role_id="1", + "role-assignment-id-1": RoleAssignment( + id="role-assignment-id-1", + name="role-assignment-name-1", + scope="/subscriptions/{}/resourceGroups/rg/providers/Microsoft.Web/sites/function1".format( + AZURE_SUBSCRIPTION_ID + ), agent_id="123", agent_type="User", + role_id="role-id-1", ) } } iam_client.roles = { - AZURE_SUBSCRIPTION_ID: [ - Role( - id="1", + AZURE_SUBSCRIPTION_ID: { + "role-id-1": Role( + id="role-id-1", name="role1", - type="User", + type="BuiltInRole", assignable_scopes=[], permissions=[], ) - ] + } } check = app_function_identity_without_admin_privileges() @@ -205,9 +210,9 @@ class Test_app_function_identity_without_admin_privileges: ) function_id = str(uuid4()) - + function_scope = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg/providers/Microsoft.Web/sites/function1" app_client.functions = { - AZURE_SUBSCRIPTION_ID: { + "subscription-name-1": { function_id: FunctionApp( id=function_id, name="function1", @@ -223,26 +228,33 @@ class Test_app_function_identity_without_admin_privileges: } } + iam_client.subscriptions = { + "subscription-name-1": AZURE_SUBSCRIPTION_ID, + } + iam_client.role_assignments = { - AZURE_SUBSCRIPTION_ID: { - "1": RoleAssignment( - role_id=USER_ACCESS_ADMINISTRATOR_ROLE_ID, + "subscription-name-1": { + "role-assignment-id-2": RoleAssignment( + id="role-assignment-id-2", + name="role-assignment-name-2", + scope=function_scope, agent_id="123", agent_type="User", + role_id=USER_ACCESS_ADMINISTRATOR_ROLE_ID, ) } } iam_client.roles = { - AZURE_SUBSCRIPTION_ID: [ - Role( - id=USER_ACCESS_ADMINISTRATOR_ROLE_ID, + "subscription-name-1": { + f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Authorization/roleDefinitions/{USER_ACCESS_ADMINISTRATOR_ROLE_ID}": Role( + id=f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Authorization/roleDefinitions/{USER_ACCESS_ADMINISTRATOR_ROLE_ID}", name="User Access Administrator", - type="User", + type="BuiltInRole", assignable_scopes=[], permissions=[], - ) - ] + ), + } } check = app_function_identity_without_admin_privileges() @@ -255,5 +267,5 @@ class Test_app_function_identity_without_admin_privileges: ) assert result[0].resource_id == function_id assert result[0].resource_name == "function1" - assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].subscription == "subscription-name-1" assert result[0].location == "West Europe" diff --git a/tests/providers/azure/services/app/app_service_test.py b/tests/providers/azure/services/app/app_service_test.py index b5047618d6..cc33c662a1 100644 --- a/tests/providers/azure/services/app/app_service_test.py +++ b/tests/providers/azure/services/app/app_service_test.py @@ -200,3 +200,47 @@ class Test_App_Service: .name == "name_diagnostic_setting2" ) + + def test_app_service_get_functions(self): + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + patch( + "prowler.providers.azure.services.monitor.monitor_service.Monitor", + new=MagicMock(), + ), + ): + from prowler.providers.azure.services.app.app_service import FunctionApp + + mock_function = FunctionApp( + id="/subscriptions/resource_id", + name="functionapp-1", + location="West Europe", + kind="functionapp", + function_keys=None, + enviroment_variables=None, + identity=ManagedServiceIdentity(type="SystemAssigned"), + public_access=True, + vnet_subnet_id="", + ftps_state="FtpsOnly", + ) + + app_service = MagicMock() + app_service.functions = { + "mock-subscription": {"/subscriptions/resource_id": mock_function} + } + + assert ( + app_service.functions["mock-subscription"][ + "/subscriptions/resource_id" + ].ftps_state + == "FtpsOnly" + ) + assert ( + app_service.functions["mock-subscription"][ + "/subscriptions/resource_id" + ].name + == "functionapp-1" + ) diff --git a/tests/providers/azure/services/databricks/databricks_service_test.py b/tests/providers/azure/services/databricks/databricks_service_test.py new file mode 100644 index 0000000000..69651f2235 --- /dev/null +++ b/tests/providers/azure/services/databricks/databricks_service_test.py @@ -0,0 +1,92 @@ +from unittest.mock import patch + +from prowler.providers.azure.services.databricks.databricks_service import ( + Databricks, + DatabricksWorkspace, + ManagedDiskEncryption, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +def mock_databricks_get_workspaces(_): + return { + AZURE_SUBSCRIPTION_ID: { + "test-workspace-id": DatabricksWorkspace( + id="test-workspace-id", + name="test-workspace", + location="eastus", + custom_managed_vnet_id="test-vnet-id", + managed_disk_encryption=ManagedDiskEncryption( + key_name="test-key", + key_version="test-version", + key_vault_uri="test-vault-uri", + ), + ) + } + } + + +@patch( + "prowler.providers.azure.services.databricks.databricks_service.Databricks._get_workspaces", + new=mock_databricks_get_workspaces, +) +class Test_Databricks_Service: + def test_get_client(self): + databricks = Databricks(set_mocked_azure_provider()) + assert ( + databricks.clients[AZURE_SUBSCRIPTION_ID].__class__.__name__ + == "AzureDatabricksManagementClient" + ) + + def test_get_workspaces(self): + databricks = Databricks(set_mocked_azure_provider()) + assert ( + databricks.workspaces[AZURE_SUBSCRIPTION_ID][ + "test-workspace-id" + ].__class__.__name__ + == "DatabricksWorkspace" + ) + workspace = databricks.workspaces[AZURE_SUBSCRIPTION_ID]["test-workspace-id"] + assert workspace.id == "test-workspace-id" + assert workspace.name == "test-workspace" + assert workspace.location == "eastus" + assert workspace.custom_managed_vnet_id == "test-vnet-id" + assert ( + workspace.managed_disk_encryption.__class__.__name__ + == "ManagedDiskEncryption" + ) + assert workspace.managed_disk_encryption.key_name == "test-key" + assert workspace.managed_disk_encryption.key_version == "test-version" + assert workspace.managed_disk_encryption.key_vault_uri == "test-vault-uri" + + +def mock_databricks_get_workspaces_no_encryption(_): + return { + AZURE_SUBSCRIPTION_ID: { + "test-workspace-id": DatabricksWorkspace( + id="test-workspace-id", + name="test-workspace", + location="eastus", + custom_managed_vnet_id="test-vnet-id", + managed_disk_encryption=None, + ) + } + } + + +@patch( + "prowler.providers.azure.services.databricks.databricks_service.Databricks._get_workspaces", + new=mock_databricks_get_workspaces_no_encryption, +) +class Test_Databricks_Service_No_Encryption: + def test_get_workspaces_no_encryption(self): + databricks = Databricks(set_mocked_azure_provider()) + workspace = databricks.workspaces[AZURE_SUBSCRIPTION_ID]["test-workspace-id"] + assert workspace.id == "test-workspace-id" + assert workspace.name == "test-workspace" + assert workspace.location == "eastus" + assert workspace.custom_managed_vnet_id == "test-vnet-id" + assert workspace.managed_disk_encryption is None diff --git a/tests/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled_test.py b/tests/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled_test.py new file mode 100644 index 0000000000..1e145d4f80 --- /dev/null +++ b/tests/providers/azure/services/databricks/databricks_workspace_cmk_encryption_enabled/databricks_workspace_cmk_encryption_enabled_test.py @@ -0,0 +1,130 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.databricks.databricks_service import ( + DatabricksWorkspace, + ManagedDiskEncryption, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_databricks_workspace_cmk_encryption_enabled: + def test_no_databricks_workspaces(self): + databricks_client = mock.MagicMock + databricks_client.workspaces = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_cmk_encryption_enabled.databricks_workspace_cmk_encryption_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_cmk_encryption_enabled.databricks_workspace_cmk_encryption_enabled import ( + databricks_workspace_cmk_encryption_enabled, + ) + + check = databricks_workspace_cmk_encryption_enabled() + result = check.execute() + assert len(result) == 0 + + def test_databricks_workspace_cmk_encryption_disabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + + databricks_client = mock.MagicMock + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + custom_managed_vnet_id=None, + managed_disk_encryption=None, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_cmk_encryption_enabled.databricks_workspace_cmk_encryption_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_cmk_encryption_enabled.databricks_workspace_cmk_encryption_enabled import ( + databricks_workspace_cmk_encryption_enabled, + ) + + check = databricks_workspace_cmk_encryption_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_ID} does not have customer-managed key (CMK) encryption enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" + + def test_databricks_workspace_cmk_encryption_enabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + key_name = "test-key" + key_version = "test-version" + key_vault_uri = "test-vault-uri" + + databricks_client = mock.MagicMock + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + custom_managed_vnet_id=None, + managed_disk_encryption=ManagedDiskEncryption( + key_name=key_name, + key_version=key_version, + key_vault_uri=key_vault_uri, + ), + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_cmk_encryption_enabled.databricks_workspace_cmk_encryption_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_cmk_encryption_enabled.databricks_workspace_cmk_encryption_enabled import ( + databricks_workspace_cmk_encryption_enabled, + ) + + check = databricks_workspace_cmk_encryption_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_ID} has customer-managed key (CMK) encryption enabled with key {key_vault_uri}/{key_name}/{key_version}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled_test.py b/tests/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled_test.py new file mode 100644 index 0000000000..912ee363ac --- /dev/null +++ b/tests/providers/azure/services/databricks/databricks_workspace_vnet_injection_enabled/databricks_workspace_vnet_injection_enabled_test.py @@ -0,0 +1,119 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.databricks.databricks_service import ( + DatabricksWorkspace, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_databricks_workspace_vnet_injection_enabled: + def test_databricks_no_workspaces(self): + databricks_client = mock.MagicMock + databricks_client.workspaces = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_vnet_injection_enabled.databricks_workspace_vnet_injection_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_vnet_injection_enabled.databricks_workspace_vnet_injection_enabled import ( + databricks_workspace_vnet_injection_enabled, + ) + + check = databricks_workspace_vnet_injection_enabled() + result = check.execute() + assert len(result) == 0 + + def test_databricks_workspace_vnet_injection_disabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + databricks_client = mock.MagicMock + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + custom_managed_vnet_id=None, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_vnet_injection_enabled.databricks_workspace_vnet_injection_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_vnet_injection_enabled.databricks_workspace_vnet_injection_enabled import ( + databricks_workspace_vnet_injection_enabled, + ) + + check = databricks_workspace_vnet_injection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_ID} is not deployed in a customer-managed VNet (VNet Injection is not enabled)." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" + + def test_databricks_workspace_vnet_injection_enabled(self): + workspace_id = str(uuid4()) + workspace_name = "test-workspace" + vnet_id = "test-vnet-id" + databricks_client = mock.MagicMock + databricks_client.workspaces = { + AZURE_SUBSCRIPTION_ID: { + workspace_id: DatabricksWorkspace( + id=workspace_id, + name=workspace_name, + location="eastus", + custom_managed_vnet_id=vnet_id, + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.databricks.databricks_workspace_vnet_injection_enabled.databricks_workspace_vnet_injection_enabled.databricks_client", + new=databricks_client, + ), + ): + from prowler.providers.azure.services.databricks.databricks_workspace_vnet_injection_enabled.databricks_workspace_vnet_injection_enabled import ( + databricks_workspace_vnet_injection_enabled, + ) + + check = databricks_workspace_vnet_injection_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Databricks workspace {workspace_name} in subscription {AZURE_SUBSCRIPTION_ID} is deployed in a customer-managed VNet ({vnet_id})." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == workspace_name + assert result[0].resource_id == workspace_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py b/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py index bb63fd7c5b..3ef2114d4c 100644 --- a/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py +++ b/tests/providers/azure/services/defender/defender_additional_email_configured_with_a_security_contact/defender_additional_email_configured_with_a_security_contact_test.py @@ -296,3 +296,49 @@ class Test_defender_additional_email_configured_with_a_security_contact: result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default" ) + + def test_defender_default_security_contact_not_found_empty_name(self): + resource_id = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default" + defender_client = mock.MagicMock + defender_client.security_contacts = { + AZURE_SUBSCRIPTION_ID: { + resource_id: SecurityContacts( + resource_id=resource_id, + name="", + emails="", + phone="", + alert_notifications_minimal_severity="", + alert_notifications_state="", + notified_roles=[""], + notified_roles_state="", + ) + } + } + contact = defender_client.security_contacts[AZURE_SUBSCRIPTION_ID][resource_id] + contact.name = getattr(contact, "name", "default") or "default" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_additional_email_configured_with_a_security_contact.defender_additional_email_configured_with_a_security_contact.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_additional_email_configured_with_a_security_contact.defender_additional_email_configured_with_a_security_contact import ( + defender_additional_email_configured_with_a_security_contact, + ) + + check = defender_additional_email_configured_with_a_security_contact() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"There is not another correct email configured for subscription {AZURE_SUBSCRIPTION_ID}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "default" + assert result[0].resource_id == resource_id diff --git a/tests/providers/azure/services/defender/defender_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 23aadef795..229ae4259e 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 @@ -208,3 +208,50 @@ class Test_defender_ensure_notify_alerts_severity_is_high: result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default" ) + + def test_defender_default_security_contact_not_found_empty_name(self): + resource_id = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default" + defender_client = mock.MagicMock + defender_client.security_contacts = { + AZURE_SUBSCRIPTION_ID: { + resource_id: SecurityContacts( + resource_id=resource_id, + name="", + emails="", + phone="", + alert_notifications_minimal_severity="", + alert_notifications_state="", + notified_roles=[""], + notified_roles_state="", + ) + } + } + + contact = defender_client.security_contacts[AZURE_SUBSCRIPTION_ID][resource_id] + contact.name = getattr(contact, "name", "default") or "default" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_ensure_notify_alerts_severity_is_high.defender_ensure_notify_alerts_severity_is_high.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_notify_alerts_severity_is_high.defender_ensure_notify_alerts_severity_is_high import ( + defender_ensure_notify_alerts_severity_is_high, + ) + + check = defender_ensure_notify_alerts_severity_is_high() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Notifications are not enabled for alerts with a minimum severity of high or lower in subscription {AZURE_SUBSCRIPTION_ID}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "default" + assert result[0].resource_id == resource_id diff --git a/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py b/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py index 5eff665838..092a3a81a0 100644 --- a/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py +++ b/tests/providers/azure/services/defender/defender_ensure_notify_emails_to_owners/defender_ensure_notify_emails_to_owners_test.py @@ -208,3 +208,49 @@ class Test_defender_ensure_notify_emails_to_owners: result[0].resource_id == f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default" ) + + def test_defender_default_security_contact_not_found_empty_name(self): + defender_client = mock.MagicMock() + resource_id = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Security/securityContacts/default" + defender_client.security_contacts = { + AZURE_SUBSCRIPTION_ID: { + resource_id: SecurityContacts( + resource_id=resource_id, + name="", + emails="", + phone="", + alert_notifications_minimal_severity="", + alert_notifications_state="", + notified_roles=[""], + notified_roles_state="", + ) + } + } + contact = defender_client.security_contacts[AZURE_SUBSCRIPTION_ID][resource_id] + contact.name = getattr(contact, "name", "default") or "default" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.defender.defender_ensure_notify_emails_to_owners.defender_ensure_notify_emails_to_owners.defender_client", + new=defender_client, + ), + ): + from prowler.providers.azure.services.defender.defender_ensure_notify_emails_to_owners.defender_ensure_notify_emails_to_owners import ( + defender_ensure_notify_emails_to_owners, + ) + + check = defender_ensure_notify_emails_to_owners() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"The Owner role is not notified for subscription {AZURE_SUBSCRIPTION_ID}." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "default" + assert result[0].resource_id == resource_id diff --git a/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py b/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py index 53ab5d8b3e..a02995c45b 100644 --- a/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py +++ b/tests/providers/azure/services/entra/entra_user_with_vm_access_has_mfa/entra_user_with_vm_access_has_mfa_test.py @@ -75,6 +75,9 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: iam_client.role_assignments = { AZURE_SUBSCRIPTION_ID: { role_assigment_id: RoleAssignment( + id=role_assigment_id, + name="test", + scope=AZURE_SUBSCRIPTION_ID, role_id=VIRTUAL_MACHINE_ADMINISTRATOR_LOGIN_ROLE_ID, agent_type="User", agent_id=user_id, @@ -149,6 +152,9 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: iam_client.role_assignments = { AZURE_SUBSCRIPTION_ID: { role_assigment_id: RoleAssignment( + id=role_assigment_id, + name="test", + scope=AZURE_SUBSCRIPTION_ID, role_id=VIRTUAL_MACHINE_ADMINISTRATOR_LOGIN_ROLE_ID, agent_type="User", agent_id=user_id, @@ -216,6 +222,9 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: iam_client.role_assignments = { AZURE_SUBSCRIPTION_ID: { role_assigment_id: RoleAssignment( + id=role_assigment_id, + name="test", + scope=AZURE_SUBSCRIPTION_ID, role_id=VIRTUAL_MACHINE_ADMINISTRATOR_LOGIN_ROLE_ID, agent_type="User", agent_id=user_id, @@ -269,6 +278,9 @@ class Test_iam_assignment_priviledge_access_vm_has_mfa: iam_client.role_assignments = { AZURE_SUBSCRIPTION_ID: { role_assigment_id: RoleAssignment( + id=role_assigment_id, + name="test", + scope=AZURE_SUBSCRIPTION_ID, role_id=str(uuid4()), agent_type="User", agent_id=user_id, diff --git a/tests/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks_test.py b/tests/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks_test.py index 760015e6a1..eaa59eff14 100644 --- a/tests/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks_test.py +++ b/tests/providers/azure/services/iam/iam_custom_role_has_permissions_to_administer_resource_locks/iam_custom_role_has_permissions_to_administer_resource_locks_test.py @@ -1,5 +1,4 @@ from unittest import mock -from uuid import uuid4 from azure.mgmt.authorization.v2022_04_01.models import Permission @@ -39,9 +38,9 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: defender_client = mock.MagicMock role_name = "test-role" defender_client.custom_roles = { - AZURE_SUBSCRIPTION_ID: [ - Role( - id=str(uuid4()), + AZURE_SUBSCRIPTION_ID: { + "test-role-id": Role( + id="test-role-id", name=role_name, type="CustomRole", assignable_scopes=["/.*", "/test"], @@ -54,7 +53,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: ) ], ) - ] + } } with ( @@ -82,7 +81,9 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert ( result[0].resource_id - == defender_client.custom_roles[AZURE_SUBSCRIPTION_ID][0].id + == defender_client.custom_roles[AZURE_SUBSCRIPTION_ID][ + "test-role-id" + ].id ) assert result[0].resource_name == role_name @@ -92,15 +93,15 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: defender_client = mock.MagicMock role_name = "test-role" defender_client.custom_roles = { - AZURE_SUBSCRIPTION_ID: [ - Role( - id=str(uuid4()), + AZURE_SUBSCRIPTION_ID: { + "test-role-id": Role( + id="test-role-id", name=role_name, type="CustomRole", assignable_scopes=["/*"], permissions=[Permission(actions=["*"])], ) - ] + } } with ( @@ -128,7 +129,9 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert ( result[0].resource_id - == defender_client.custom_roles[AZURE_SUBSCRIPTION_ID][0].id + == defender_client.custom_roles[AZURE_SUBSCRIPTION_ID][ + "test-role-id" + ].id ) assert result[0].resource_name == role_name @@ -139,9 +142,9 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: role_name = "test-role" role_name2 = "test-role2" defender_client.custom_roles = { - AZURE_SUBSCRIPTION_ID: [ - Role( - id=str(uuid4()), + AZURE_SUBSCRIPTION_ID: { + "test-role-id": Role( + id="test-role-id", name=role_name, type="CustomRole", assignable_scopes=["/.*", "/test"], @@ -154,8 +157,8 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: ) ], ), - Role( - id=str(uuid4()), + "test-role-id2": Role( + id="test-role-id2", name=role_name2, type="CustomRole", assignable_scopes=["/.*", "/test"], @@ -168,7 +171,7 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: ) ], ), - ] + } } with ( @@ -196,12 +199,14 @@ class Test_iam_custom_role_has_permissions_to_administer_resource_locks: assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert ( result[0].resource_id - == defender_client.custom_roles[AZURE_SUBSCRIPTION_ID][0].id + == defender_client.custom_roles[AZURE_SUBSCRIPTION_ID][ + "test-role-id" + ].id ) def test_iam_custom_roles_empty_list_but_with_key(self): defender_client = mock.MagicMock - defender_client.custom_roles = {AZURE_SUBSCRIPTION_ID: []} + defender_client.custom_roles = {AZURE_SUBSCRIPTION_ID: {}} with ( mock.patch( diff --git a/tests/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted_test.py b/tests/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted_test.py new file mode 100644 index 0000000000..dbf842d589 --- /dev/null +++ b/tests/providers/azure/services/iam/iam_role_user_access_admin_restricted/iam_role_user_access_admin_restricted_test.py @@ -0,0 +1,153 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.iam.iam_service import Role, RoleAssignment +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_iam_role_user_access_admin_restricted: + def test_iam_no_role_assignments(self): + iam_client = mock.MagicMock + iam_client.role_assignments = {} + iam_client.roles = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.iam.iam_role_user_access_admin_restricted.iam_role_user_access_admin_restricted.iam_client", + new=iam_client, + ), + ): + from prowler.providers.azure.services.iam.iam_role_user_access_admin_restricted.iam_role_user_access_admin_restricted import ( + iam_role_user_access_admin_restricted, + ) + + check = iam_role_user_access_admin_restricted() + result = check.execute() + assert len(result) == 0 + + def test_iam_user_access_administrator_role_assigned(self): + iam_client = mock.MagicMock + role_id = str(uuid4()) + role_assignment_id = str(uuid4()) + agent_id = str(uuid4()) + role_name = "User Access Administrator" + + iam_client.subscriptions = { + "subscription-name-1": AZURE_SUBSCRIPTION_ID, + } + + iam_client.role_assignments = { + "subscription-name-1": { + role_assignment_id: RoleAssignment( + id=role_assignment_id, + name="test-assignment", + scope=f"/subscriptions/{AZURE_SUBSCRIPTION_ID}", + agent_id=agent_id, + agent_type="User", + role_id=role_id, + ) + } + } + iam_client.roles = { + "subscription-name-1": { + f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Authorization/roleDefinitions/{role_id}": Role( + id=role_id, + name=role_name, + type="BuiltInRole", + assignable_scopes=[f"/subscriptions/{AZURE_SUBSCRIPTION_ID}"], + permissions=[], + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.iam.iam_role_user_access_admin_restricted.iam_role_user_access_admin_restricted.iam_client", + new=iam_client, + ), + ): + from prowler.providers.azure.services.iam.iam_role_user_access_admin_restricted.iam_role_user_access_admin_restricted import ( + iam_role_user_access_admin_restricted, + ) + + check = iam_role_user_access_admin_restricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Role assignment test-assignment in subscription subscription-name-1 grants User Access Administrator role to User {agent_id}." + ) + assert result[0].subscription == "subscription-name-1" + assert result[0].resource_id == role_assignment_id + + def test_iam_non_user_access_administrator_role_assigned(self): + iam_client = mock.MagicMock + role_id = str(uuid4()) + role_assignment_id = str(uuid4()) + agent_id = str(uuid4()) + role_name = "Reader" + + iam_client.subscriptions = { + "subscription-name-1": AZURE_SUBSCRIPTION_ID, + } + + iam_client.role_assignments = { + "subscription-name-1": { + role_assignment_id: RoleAssignment( + id=role_assignment_id, + name="test-assignment", + scope=f"/subscriptions/{AZURE_SUBSCRIPTION_ID}", + agent_id=agent_id, + agent_type="User", + role_id=role_id, + ) + } + } + iam_client.roles = { + "subscription-name-1": { + f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/providers/Microsoft.Authorization/roleDefinitions/{role_id}": Role( + id=role_id, + name=role_name, + type="BuiltInRole", + assignable_scopes=[f"/subscriptions/{AZURE_SUBSCRIPTION_ID}"], + permissions=[], + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.iam.iam_role_user_access_admin_restricted.iam_role_user_access_admin_restricted.iam_client", + new=iam_client, + ), + ): + from prowler.providers.azure.services.iam.iam_role_user_access_admin_restricted.iam_role_user_access_admin_restricted import ( + iam_role_user_access_admin_restricted, + ) + + check = iam_role_user_access_admin_restricted() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Role assignment test-assignment in subscription subscription-name-1 does not grant User Access Administrator role." + ) + assert result[0].subscription == "subscription-name-1" + assert result[0].resource_id == role_assignment_id diff --git a/tests/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created_test.py b/tests/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created_test.py index 52f824829d..7f15b69466 100644 --- a/tests/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created_test.py +++ b/tests/providers/azure/services/iam/iam_subscription_roles_owner_custom_not_created/iam_subscription_roles_owner_custom_not_created_test.py @@ -1,5 +1,4 @@ from unittest import mock -from uuid import uuid4 from azure.mgmt.authorization.v2022_04_01.models import Permission @@ -37,15 +36,15 @@ class Test_iam_subscription_roles_owner_custom_not_created: defender_client = mock.MagicMock role_name = "test-role" defender_client.custom_roles = { - AZURE_SUBSCRIPTION_ID: [ - Role( - id=str(uuid4()), + AZURE_SUBSCRIPTION_ID: { + "test-role-id": Role( + id="test-role-id", name=role_name, type="CustomRole", assignable_scopes=["/*"], permissions=[Permission(actions="*")], ) - ] + } } with ( @@ -73,7 +72,9 @@ class Test_iam_subscription_roles_owner_custom_not_created: assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert ( result[0].resource_id - == defender_client.custom_roles[AZURE_SUBSCRIPTION_ID][0].id + == defender_client.custom_roles[AZURE_SUBSCRIPTION_ID][ + "test-role-id" + ].id ) assert result[0].resource_name == role_name @@ -81,15 +82,15 @@ class Test_iam_subscription_roles_owner_custom_not_created: defender_client = mock.MagicMock role_name = "test-role" defender_client.custom_roles = { - AZURE_SUBSCRIPTION_ID: [ - Role( - id=str(uuid4()), + AZURE_SUBSCRIPTION_ID: { + "test-role-id": Role( + id="test-role-id", name=role_name, type="type-role", assignable_scopes=[""], permissions=[Permission()], ) - ] + } } with ( @@ -117,6 +118,8 @@ class Test_iam_subscription_roles_owner_custom_not_created: assert result[0].subscription == AZURE_SUBSCRIPTION_ID assert ( result[0].resource_id - == defender_client.custom_roles[AZURE_SUBSCRIPTION_ID][0].id + == defender_client.custom_roles[AZURE_SUBSCRIPTION_ID][ + "test-role-id" + ].id ) assert result[0].resource_name == role_name diff --git a/tests/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints_test.py b/tests/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints_test.py new file mode 100644 index 0000000000..793b3b4912 --- /dev/null +++ b/tests/providers/azure/services/keyvault/keyvault_access_only_through_private_endpoints/keyvault_access_only_through_private_endpoints_test.py @@ -0,0 +1,193 @@ +from unittest import mock +from uuid import uuid4 + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_keyvault_access_only_through_private_endpoints: + def test_no_key_vaults(self): + keyvault_client = mock.MagicMock + keyvault_client.key_vaults = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_access_only_through_private_endpoints.keyvault_access_only_through_private_endpoints.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_access_only_through_private_endpoints.keyvault_access_only_through_private_endpoints import ( + keyvault_access_only_through_private_endpoints, + ) + + check = keyvault_access_only_through_private_endpoints() + result = check.execute() + assert len(result) == 0 + + def test_key_vaults_no_private_endpoints(self): + keyvault_client = mock.MagicMock + keyvault_name = "Keyvault Name" + keyvault_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_access_only_through_private_endpoints.keyvault_access_only_through_private_endpoints.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_access_only_through_private_endpoints.keyvault_access_only_through_private_endpoints import ( + keyvault_access_only_through_private_endpoints, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVaultInfo, + VaultProperties, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id=keyvault_id, + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + enable_rbac_authorization=False, + private_endpoint_connections=[], + enable_soft_delete=True, + enable_purge_protection=True, + public_network_access_disabled=False, + ), + ) + ] + } + + check = keyvault_access_only_through_private_endpoints() + result = check.execute() + assert len(result) == 0 + + def test_key_vaults_with_private_endpoints_public_access_enabled(self): + keyvault_client = mock.MagicMock + keyvault_name = "Keyvault Name" + keyvault_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_access_only_through_private_endpoints.keyvault_access_only_through_private_endpoints.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_access_only_through_private_endpoints.keyvault_access_only_through_private_endpoints import ( + keyvault_access_only_through_private_endpoints, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVaultInfo, + PrivateEndpointConnection, + VaultProperties, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id=keyvault_id, + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + enable_rbac_authorization=True, + private_endpoint_connections=[ + PrivateEndpointConnection(id="id1") + ], + enable_soft_delete=True, + enable_purge_protection=True, + public_network_access_disabled=False, + ), + ) + ] + } + + check = keyvault_access_only_through_private_endpoints() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has public network access enabled while using private endpoints." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == keyvault_name + assert result[0].resource_id == keyvault_id + assert result[0].location == "westeurope" + + def test_key_vaults_with_private_endpoints_public_access_disabled(self): + keyvault_client = mock.MagicMock + keyvault_name = "Keyvault Name" + keyvault_id = str(uuid4()) + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.keyvault.keyvault_access_only_through_private_endpoints.keyvault_access_only_through_private_endpoints.keyvault_client", + new=keyvault_client, + ), + ): + from prowler.providers.azure.services.keyvault.keyvault_access_only_through_private_endpoints.keyvault_access_only_through_private_endpoints import ( + keyvault_access_only_through_private_endpoints, + ) + from prowler.providers.azure.services.keyvault.keyvault_service import ( + KeyVaultInfo, + PrivateEndpointConnection, + VaultProperties, + ) + + keyvault_client.key_vaults = { + AZURE_SUBSCRIPTION_ID: [ + KeyVaultInfo( + id=keyvault_id, + name=keyvault_name, + location="westeurope", + resource_group="resource_group", + properties=VaultProperties( + tenant_id="tenantid", + enable_rbac_authorization=True, + private_endpoint_connections=[ + PrivateEndpointConnection(id="id1") + ], + enable_soft_delete=True, + enable_purge_protection=True, + public_network_access_disabled=True, + ), + ) + ] + } + + check = keyvault_access_only_through_private_endpoints() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Keyvault {keyvault_name} from subscription {AZURE_SUBSCRIPTION_ID} has public network access disabled and is using private endpoints." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == keyvault_name + assert result[0].resource_id == keyvault_id + assert result[0].location == "westeurope" diff --git a/tests/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists_test.py b/tests/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists_test.py new file mode 100644 index 0000000000..1d7dbb12b5 --- /dev/null +++ b/tests/providers/azure/services/monitor/monitor_alert_service_health_exists/monitor_alert_service_health_exists_test.py @@ -0,0 +1,164 @@ +from unittest import mock + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_monitor_alert_service_health_exists: + def test_monitor_alert_service_health_exists_no_subscriptions(self): + monitor_client = mock.MagicMock() + monitor_client.alert_rules = {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.monitor.monitor_alert_service_health_exists.monitor_alert_service_health_exists.monitor_client", + new=monitor_client, + ), + ): + from prowler.providers.azure.services.monitor.monitor_alert_service_health_exists.monitor_alert_service_health_exists import ( + monitor_alert_service_health_exists, + ) + + check = monitor_alert_service_health_exists() + result = check.execute() + assert len(result) == 0 + + def test_no_alert_rules(self): + monitor_client = mock.MagicMock() + monitor_client.alert_rules = {AZURE_SUBSCRIPTION_ID: []} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.monitor.monitor_alert_service_health_exists.monitor_alert_service_health_exists.monitor_client", + new=monitor_client, + ), + ): + from prowler.providers.azure.services.monitor.monitor_alert_service_health_exists.monitor_alert_service_health_exists import ( + monitor_alert_service_health_exists, + ) + + check = monitor_alert_service_health_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "Monitor" + assert result[0].resource_id == "Monitor" + assert ( + result[0].status_extended + == f"There is no activity log alert for Service Health in subscription {AZURE_SUBSCRIPTION_ID}." + ) + + def test_alert_rules_configured(self): + monitor_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.monitor.monitor_alert_service_health_exists.monitor_alert_service_health_exists.monitor_client", + new=monitor_client, + ), + ): + from prowler.providers.azure.services.monitor.monitor_alert_service_health_exists.monitor_alert_service_health_exists import ( + monitor_alert_service_health_exists, + ) + from prowler.providers.azure.services.monitor.monitor_service import ( + AlertRule, + AlertRuleAllOfCondition, + AlertRuleAnyOfOrLeafCondition, + ) + + monitor_client.alert_rules = { + AZURE_SUBSCRIPTION_ID: [ + AlertRule( + id="id1", + name="name1", + condition=AlertRuleAllOfCondition( + all_of=[ + AlertRuleAnyOfOrLeafCondition( + field="category", equals="ServiceHealth" + ), + AlertRuleAnyOfOrLeafCondition( + field="properties.incidentType", equals="Incident" + ), + ] + ), + enabled=True, + description="desc1", + ), + ] + } + check = monitor_alert_service_health_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "name1" + assert result[0].resource_id == "id1" + assert ( + result[0].status_extended + == f"There is an activity log alert for Service Health in subscription {AZURE_SUBSCRIPTION_ID}." + ) + + def test_alert_rules_configured_but_disabled(self): + monitor_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.monitor.monitor_alert_service_health_exists.monitor_alert_service_health_exists.monitor_client", + new=monitor_client, + ), + ): + from prowler.providers.azure.services.monitor.monitor_alert_service_health_exists.monitor_alert_service_health_exists import ( + monitor_alert_service_health_exists, + ) + from prowler.providers.azure.services.monitor.monitor_service import ( + AlertRule, + AlertRuleAllOfCondition, + AlertRuleAnyOfOrLeafCondition, + ) + + monitor_client.alert_rules = { + AZURE_SUBSCRIPTION_ID: [ + AlertRule( + id="id1", + name="name1", + condition=AlertRuleAllOfCondition( + all_of=[ + AlertRuleAnyOfOrLeafCondition( + field="category", equals="ServiceHealth" + ), + AlertRuleAnyOfOrLeafCondition( + field="properties.incidentType", equals="Incident" + ), + ] + ), + enabled=False, + description="desc1", + ), + ] + } + check = monitor_alert_service_health_exists() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == "Monitor" + assert result[0].resource_id == "Monitor" + assert ( + result[0].status_extended + == f"There is no activity log alert for Service Health in subscription {AZURE_SUBSCRIPTION_ID}." + ) diff --git a/tests/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days_test.py b/tests/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days_test.py index 44ce72a127..1a8f13e280 100644 --- a/tests/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days_test.py +++ b/tests/providers/azure/services/network/network_flow_log_more_than_90_days/network_flow_log_more_than_90_days_test.py @@ -188,6 +188,58 @@ class Test_network_flow_log_more_than_90_days: assert result[0].resource_id == network_watcher_id assert result[0].location == "location" + def test_network_network_watchers_flow_logs_retention_days_0(self): + network_client = mock.MagicMock + network_watcher_name = "Network Watcher Name" + network_watcher_id = str(uuid4()) + + network_client.network_watchers = { + AZURE_SUBSCRIPTION_ID: [ + NetworkWatcher( + id=network_watcher_id, + name=network_watcher_name, + location="location", + flow_logs=[ + FlowLog( + enabled=True, + retention_policy=RetentionPolicyParameters(days=0), + ) + ], + ) + ] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.network.network_service.Network", + new=network_client, + ) as service_client, + mock.patch( + "prowler.providers.azure.services.network.network_client.network_client", + new=service_client, + ), + ): + from prowler.providers.azure.services.network.network_flow_log_more_than_90_days.network_flow_log_more_than_90_days import ( + network_flow_log_more_than_90_days, + ) + + check = network_flow_log_more_than_90_days() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Network Watcher {network_watcher_name} from subscription {AZURE_SUBSCRIPTION_ID} has flow logs enabled for more than 90 days" + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == network_watcher_name + assert result[0].resource_id == network_watcher_id + assert result[0].location == "location" + def test_network_network_watchers_flow_logs_well_configured(self): network_client = mock.MagicMock network_watcher_name = "Network Watcher Name" diff --git a/tests/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled_test.py b/tests/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled_test.py new file mode 100644 index 0000000000..ae0e89716a --- /dev/null +++ b/tests/providers/azure/services/storage/storage_blob_versioning_is_enabled/storage_blob_versioning_is_enabled_test.py @@ -0,0 +1,207 @@ +from unittest import mock +from uuid import uuid4 + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_storage_blob_versioning_is_enabled: + def test_storage_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_blob_versioning_is_enabled.storage_blob_versioning_is_enabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_blob_versioning_is_enabled.storage_blob_versioning_is_enabled import ( + storage_blob_versioning_is_enabled, + ) + + check = storage_blob_versioning_is_enabled() + result = check.execute() + assert len(result) == 0 + + def test_storage_no_blob_properties(self): + storage_account_id = str(uuid4()) + storage_account_name = "Test Storage Account" + storage_client = mock.MagicMock + storage_account_blob_properties = None + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_blob_versioning_is_enabled.storage_blob_versioning_is_enabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_service import Account + + 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, + blob_properties=storage_account_blob_properties, + ) + ] + } + from prowler.providers.azure.services.storage.storage_blob_versioning_is_enabled.storage_blob_versioning_is_enabled import ( + storage_blob_versioning_is_enabled, + ) + + check = storage_blob_versioning_is_enabled() + result = check.execute() + assert len(result) == 0 + + def test_storage_blob_versioning_is_enabled(self): + storage_account_id = str(uuid4()) + storage_account_name = "Test Storage Account" + storage_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_blob_versioning_is_enabled.storage_blob_versioning_is_enabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_service import ( + Account, + BlobProperties, + DeleteRetentionPolicy, + ) + + storage_account_blob_properties = BlobProperties( + id=None, + name=None, + type=None, + default_service_version=None, + container_delete_retention_policy=DeleteRetentionPolicy( + enabled=False, days=0 + ), + versioning_enabled=True, + ) + 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, + blob_properties=storage_account_blob_properties, + ) + ] + } + from prowler.providers.azure.services.storage.storage_blob_versioning_is_enabled.storage_blob_versioning_is_enabled import ( + storage_blob_versioning_is_enabled, + ) + + check = storage_blob_versioning_is_enabled() + 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 blob versioning 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_blob_versioning_is_disabled(self): + storage_account_id = str(uuid4()) + storage_account_name = "Test Storage Account" + storage_client = mock.MagicMock + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.storage.storage_blob_versioning_is_enabled.storage_blob_versioning_is_enabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_service import ( + Account, + BlobProperties, + DeleteRetentionPolicy, + ) + + storage_account_blob_properties = BlobProperties( + id=None, + name=None, + type=None, + default_service_version=None, + container_delete_retention_policy=DeleteRetentionPolicy( + enabled=False, days=0 + ), + versioning_enabled=False, + ) + 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, + blob_properties=storage_account_blob_properties, + ) + ] + } + from prowler.providers.azure.services.storage.storage_blob_versioning_is_enabled.storage_blob_versioning_is_enabled import ( + storage_blob_versioning_is_enabled, + ) + + check = storage_blob_versioning_is_enabled() + 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} does not have blob versioning 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" diff --git a/tests/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled_test.py b/tests/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_disabled_test.py new file mode 100644 index 0000000000..803856572a --- /dev/null +++ b/tests/providers/azure/services/storage/storage_cross_tenant_replication_disabled/storage_cross_tenant_replication_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_cross_tenant_replication_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_cross_tenant_replication_disabled.storage_cross_tenant_replication_disabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_cross_tenant_replication_disabled.storage_cross_tenant_replication_disabled import ( + storage_cross_tenant_replication_disabled, + ) + + check = storage_cross_tenant_replication_disabled() + result = check.execute() + assert len(result) == 0 + + def test_storage_account_cross_tenant_replication_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_cross_tenant_replication=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_cross_tenant_replication_disabled.storage_cross_tenant_replication_disabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_cross_tenant_replication_disabled.storage_cross_tenant_replication_disabled import ( + storage_cross_tenant_replication_disabled, + ) + + check = storage_cross_tenant_replication_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 cross-tenant replication 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_cross_tenant_replication_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_cross_tenant_replication=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_cross_tenant_replication_disabled.storage_cross_tenant_replication_disabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_cross_tenant_replication_disabled.storage_cross_tenant_replication_disabled import ( + storage_cross_tenant_replication_disabled, + ) + + check = storage_cross_tenant_replication_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 cross-tenant replication 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_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled_test.py b/tests/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled_test.py new file mode 100644 index 0000000000..33de6038d8 --- /dev/null +++ b/tests/providers/azure/services/storage/storage_default_to_entra_authorization_enabled/storage_default_to_entra_authorization_enabled_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_default_to_entra_authorization_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_default_to_entra_authorization_enabled.storage_default_to_entra_authorization_enabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_default_to_entra_authorization_enabled.storage_default_to_entra_authorization_enabled import ( + storage_default_to_entra_authorization_enabled, + ) + + check = storage_default_to_entra_authorization_enabled() + result = check.execute() + assert len(result) == 0 + + def test_storage_default_to_entra_authorization_enabled(self): + storage_account_id = str(uuid4()) + storage_account_name = "Test Storage Account Entra Auth Enabled" + 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=False, + network_rule_set=None, + encryption_type=None, + minimum_tls_version=None, + key_expiration_period_in_days=None, + location="westeurope", + private_endpoint_connections=None, + default_to_entra_authorization=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_default_to_entra_authorization_enabled.storage_default_to_entra_authorization_enabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_default_to_entra_authorization_enabled.storage_default_to_entra_authorization_enabled import ( + storage_default_to_entra_authorization_enabled, + ) + + check = storage_default_to_entra_authorization_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Default to Microsoft Entra authorization is enabled for storage account {storage_account_name}." + ) + 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_default_to_entra_authorization_disabled(self): + storage_account_id = str(uuid4()) + storage_account_name = "Test Storage Account Entra Auth Disabled" + 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=False, + network_rule_set=None, + encryption_type=None, + minimum_tls_version=None, + key_expiration_period_in_days=None, + location="westeurope", + private_endpoint_connections=None, + default_to_entra_authorization=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_default_to_entra_authorization_enabled.storage_default_to_entra_authorization_enabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_default_to_entra_authorization_enabled.storage_default_to_entra_authorization_enabled import ( + storage_default_to_entra_authorization_enabled, + ) + + check = storage_default_to_entra_authorization_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Default to Microsoft Entra authorization is not enabled for storage account {storage_account_name}." + ) + 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 index 219673e8f0..61baac34b6 100644 --- a/tests/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled_test.py +++ b/tests/providers/azure/services/storage/storage_ensure_file_shares_soft_delete_is_enabled/storage_ensure_file_shares_soft_delete_is_enabled_test.py @@ -1,7 +1,11 @@ from unittest import mock from uuid import uuid4 -from prowler.providers.azure.services.storage.storage_service import Account, FileShare +from prowler.providers.azure.services.storage.storage_service import ( + Account, + DeleteRetentionPolicy, + FileServiceProperties, +) from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, set_mocked_azure_provider, @@ -31,7 +35,7 @@ class Test_storage_ensure_file_shares_soft_delete_is_enabled: result = check.execute() assert len(result) == 0 - def test_no_file_shares(self): + def test_storage_account_no_file_properties(self): storage_account_id = str(uuid4()) storage_account_name = "Test Storage Account" storage_client = mock.MagicMock @@ -50,7 +54,7 @@ class Test_storage_ensure_file_shares_soft_delete_is_enabled: key_expiration_period_in_days=None, location="westeurope", private_endpoint_connections=None, - file_shares=[], + file_service_properties=None, ) ] } @@ -76,13 +80,14 @@ class Test_storage_ensure_file_shares_soft_delete_is_enabled: 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 + retention_policy = DeleteRetentionPolicy(enabled=False, days=0) + file_service_properties = FileServiceProperties( + id=f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/prowler-resource-group/providers/Microsoft.Storage/storageAccounts/{storage_account_name}/fileServices/default", + name="default", + type="Microsoft.Storage/storageAccounts/fileServices", + share_delete_retention_policy=retention_policy, + ) storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -98,7 +103,7 @@ class Test_storage_ensure_file_shares_soft_delete_is_enabled: key_expiration_period_in_days=None, location="westeurope", private_endpoint_connections=None, - file_shares=[file_share], + file_service_properties=file_service_properties, ) ] } @@ -123,23 +128,24 @@ class Test_storage_ensure_file_shares_soft_delete_is_enabled: 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." + == f"File share soft delete is not enabled for storage account {storage_account_name}." ) 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 + assert result[0].resource_id == file_service_properties.id + assert result[0].location == "westeurope" 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 + retention_policy = DeleteRetentionPolicy(enabled=True, days=7) + file_service_properties = FileServiceProperties( + id=f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/prowler-resource-group/providers/Microsoft.Storage/storageAccounts/{storage_account_name}/fileServices/default", + name="default", + type="Microsoft.Storage/storageAccounts/fileServices", + share_delete_retention_policy=retention_policy, + ) storage_client.storage_accounts = { AZURE_SUBSCRIPTION_ID: [ Account( @@ -155,7 +161,7 @@ class Test_storage_ensure_file_shares_soft_delete_is_enabled: key_expiration_period_in_days=None, location="westeurope", private_endpoint_connections=None, - file_shares=[file_share], + file_service_properties=file_service_properties, ) ] } @@ -180,9 +186,9 @@ class Test_storage_ensure_file_shares_soft_delete_is_enabled: 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." + == f"File share soft delete is enabled for storage account {storage_account_name} with a retention period of {retention_policy.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 + assert result[0].resource_id == file_service_properties.id + assert result[0].location == "westeurope" diff --git a/tests/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled_test.py b/tests/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled_test.py new file mode 100644 index 0000000000..ce17cb80ed --- /dev/null +++ b/tests/providers/azure/services/storage/storage_geo_redundant_enabled/storage_geo_redundant_enabled_test.py @@ -0,0 +1,137 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.storage.storage_service import ( + Account, + ReplicationSettings, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_storage_geo_redundant_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_geo_redundant_enabled.storage_geo_redundant_enabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_geo_redundant_enabled.storage_geo_redundant_enabled import ( + storage_geo_redundant_enabled, + ) + + check = storage_geo_redundant_enabled() + result = check.execute() + assert len(result) == 0 + + def test_storage_geo_redundant_enabled(self): + storage_account_id = str(uuid4()) + storage_account_name = "Test Storage Account GRS" + 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=False, + network_rule_set=None, + encryption_type=None, + minimum_tls_version=None, + key_expiration_period_in_days=None, + location="westeurope", + private_endpoint_connections=None, + replication_settings=ReplicationSettings.STANDARD_GRS, + ) + ] + } + + 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_geo_redundant_enabled.storage_geo_redundant_enabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_geo_redundant_enabled.storage_geo_redundant_enabled import ( + storage_geo_redundant_enabled, + ) + + check = storage_geo_redundant_enabled() + 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 Geo-redundant storage (GRS) 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_geo_redundant_disabled(self): + storage_account_id = str(uuid4()) + storage_account_name = "Test Storage Account LRS" + 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=False, + network_rule_set=None, + encryption_type=None, + minimum_tls_version=None, + key_expiration_period_in_days=None, + location="westeurope", + private_endpoint_connections=None, + replication_settings=ReplicationSettings.STANDARD_LRS, + ) + ] + } + + 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_geo_redundant_enabled.storage_geo_redundant_enabled.storage_client", + new=storage_client, + ), + ): + from prowler.providers.azure.services.storage.storage_geo_redundant_enabled.storage_geo_redundant_enabled import ( + storage_geo_redundant_enabled, + ) + + check = storage_geo_redundant_enabled() + 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} does not have Geo-redundant storage (GRS) 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" diff --git a/tests/providers/azure/services/storage/storage_service_test.py b/tests/providers/azure/services/storage/storage_service_test.py index 09057b0463..a6205b789c 100644 --- a/tests/providers/azure/services/storage/storage_service_test.py +++ b/tests/providers/azure/services/storage/storage_service_test.py @@ -3,7 +3,9 @@ from unittest.mock import patch from prowler.providers.azure.services.storage.storage_service import ( Account, BlobProperties, - FileShare, + DeleteRetentionPolicy, + FileServiceProperties, + ReplicationSettings, Storage, ) from tests.providers.azure.azure_fixtures import ( @@ -20,10 +22,13 @@ 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), - ] + retention_policy = DeleteRetentionPolicy(enabled=True, days=7) + file_service_properties = FileServiceProperties( + id="id", + name="name", + type="type", + share_delete_retention_policy=retention_policy, + ) return { AZURE_SUBSCRIPTION_ID: [ Account( @@ -40,8 +45,11 @@ def mock_storage_get_storage_accounts(_): private_endpoint_connections=None, location="westeurope", blob_properties=blob_properties, + default_to_entra_authorization=True, + replication_settings=ReplicationSettings.STANDARD_LRS, + allow_cross_tenant_replication=True, allow_shared_key_access=True, - file_shares=file_shares, + file_service_properties=file_service_properties, ) ] } @@ -117,6 +125,19 @@ class Test_Storage_Service: default_service_version=None, container_delete_retention_policy=None, ) + assert storage.storage_accounts[AZURE_SUBSCRIPTION_ID][ + 0 + ].default_to_entra_authorization + assert ( + storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0].replication_settings + == ReplicationSettings.STANDARD_LRS + ) + assert ( + storage.storage_accounts[AZURE_SUBSCRIPTION_ID][ + 0 + ].allow_cross_tenant_replication + is True + ) assert ( storage.storage_accounts[AZURE_SUBSCRIPTION_ID][0].allow_shared_key_access is True @@ -155,14 +176,12 @@ class Test_Storage_Service: is None ) - def test_get_file_shares_properties(self): + def test_get_file_service_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 + assert hasattr(account, "file_service_properties") + assert ( + account.file_service_properties.share_delete_retention_policy.enabled + is True + ) + assert account.file_service_properties.share_delete_retention_policy.days == 7 diff --git a/tests/providers/gcp/lib/mutelist/gcp_mutelist_test.py b/tests/providers/gcp/lib/mutelist/gcp_mutelist_test.py index 01d3d1abfa..0285dd44f7 100644 --- a/tests/providers/gcp/lib/mutelist/gcp_mutelist_test.py +++ b/tests/providers/gcp/lib/mutelist/gcp_mutelist_test.py @@ -89,7 +89,7 @@ class TestGCPMutelist: account_uid="project_1", region="test-region", resource_uid="test_resource", - resource_tags=[], + resource_tags={}, muted=False, ) diff --git a/tests/providers/gcp/services/compute/compute_instance_block_project_wide_ssh_keys_disabled/compute_instance_block_project_wide_ssh_keys_disabled_test.py b/tests/providers/gcp/services/compute/compute_instance_block_project_wide_ssh_keys_disabled/compute_instance_block_project_wide_ssh_keys_disabled_test.py index 657e9a6489..8bc01c7eae 100644 --- a/tests/providers/gcp/services/compute/compute_instance_block_project_wide_ssh_keys_disabled/compute_instance_block_project_wide_ssh_keys_disabled_test.py +++ b/tests/providers/gcp/services/compute/compute_instance_block_project_wide_ssh_keys_disabled/compute_instance_block_project_wide_ssh_keys_disabled_test.py @@ -77,6 +77,55 @@ class Test_compute_instance_block_project_wide_ssh_keys_disabled: assert result[0].resource_id == instance.id assert result[0].location == "us-central1" + def test_one_compliant_instance_with_block_project_ssh_keys_true_uppercase(self): + from prowler.providers.gcp.services.compute.compute_service import Instance + + instance = Instance( + name="test", + id="1234567890", + zone="us-central1-a", + region="us-central1", + public_ip=True, + metadata={"items": [{"key": "block-project-ssh-keys", "value": "TRUE"}]}, + shielded_enabled_vtpm=True, + shielded_enabled_integrity_monitoring=True, + confidential_computing=True, + service_accounts=[], + ip_forward=False, + disks_encryption=[("disk1", False), ("disk2", False)], + project_id=GCP_PROJECT_ID, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.instances = [instance] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_instance_block_project_wide_ssh_keys_disabled.compute_instance_block_project_wide_ssh_keys_disabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_instance_block_project_wide_ssh_keys_disabled.compute_instance_block_project_wide_ssh_keys_disabled import ( + compute_instance_block_project_wide_ssh_keys_disabled, + ) + + check = compute_instance_block_project_wide_ssh_keys_disabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert search( + f"The VM Instance {instance.name} is not making use of common/shared project-wide SSH key", + result[0].status_extended, + ) + assert result[0].resource_id == instance.id + assert result[0].location == "us-central1" + def test_one_instance_without_metadata(self): from prowler.providers.gcp.services.compute.compute_service import Instance diff --git a/tests/providers/gcp/services/compute/compute_project_os_login_enabled/compute_project_os_login_enabled_test.py b/tests/providers/gcp/services/compute/compute_project_os_login_enabled/compute_project_os_login_enabled_test.py index abfd4ea361..0dcacf5513 100644 --- a/tests/providers/gcp/services/compute/compute_project_os_login_enabled/compute_project_os_login_enabled_test.py +++ b/tests/providers/gcp/services/compute/compute_project_os_login_enabled/compute_project_os_login_enabled_test.py @@ -128,3 +128,103 @@ class Test_compute_project_os_login_enabled: assert result[0].resource_name == "test" assert result[0].location == "global" assert result[0].project_id == GCP_PROJECT_ID + + def test_one_compliant_project_empty_project_name(self): + from prowler.providers.gcp.services.compute.compute_service import Project + + project = Project( + id=GCP_PROJECT_ID, + enable_oslogin=True, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.compute_projects = [project] + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "global" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_project_os_login_enabled.compute_project_os_login_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_project_os_login_enabled.compute_project_os_login_enabled import ( + compute_project_os_login_enabled, + ) + + check = compute_project_os_login_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert search( + f"Project {project.id} has OS Login enabled", + result[0].status_extended, + ) + assert result[0].resource_id == project.id + assert result[0].resource_name == GCP_PROJECT_ID + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_one_non_compliant_project_empty_project_name(self): + from prowler.providers.gcp.services.compute.compute_service import Project + + project = Project( + id=GCP_PROJECT_ID, + enable_oslogin=False, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.compute_projects = [project] + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "global" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_project_os_login_enabled.compute_project_os_login_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_project_os_login_enabled.compute_project_os_login_enabled import ( + compute_project_os_login_enabled, + ) + + check = compute_project_os_login_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert search( + f"Project {project.id} does not have OS Login enabled", + result[0].status_extended, + ) + assert result[0].resource_id == project.id + assert result[0].resource_name == GCP_PROJECT_ID + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID diff --git a/tests/providers/gcp/services/iam/iam_audit_logs_enabled/iam_audit_logs_enabled_test.py b/tests/providers/gcp/services/iam/iam_audit_logs_enabled/iam_audit_logs_enabled_test.py index 1056b4f055..df1185cac3 100644 --- a/tests/providers/gcp/services/iam/iam_audit_logs_enabled/iam_audit_logs_enabled_test.py +++ b/tests/providers/gcp/services/iam/iam_audit_logs_enabled/iam_audit_logs_enabled_test.py @@ -129,3 +129,103 @@ class Test_iam_audit_logs_enabled: assert r.resource_name == "test" assert r.project_id == GCP_PROJECT_ID assert r.location == cloudresourcemanager_client.region + + def test_compliant_project_empty_project_name(self): + from prowler.providers.gcp.services.cloudresourcemanager.cloudresourcemanager_service import ( + Project, + ) + + project1 = Project(id=GCP_PROJECT_ID, audit_logging=True) + + cloudresourcemanager_client = mock.MagicMock() + cloudresourcemanager_client.project_ids = [GCP_PROJECT_ID] + cloudresourcemanager_client.cloud_resource_manager_projects = [project1] + cloudresourcemanager_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + cloudresourcemanager_client.region = "global" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_audit_logs_enabled.iam_audit_logs_enabled.cloudresourcemanager_client", + new=cloudresourcemanager_client, + ), + ): + from prowler.providers.gcp.services.iam.iam_audit_logs_enabled.iam_audit_logs_enabled import ( + iam_audit_logs_enabled, + ) + + check = iam_audit_logs_enabled() + result = check.execute() + + assert len(result) == 1 + for idx, r in enumerate(result): + assert r.status == "PASS" + assert search( + "Audit Logs are enabled for project", + r.status_extended, + ) + assert r.resource_id == GCP_PROJECT_ID + assert r.resource_name == GCP_PROJECT_ID + assert r.project_id == GCP_PROJECT_ID + assert r.location == cloudresourcemanager_client.region + + def test_uncompliant_project_empty_project_name(self): + from prowler.providers.gcp.services.cloudresourcemanager.cloudresourcemanager_service import ( + Project, + ) + + project1 = Project(id=GCP_PROJECT_ID, audit_logging=False) + + cloudresourcemanager_client = mock.MagicMock() + cloudresourcemanager_client.project_ids = [GCP_PROJECT_ID] + cloudresourcemanager_client.cloud_resource_manager_projects = [project1] + cloudresourcemanager_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + cloudresourcemanager_client.region = "global" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_audit_logs_enabled.iam_audit_logs_enabled.cloudresourcemanager_client", + new=cloudresourcemanager_client, + ), + ): + from prowler.providers.gcp.services.iam.iam_audit_logs_enabled.iam_audit_logs_enabled import ( + iam_audit_logs_enabled, + ) + + check = iam_audit_logs_enabled() + result = check.execute() + + assert len(result) == 1 + for idx, r in enumerate(result): + assert r.status == "FAIL" + assert search( + "Audit Logs are not enabled for project", + r.status_extended, + ) + assert r.resource_id == GCP_PROJECT_ID + assert r.resource_name == GCP_PROJECT_ID + assert r.project_id == GCP_PROJECT_ID + assert r.location == cloudresourcemanager_client.region diff --git a/tests/providers/gcp/services/iam/iam_no_service_roles_at_project_level/iam_no_service_roles_at_project_level_test.py b/tests/providers/gcp/services/iam/iam_no_service_roles_at_project_level/iam_no_service_roles_at_project_level_test.py index ee74496f27..66744d7ef6 100644 --- a/tests/providers/gcp/services/iam/iam_no_service_roles_at_project_level/iam_no_service_roles_at_project_level_test.py +++ b/tests/providers/gcp/services/iam/iam_no_service_roles_at_project_level/iam_no_service_roles_at_project_level_test.py @@ -212,3 +212,49 @@ class Test_iam_no_service_roles_at_project_level: assert result[0].resource_name == binding.role assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == cloudresourcemanager_client.region + + def test_iam_no_bindings_empty_project_name(self): + cloudresourcemanager_client = mock.MagicMock() + cloudresourcemanager_client.bindings = [] + cloudresourcemanager_client.project_ids = [GCP_PROJECT_ID] + cloudresourcemanager_client.region = "global" + cloudresourcemanager_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudresourcemanager.cloudresourcemanager_service.CloudResourceManager", + new=cloudresourcemanager_client, + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_no_service_roles_at_project_level.iam_no_service_roles_at_project_level.cloudresourcemanager_client", + new=cloudresourcemanager_client, + ), + ): + from prowler.providers.gcp.services.iam.iam_no_service_roles_at_project_level.iam_no_service_roles_at_project_level import ( + iam_no_service_roles_at_project_level, + ) + + check = iam_no_service_roles_at_project_level() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert search( + "No IAM Users assigned to service roles at project level", + result[0].status_extended, + ) + assert result[0].resource_id == GCP_PROJECT_ID + assert result[0].resource_name == GCP_PROJECT_ID + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == cloudresourcemanager_client.region diff --git a/tests/providers/gcp/services/iam/iam_role_kms_enforce_separation_of_duties/iam_role_kms_enforce_separation_of_duties_test.py b/tests/providers/gcp/services/iam/iam_role_kms_enforce_separation_of_duties/iam_role_kms_enforce_separation_of_duties_test.py index 6354340e52..95948eb8a6 100644 --- a/tests/providers/gcp/services/iam/iam_role_kms_enforce_separation_of_duties/iam_role_kms_enforce_separation_of_duties_test.py +++ b/tests/providers/gcp/services/iam/iam_role_kms_enforce_separation_of_duties/iam_role_kms_enforce_separation_of_duties_test.py @@ -173,3 +173,110 @@ class Test_iam_role_kms_enforce_separation_of_duties: assert r.resource_id == GCP_PROJECT_ID assert r.project_id == GCP_PROJECT_ID assert r.location == cloudresourcemanager_client.region + + def test_iam_no_bindings_empty_project_name(self): + cloudresourcemanager_client = mock.MagicMock() + cloudresourcemanager_client.bindings = [] + cloudresourcemanager_client.project_ids = [GCP_PROJECT_ID] + cloudresourcemanager_client.region = "global" + cloudresourcemanager_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_role_kms_enforce_separation_of_duties.iam_role_kms_enforce_separation_of_duties.cloudresourcemanager_client", + new=cloudresourcemanager_client, + ), + ): + from prowler.providers.gcp.services.iam.iam_role_kms_enforce_separation_of_duties.iam_role_kms_enforce_separation_of_duties import ( + iam_role_kms_enforce_separation_of_duties, + ) + + check = iam_role_kms_enforce_separation_of_duties() + result = check.execute() + assert len(result) == 1 + for idx, r in enumerate(result): + assert r.status == "PASS" + assert search( + "Principle of separation of duties was enforced for KMS-Related Roles", + r.status_extended, + ) + assert r.resource_id == GCP_PROJECT_ID + assert r.resource_name == GCP_PROJECT_ID + assert r.project_id == GCP_PROJECT_ID + assert r.location == cloudresourcemanager_client.region + + def test_uncompliant_binding_empty_project_name(self): + from prowler.providers.gcp.services.cloudresourcemanager.cloudresourcemanager_service import ( + Binding, + ) + + binding1 = Binding( + role="roles/cloudkms.admin", + members=["serviceAccount:685829395199@cloudbuild.gserviceaccount.com"], + project_id=GCP_PROJECT_ID, + ) + binding2 = Binding( + role="roles/cloudkms.cryptoKeyEncrypterDecrypter", + members=["serviceAccount:685829395199@cloudbuild.gserviceaccount.com"], + project_id=GCP_PROJECT_ID, + ) + binding3 = Binding( + role="roles/connectors.managedZoneViewer", + members=["serviceAccount:685829395199@cloudbuild.gserviceaccount.com"], + project_id=GCP_PROJECT_ID, + ) + + cloudresourcemanager_client = mock.MagicMock() + cloudresourcemanager_client.project_ids = [GCP_PROJECT_ID] + cloudresourcemanager_client.bindings = [binding1, binding2, binding3] + cloudresourcemanager_client.region = "global" + cloudresourcemanager_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_role_kms_enforce_separation_of_duties.iam_role_kms_enforce_separation_of_duties.cloudresourcemanager_client", + new=cloudresourcemanager_client, + ), + ): + from prowler.providers.gcp.services.iam.iam_role_kms_enforce_separation_of_duties.iam_role_kms_enforce_separation_of_duties import ( + iam_role_kms_enforce_separation_of_duties, + ) + + check = iam_role_kms_enforce_separation_of_duties() + result = check.execute() + + assert len(result) == 1 + for idx, r in enumerate(result): + assert r.status == "FAIL" + assert search( + "Principle of separation of duties was not enforced for KMS-Related Roles", + r.status_extended, + ) + assert r.resource_id == GCP_PROJECT_ID + assert r.resource_name == GCP_PROJECT_ID + assert r.project_id == GCP_PROJECT_ID + assert r.location == cloudresourcemanager_client.region diff --git a/tests/providers/gcp/services/iam/iam_role_sa_enforce_separation_of_duties/iam_role_sa_enforce_separation_of_duties_test.py b/tests/providers/gcp/services/iam/iam_role_sa_enforce_separation_of_duties/iam_role_sa_enforce_separation_of_duties_test.py index 479f540889..5672fc78ad 100644 --- a/tests/providers/gcp/services/iam/iam_role_sa_enforce_separation_of_duties/iam_role_sa_enforce_separation_of_duties_test.py +++ b/tests/providers/gcp/services/iam/iam_role_sa_enforce_separation_of_duties/iam_role_sa_enforce_separation_of_duties_test.py @@ -173,3 +173,110 @@ class Test_iam_role_sa_enforce_separation_of_duties: assert r.resource_id == GCP_PROJECT_ID assert r.project_id == GCP_PROJECT_ID assert r.location == cloudresourcemanager_client.region + + def test_iam_no_bindings_empty_project_name(self): + cloudresourcemanager_client = mock.MagicMock() + cloudresourcemanager_client.bindings = [] + cloudresourcemanager_client.project_ids = [GCP_PROJECT_ID] + cloudresourcemanager_client.region = "global" + cloudresourcemanager_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_role_sa_enforce_separation_of_duties.iam_role_sa_enforce_separation_of_duties.cloudresourcemanager_client", + new=cloudresourcemanager_client, + ), + ): + from prowler.providers.gcp.services.iam.iam_role_sa_enforce_separation_of_duties.iam_role_sa_enforce_separation_of_duties import ( + iam_role_sa_enforce_separation_of_duties, + ) + + check = iam_role_sa_enforce_separation_of_duties() + result = check.execute() + assert len(result) == 1 + for idx, r in enumerate(result): + assert r.status == "PASS" + assert search( + "Principle of separation of duties was enforced for Service-Account Related Roles", + r.status_extended, + ) + assert r.resource_id == GCP_PROJECT_ID + assert r.resource_name == GCP_PROJECT_ID + assert r.project_id == GCP_PROJECT_ID + assert r.location == cloudresourcemanager_client.region + + def test_one_uncompliant_binding_empty_project_name(self): + from prowler.providers.gcp.services.cloudresourcemanager.cloudresourcemanager_service import ( + Binding, + ) + + binding1 = Binding( + role="roles/iam.serviceAccountUser", + members=["serviceAccount:685829395199@cloudbuild.gserviceaccount.com"], + project_id=GCP_PROJECT_ID, + ) + binding2 = Binding( + role="roles/compute.serviceAgent", + members=["serviceAccount:685829395199@cloudbuild.gserviceaccount.com"], + project_id=GCP_PROJECT_ID, + ) + binding3 = Binding( + role="roles/connectors.managedZoneViewer", + members=["serviceAccount:685829395199@cloudbuild.gserviceaccount.com"], + project_id=GCP_PROJECT_ID, + ) + + cloudresourcemanager_client = mock.MagicMock() + cloudresourcemanager_client.project_ids = [GCP_PROJECT_ID] + cloudresourcemanager_client.bindings = [binding1, binding2, binding3] + cloudresourcemanager_client.region = "global" + cloudresourcemanager_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.iam.iam_role_sa_enforce_separation_of_duties.iam_role_sa_enforce_separation_of_duties.cloudresourcemanager_client", + new=cloudresourcemanager_client, + ), + ): + from prowler.providers.gcp.services.iam.iam_role_sa_enforce_separation_of_duties.iam_role_sa_enforce_separation_of_duties import ( + iam_role_sa_enforce_separation_of_duties, + ) + + check = iam_role_sa_enforce_separation_of_duties() + result = check.execute() + + assert len(result) == 1 + for idx, r in enumerate(result): + assert r.status == "FAIL" + assert search( + "Principle of separation of duties was not enforced for Service-Account Related Roles", + r.status_extended, + ) + assert r.resource_id == GCP_PROJECT_ID + assert r.resource_name == GCP_PROJECT_ID + assert r.project_id == GCP_PROJECT_ID + assert r.location == cloudresourcemanager_client.region diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py index 7dd0a4ca56..99fbd43d5e 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled_test.py @@ -93,6 +93,58 @@ class Test_logging_log_metric_filter_and_alert_for_audit_configuration_changes_e assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + def test_no_log_metric_filters_no_alerts_one_project_empty_name(self): + logging_client = MagicMock() + monitoring_client = MagicMock() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled, + ) + + logging_client.metrics = [] + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + monitoring_client.alert_policies = [] + + check = ( + logging_log_metric_filter_and_alert_for_audit_configuration_changes_enabled() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"There are no log metric filters or alerts associated in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == GCP_PROJECT_ID + assert result[0].resource_name == GCP_PROJECT_ID + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_EU1_LOCATION + def test_log_metric_filters_no_alerts(self): logging_client = MagicMock() monitoring_client = MagicMock() diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py index 17cf202c76..3faf9c6309 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled/logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled_test.py @@ -93,6 +93,58 @@ class Test_logging_log_metric_filter_and_alert_for_bucket_permission_changes_ena assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + def test_no_log_metric_filters_no_alerts_one_project_empty_name(self): + logging_client = MagicMock() + monitoring_client = MagicMock() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled.logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled import ( + logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled, + ) + + logging_client.metrics = [] + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + monitoring_client.alert_policies = [] + + check = ( + logging_log_metric_filter_and_alert_for_bucket_permission_changes_enabled() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"There are no log metric filters or alerts associated in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == GCP_PROJECT_ID + assert result[0].resource_name == GCP_PROJECT_ID + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_EU1_LOCATION + def test_log_metric_filters_no_alerts(self): logging_client = MagicMock() monitoring_client = MagicMock() diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py index d08cabcfb3..fe4ca5e344 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled/logging_log_metric_filter_and_alert_for_custom_role_changes_enabled_test.py @@ -93,6 +93,58 @@ class Test_logging_log_metric_filter_and_alert_for_custom_role_changes_enabled: assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + def test_no_log_metric_filters_no_alerts_one_project_empty_name(self): + logging_client = MagicMock() + monitoring_client = MagicMock() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled.logging_log_metric_filter_and_alert_for_custom_role_changes_enabled import ( + logging_log_metric_filter_and_alert_for_custom_role_changes_enabled, + ) + + logging_client.metrics = [] + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + monitoring_client.alert_policies = [] + + check = ( + logging_log_metric_filter_and_alert_for_custom_role_changes_enabled() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"There are no log metric filters or alerts associated in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == GCP_PROJECT_ID + assert result[0].resource_name == GCP_PROJECT_ID + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_EU1_LOCATION + def test_log_metric_filters_no_alerts(self): logging_client = MagicMock() monitoring_client = MagicMock() diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py index 6687c5d3bd..a3279bca0d 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled/logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled_test.py @@ -93,6 +93,58 @@ class Test_logging_log_metric_filter_and_alert_for_project_ownership_changes_ena assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + def test_no_log_metric_filters_no_alerts_one_project_empty_name(self): + logging_client = MagicMock() + monitoring_client = MagicMock() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled.logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled import ( + logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled, + ) + + logging_client.metrics = [] + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + monitoring_client.alert_policies = [] + + check = ( + logging_log_metric_filter_and_alert_for_project_ownership_changes_enabled() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"There are no log metric filters or alerts associated in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == GCP_PROJECT_ID + assert result[0].resource_name == GCP_PROJECT_ID + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_EU1_LOCATION + def test_log_metric_filters_no_alerts(self): logging_client = MagicMock() monitoring_client = MagicMock() diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py index d4bdfaa579..18562913de 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled/logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled_test.py @@ -93,6 +93,58 @@ class Test_logging_log_metric_filter_and_alert_for_sql_instance_configuration_ch assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + def test_no_log_metric_filters_no_alerts_one_project_empty_name(self): + logging_client = MagicMock() + monitoring_client = MagicMock() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled.logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled import ( + logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled, + ) + + logging_client.metrics = [] + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + monitoring_client.alert_policies = [] + + check = ( + logging_log_metric_filter_and_alert_for_sql_instance_configuration_changes_enabled() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"There are no log metric filters or alerts associated in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == GCP_PROJECT_ID + assert result[0].resource_name == GCP_PROJECT_ID + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_EU1_LOCATION + def test_log_metric_filters_no_alerts(self): logging_client = MagicMock() monitoring_client = MagicMock() diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py index cc8dfc6338..ce773d041d 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled_test.py @@ -93,6 +93,58 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_ena assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + def test_no_log_metric_filters_no_alerts_one_project_empty_name(self): + logging_client = MagicMock() + monitoring_client = MagicMock() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled, + ) + + logging_client.metrics = [] + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + monitoring_client.alert_policies = [] + + check = ( + logging_log_metric_filter_and_alert_for_vpc_firewall_rule_changes_enabled() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"There are no log metric filters or alerts associated in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == GCP_PROJECT_ID + assert result[0].resource_name == GCP_PROJECT_ID + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_EU1_LOCATION + def test_log_metric_filters_no_alerts(self): logging_client = MagicMock() monitoring_client = MagicMock() diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py index d9a166f33e..e97aba2ec3 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled_test.py @@ -93,6 +93,58 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled: assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + def test_no_log_metric_filters_no_alerts_one_project_empty_name(self): + logging_client = MagicMock() + monitoring_client = MagicMock() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled, + ) + + logging_client.metrics = [] + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + monitoring_client.alert_policies = [] + + check = ( + logging_log_metric_filter_and_alert_for_vpc_network_changes_enabled() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"There are no log metric filters or alerts associated in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == GCP_PROJECT_ID + assert result[0].resource_name == GCP_PROJECT_ID + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_EU1_LOCATION + def test_log_metric_filters_no_alerts(self): logging_client = MagicMock() monitoring_client = MagicMock() diff --git a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py index f96835238c..1f413a03f2 100644 --- a/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py +++ b/tests/providers/gcp/services/logging/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled/logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled_test.py @@ -93,6 +93,58 @@ class Test_logging_log_metric_filter_and_alert_for_vpc_network_route_changes_ena assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + def test_no_log_metric_filters_no_alerts_one_project_empty_name(self): + logging_client = MagicMock() + monitoring_client = MagicMock() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_client", + new=logging_client, + ), + patch( + "prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.monitoring_client", + new=monitoring_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled.logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled import ( + logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled, + ) + + logging_client.metrics = [] + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + monitoring_client.alert_policies = [] + + check = ( + logging_log_metric_filter_and_alert_for_vpc_network_route_changes_enabled() + ) + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"There are no log metric filters or alerts associated in project {GCP_PROJECT_ID}." + ) + assert result[0].resource_id == GCP_PROJECT_ID + assert result[0].resource_name == GCP_PROJECT_ID + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_EU1_LOCATION + def test_log_metric_filters_no_alerts(self): logging_client = MagicMock() monitoring_client = MagicMock() diff --git a/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py b/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py index 9392efb625..f13b19d0cf 100644 --- a/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py +++ b/tests/providers/gcp/services/logging/logging_sink_created/logging_sink_created_test.py @@ -168,3 +168,46 @@ class Test_logging_sink_created: assert result[0].resource_name == "test" assert result[0].project_id == GCP_PROJECT_ID assert result[0].location == GCP_EU1_LOCATION + + def test_no_sinks_empty_project_name(self): + logging_client = MagicMock() + + with ( + patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + patch( + "prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created.logging_client", + new=logging_client, + ), + ): + from prowler.providers.gcp.services.logging.logging_sink_created.logging_sink_created import ( + logging_sink_created, + ) + + logging_client.project_ids = [GCP_PROJECT_ID] + logging_client.region = GCP_EU1_LOCATION + logging_client.sinks = [] + logging_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + + check = logging_sink_created() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == GCP_PROJECT_ID + assert result[0].resource_name == GCP_PROJECT_ID + assert result[0].project_id == GCP_PROJECT_ID + assert result[0].location == GCP_EU1_LOCATION + assert ( + result[0].status_extended + == f"There are no logging sinks to export copies of all the log entries in project {GCP_PROJECT_ID}." + ) diff --git a/tests/providers/iac/iac_fixtures.py b/tests/providers/iac/iac_fixtures.py new file mode 100644 index 0000000000..877d843939 --- /dev/null +++ b/tests/providers/iac/iac_fixtures.py @@ -0,0 +1,257 @@ +from checkov.common.models.enums import CheckResult +from checkov.common.output.record import Record +from checkov.common.output.report import Report + +# IAC Provider Constants +DEFAULT_SCAN_PATH = "." + +# Sample Finding Data +SAMPLE_FINDING = Report(check_type="terraform") +SAMPLE_FAILED_CHECK = Record( + check_id="CKV_AWS_1", + check_name="Ensure S3 bucket has encryption enabled", + severity="low", + file_path="test.tf", + file_line_range=[1, 2], + resource="aws_s3_bucket.test_bucket", + evaluations=[], + check_class="terraform", + check_result=CheckResult.FAILED, + code_block=[], + file_abs_path="test.tf", +) +SAMPLE_FAILED_CHECK.guideline = ( + "https://docs.bridgecrew.io/docs/s3_1-s3-bucket-has-encryption-enabled" +) + +SAMPLE_PASSED_CHECK = Record( + check_id="CKV_AWS_3", + check_name="Ensure S3 bucket has versioning enabled", + severity="low", + file_path="test.tf", + file_line_range=[1, 2], + resource="aws_s3_bucket.test_bucket", + evaluations=[], + check_class="terraform", + check_result=CheckResult.PASSED, + code_block=[], + file_abs_path="test.tf", +) +SAMPLE_PASSED_CHECK.guideline = ( + "https://docs.bridgecrew.io/docs/s3_3-s3-bucket-has-versioning-enabled" +) + +# Additional test fixtures for comprehensive testing +SAMPLE_SKIPPED_CHECK = Record( + check_id="CKV_AWS_2", + check_name="Ensure S3 bucket has public access blocked", + severity="high", + file_path="test.tf", + file_line_range=[3, 4], + resource="aws_s3_bucket.test_bucket", + evaluations=[], + check_class="terraform", + check_result=CheckResult.SKIPPED, + code_block=[], + file_abs_path="test.tf", +) +SAMPLE_SKIPPED_CHECK.guideline = ( + "https://docs.bridgecrew.io/docs/s3_2-s3-bucket-has-public-access-blocked" +) + +SAMPLE_HIGH_SEVERITY_CHECK = Record( + check_id="CKV_AWS_4", + check_name="Ensure S3 bucket has logging enabled", + severity="HIGH", + file_path="test.tf", + file_line_range=[5, 6], + resource="aws_s3_bucket.test_bucket", + evaluations=[], + check_class="terraform", + check_result=CheckResult.FAILED, + code_block=[], + file_abs_path="test.tf", +) +SAMPLE_HIGH_SEVERITY_CHECK.guideline = ( + "https://docs.bridgecrew.io/docs/s3_4-s3-bucket-has-logging-enabled" +) + +SAMPLE_KUBERNETES_CHECK = Record( + check_id="CKV_K8S_1", + check_name="Ensure API server has audit logging enabled", + severity="medium", + file_path="deployment.yaml", + file_line_range=[1, 10], + resource="kubernetes_deployment.test_deployment", + evaluations=[], + check_class="kubernetes", + check_result=CheckResult.FAILED, + code_block=[], + file_abs_path="deployment.yaml", +) +SAMPLE_KUBERNETES_CHECK.guideline = ( + "https://docs.bridgecrew.io/docs/k8s_1-api-server-has-audit-logging-enabled" +) + +SAMPLE_CLOUDFORMATION_CHECK = Record( + check_id="CKV_AWS_5", + check_name="Ensure CloudFormation stacks are not publicly accessible", + severity="critical", + file_path="template.yaml", + file_line_range=[1, 20], + resource="AWS::CloudFormation::Stack", + evaluations=[], + check_class="cloudformation", + check_result=CheckResult.PASSED, + code_block=[], + file_abs_path="template.yaml", +) +SAMPLE_CLOUDFORMATION_CHECK.guideline = "https://docs.bridgecrew.io/docs/cfn_1-cloudformation-stacks-are-not-publicly-accessible" + +# Sample findings for different frameworks +SAMPLE_KUBERNETES_FINDING = Report(check_type="kubernetes") +SAMPLE_CLOUDFORMATION_FINDING = Report(check_type="cloudformation") + +# Additional fixtures for different test scenarios +SAMPLE_CHECK_WITHOUT_GUIDELINE = Record( + check_id="CKV_AWS_6", + check_name="Test check without guideline", + severity="low", + file_path="test.tf", + file_line_range=[1, 2], + resource="aws_s3_bucket.test_bucket", + evaluations=[], + check_class="terraform", + check_result=CheckResult.FAILED, + code_block=[], + file_abs_path="test.tf", +) +# Note: No guideline attribute set + +SAMPLE_MEDIUM_SEVERITY_CHECK = Record( + check_id="CKV_AWS_7", + check_name="Ensure S3 bucket has proper access controls", + severity="MEDIUM", + file_path="test.tf", + file_line_range=[7, 8], + resource="aws_s3_bucket.test_bucket", + evaluations=[], + check_class="terraform", + check_result=CheckResult.FAILED, + code_block=[], + file_abs_path="test.tf", +) +SAMPLE_MEDIUM_SEVERITY_CHECK.guideline = ( + "https://docs.bridgecrew.io/docs/s3_7-s3-bucket-has-proper-access-controls" +) + +SAMPLE_CRITICAL_SEVERITY_CHECK = Record( + check_id="CKV_AWS_8", + check_name="Ensure S3 bucket has encryption at rest", + severity="CRITICAL", + file_path="test.tf", + file_line_range=[9, 10], + resource="aws_s3_bucket.test_bucket", + evaluations=[], + check_class="terraform", + check_result=CheckResult.FAILED, + code_block=[], + file_abs_path="test.tf", +) +SAMPLE_CRITICAL_SEVERITY_CHECK.guideline = ( + "https://docs.bridgecrew.io/docs/s3_8-s3-bucket-has-encryption-at-rest" +) + +# Sample reports for different frameworks +SAMPLE_TERRAFORM_REPORT = Report(check_type="terraform") +SAMPLE_KUBERNETES_REPORT = Report(check_type="kubernetes") +SAMPLE_CLOUDFORMATION_REPORT = Report(check_type="cloudformation") +SAMPLE_DOCKERFILE_REPORT = Report(check_type="dockerfile") +SAMPLE_YAML_REPORT = Report(check_type="yaml") + +# Sample checks for different frameworks +SAMPLE_DOCKERFILE_CHECK = Record( + check_id="CKV_DOCKER_1", + check_name="Ensure base image is not using latest tag", + severity="high", + file_path="Dockerfile", + file_line_range=[1, 1], + resource="Dockerfile", + evaluations=[], + check_class="dockerfile", + check_result=CheckResult.FAILED, + code_block=[], + file_abs_path="Dockerfile", +) +SAMPLE_DOCKERFILE_CHECK.guideline = ( + "https://docs.bridgecrew.io/docs/docker_1-base-image-not-using-latest-tag" +) + +SAMPLE_YAML_CHECK = Record( + check_id="CKV_YAML_1", + check_name="Ensure YAML file has proper indentation", + severity="low", + file_path="config.yaml", + file_line_range=[1, 5], + resource="config.yaml", + evaluations=[], + check_class="yaml", + check_result=CheckResult.PASSED, + code_block=[], + file_abs_path="config.yaml", +) +SAMPLE_YAML_CHECK.guideline = ( + "https://docs.bridgecrew.io/docs/yaml_1-proper-indentation" +) + +# Sample checks with different statuses for comprehensive testing +SAMPLE_ANOTHER_FAILED_CHECK = Record( + check_id="CKV_AWS_9", + check_name="Ensure S3 bucket has lifecycle policy", + severity="medium", + file_path="test.tf", + file_line_range=[11, 12], + resource="aws_s3_bucket.test_bucket", + evaluations=[], + check_class="terraform", + check_result=CheckResult.FAILED, + code_block=[], + file_abs_path="test.tf", +) +SAMPLE_ANOTHER_FAILED_CHECK.guideline = ( + "https://docs.bridgecrew.io/docs/s3_9-s3-bucket-has-lifecycle-policy" +) + +SAMPLE_ANOTHER_PASSED_CHECK = Record( + check_id="CKV_AWS_10", + check_name="Ensure S3 bucket has proper tags", + severity="low", + file_path="test.tf", + file_line_range=[13, 14], + resource="aws_s3_bucket.test_bucket", + evaluations=[], + check_class="terraform", + check_result=CheckResult.PASSED, + code_block=[], + file_abs_path="test.tf", +) +SAMPLE_ANOTHER_PASSED_CHECK.guideline = ( + "https://docs.bridgecrew.io/docs/s3_10-s3-bucket-has-proper-tags" +) + +SAMPLE_ANOTHER_SKIPPED_CHECK = Record( + check_id="CKV_AWS_11", + check_name="Ensure S3 bucket has cross-region replication", + severity="high", + file_path="test.tf", + file_line_range=[15, 16], + resource="aws_s3_bucket.test_bucket", + evaluations=[], + check_class="terraform", + check_result=CheckResult.SKIPPED, + code_block=[], + file_abs_path="test.tf", +) +SAMPLE_ANOTHER_SKIPPED_CHECK.guideline = ( + "https://docs.bridgecrew.io/docs/s3_11-s3-bucket-has-cross-region-replication" +) diff --git a/tests/providers/iac/iac_provider_test.py b/tests/providers/iac/iac_provider_test.py new file mode 100644 index 0000000000..f3da2a7c11 --- /dev/null +++ b/tests/providers/iac/iac_provider_test.py @@ -0,0 +1,545 @@ +from unittest.mock import Mock, patch + +import pytest + +from prowler.lib.check.models import CheckReportIAC +from prowler.providers.iac.iac_provider import IacProvider +from tests.providers.iac.iac_fixtures import ( + DEFAULT_SCAN_PATH, + SAMPLE_ANOTHER_FAILED_CHECK, + SAMPLE_ANOTHER_PASSED_CHECK, + SAMPLE_ANOTHER_SKIPPED_CHECK, + SAMPLE_CHECK_WITHOUT_GUIDELINE, + SAMPLE_CLOUDFORMATION_CHECK, + SAMPLE_CRITICAL_SEVERITY_CHECK, + SAMPLE_DOCKERFILE_CHECK, + SAMPLE_DOCKERFILE_REPORT, + SAMPLE_FAILED_CHECK, + SAMPLE_FINDING, + SAMPLE_HIGH_SEVERITY_CHECK, + SAMPLE_KUBERNETES_CHECK, + SAMPLE_KUBERNETES_FINDING, + SAMPLE_MEDIUM_SEVERITY_CHECK, + SAMPLE_PASSED_CHECK, + SAMPLE_SKIPPED_CHECK, + SAMPLE_YAML_CHECK, + SAMPLE_YAML_REPORT, +) + + +class TestIacProvider: + def test_iac_provider(self): + """Test IAC provider with default parameters""" + + provider = IacProvider() + + assert provider._type == "iac" + assert provider.type == "iac" + assert provider.scan_path == DEFAULT_SCAN_PATH + assert provider._audit_config == {} + assert provider._mutelist is None + + def test_iac_provider_custom_scan_path(self): + """Test IAC provider with custom scan path""" + custom_path = "/custom/path" + + provider = IacProvider(scan_path=custom_path) + + assert provider._type == "iac" + assert provider.scan_path == custom_path + + def test_iac_provider_process_check_failed(self): + """Test processing a failed check""" + provider = IacProvider() + + report = provider._process_check(SAMPLE_FINDING, SAMPLE_FAILED_CHECK, "FAIL") + + assert isinstance(report, CheckReportIAC) + assert report.status == "FAIL" + + assert report.check_metadata.Provider == "iac" + assert report.check_metadata.CheckID == SAMPLE_FAILED_CHECK.check_id + assert report.check_metadata.CheckTitle == SAMPLE_FAILED_CHECK.check_name + assert report.check_metadata.Severity == "low" + assert report.check_metadata.RelatedUrl == SAMPLE_FAILED_CHECK.guideline + + def test_iac_provider_process_check_passed(self): + """Test processing a passed check""" + provider = IacProvider() + + report = provider._process_check(SAMPLE_FINDING, SAMPLE_PASSED_CHECK, "PASS") + + assert isinstance(report, CheckReportIAC) + assert report.status == "PASS" + + assert report.check_metadata.Provider == "iac" + assert report.check_metadata.CheckID == SAMPLE_PASSED_CHECK.check_id + assert report.check_metadata.CheckTitle == SAMPLE_PASSED_CHECK.check_name + assert report.check_metadata.Severity == "low" + assert report.check_metadata.RelatedUrl == SAMPLE_PASSED_CHECK.guideline + + def test_iac_provider_process_check_skipped(self): + """Test processing a skipped check""" + provider = IacProvider() + + report = provider._process_check(SAMPLE_FINDING, SAMPLE_SKIPPED_CHECK, "MUTED") + + assert isinstance(report, CheckReportIAC) + assert report.status == "MUTED" + assert report.muted is True + + assert report.check_metadata.Provider == "iac" + assert report.check_metadata.CheckID == SAMPLE_SKIPPED_CHECK.check_id + assert report.check_metadata.CheckTitle == SAMPLE_SKIPPED_CHECK.check_name + assert report.check_metadata.Severity == "high" + assert report.check_metadata.RelatedUrl == SAMPLE_SKIPPED_CHECK.guideline + + def test_iac_provider_process_check_high_severity(self): + """Test processing a high severity check""" + provider = IacProvider() + + report = provider._process_check( + SAMPLE_FINDING, SAMPLE_HIGH_SEVERITY_CHECK, "FAIL" + ) + + assert isinstance(report, CheckReportIAC) + assert report.status == "FAIL" + assert report.check_metadata.Severity == "high" + + def test_iac_provider_process_check_different_framework(self): + """Test processing a check from a different framework (Kubernetes)""" + provider = IacProvider() + + report = provider._process_check( + SAMPLE_KUBERNETES_FINDING, SAMPLE_KUBERNETES_CHECK, "FAIL" + ) + + assert isinstance(report, CheckReportIAC) + assert report.status == "FAIL" + assert report.check_metadata.ServiceName == "kubernetes" + assert report.check_metadata.CheckID == SAMPLE_KUBERNETES_CHECK.check_id + + def test_iac_provider_process_check_no_guideline(self): + """Test processing a check without guideline URL""" + provider = IacProvider() + + report = provider._process_check( + SAMPLE_FINDING, SAMPLE_CHECK_WITHOUT_GUIDELINE, "FAIL" + ) + + assert isinstance(report, CheckReportIAC) + assert report.status == "FAIL" + assert report.check_metadata.RelatedUrl == "" + + def test_iac_provider_process_check_medium_severity(self): + """Test processing a medium severity check""" + provider = IacProvider() + + report = provider._process_check( + SAMPLE_FINDING, SAMPLE_MEDIUM_SEVERITY_CHECK, "FAIL" + ) + + assert isinstance(report, CheckReportIAC) + assert report.status == "FAIL" + assert report.check_metadata.Severity == "medium" + + def test_iac_provider_process_check_critical_severity(self): + """Test processing a critical severity check""" + provider = IacProvider() + + report = provider._process_check( + SAMPLE_FINDING, SAMPLE_CRITICAL_SEVERITY_CHECK, "FAIL" + ) + + assert isinstance(report, CheckReportIAC) + assert report.status == "FAIL" + assert report.check_metadata.Severity == "critical" + + def test_iac_provider_process_check_dockerfile(self): + """Test processing a Dockerfile check""" + provider = IacProvider() + + report = provider._process_check( + SAMPLE_DOCKERFILE_REPORT, SAMPLE_DOCKERFILE_CHECK, "FAIL" + ) + + assert isinstance(report, CheckReportIAC) + assert report.status == "FAIL" + assert report.check_metadata.ServiceName == "dockerfile" + assert report.check_metadata.CheckID == SAMPLE_DOCKERFILE_CHECK.check_id + + def test_iac_provider_process_check_yaml(self): + """Test processing a YAML check""" + provider = IacProvider() + + report = provider._process_check(SAMPLE_YAML_REPORT, SAMPLE_YAML_CHECK, "PASS") + + assert isinstance(report, CheckReportIAC) + assert report.status == "PASS" + assert report.check_metadata.ServiceName == "yaml" + assert report.check_metadata.CheckID == SAMPLE_YAML_CHECK.check_id + + @patch("prowler.providers.iac.iac_provider.RunnerRegistry") + @patch("prowler.providers.iac.iac_provider.RunnerFilter") + @patch("prowler.providers.iac.iac_provider.logger") + def test_run_scan_success_with_failed_and_passed_checks( + self, mock_logger, mock_runner_filter, mock_runner_registry + ): + """Test successful run_scan with both failed and passed checks""" + # Setup mocks + mock_registry_instance = Mock() + mock_runner_registry.return_value = mock_registry_instance + + # Create mock reports with failed and passed checks + mock_report = Mock() + mock_report.check_type = "terraform" # Set the check_type attribute + mock_report.failed_checks = [SAMPLE_FAILED_CHECK] + mock_report.passed_checks = [SAMPLE_PASSED_CHECK] + mock_report.skipped_checks = [] + + mock_registry_instance.run.return_value = [mock_report] + + provider = IacProvider() + result = provider.run_scan("/test/directory", ["terraform"], []) + + # Verify logger was called + mock_logger.info.assert_called_with("Running IaC scan on /test/directory...") + + # Verify RunnerFilter was created with correct parameters + mock_runner_filter.assert_called_with( + framework=["terraform"], excluded_paths=[] + ) + + # Verify RunnerRegistry was created and run was called + mock_runner_registry.assert_called_once() + mock_registry_instance.run.assert_called_with(root_folder="/test/directory") + + # Verify results + assert len(result) == 2 + assert all(isinstance(report, CheckReportIAC) for report in result) + + # Check that we have one FAIL and one PASS report + statuses = [report.status for report in result] + assert "FAIL" in statuses + assert "PASS" in statuses + + @patch("prowler.providers.iac.iac_provider.RunnerRegistry") + @patch("prowler.providers.iac.iac_provider.RunnerFilter") + @patch("prowler.providers.iac.iac_provider.logger") + def test_run_scan_with_skipped_checks( + self, mock_logger, mock_runner_filter, mock_runner_registry + ): + """Test run_scan with skipped checks (muted)""" + # Setup mocks + mock_registry_instance = Mock() + mock_runner_registry.return_value = mock_registry_instance + + # Create mock report with skipped checks + mock_report = Mock() + mock_report.check_type = "terraform" # Set the check_type attribute + mock_report.failed_checks = [] + mock_report.passed_checks = [] + mock_report.skipped_checks = [SAMPLE_SKIPPED_CHECK] + + mock_registry_instance.run.return_value = [mock_report] + + provider = IacProvider() + result = provider.run_scan("/test/directory", ["all"], ["exclude/path"]) + + # Verify RunnerFilter was created with correct parameters + mock_runner_filter.assert_called_with( + framework=["all"], excluded_paths=["exclude/path"] + ) + + # Verify results + assert len(result) == 1 + assert isinstance(result[0], CheckReportIAC) + assert result[0].status == "MUTED" + assert result[0].muted is True + + @patch("prowler.providers.iac.iac_provider.RunnerRegistry") + @patch("prowler.providers.iac.iac_provider.RunnerFilter") + @patch("prowler.providers.iac.iac_provider.logger") + def test_run_scan_empty_results( + self, mock_logger, mock_runner_filter, mock_runner_registry + ): + """Test run_scan with no findings""" + # Setup mocks + mock_registry_instance = Mock() + mock_runner_registry.return_value = mock_registry_instance + + # Create mock report with no checks + mock_report = Mock() + mock_report.check_type = "terraform" # Set the check_type attribute + mock_report.failed_checks = [] + mock_report.passed_checks = [] + mock_report.skipped_checks = [] + + mock_registry_instance.run.return_value = [mock_report] + + provider = IacProvider() + result = provider.run_scan("/test/directory", ["kubernetes"], []) + + # Verify results + assert len(result) == 0 + + @patch("prowler.providers.iac.iac_provider.RunnerRegistry") + @patch("prowler.providers.iac.iac_provider.RunnerFilter") + @patch("prowler.providers.iac.iac_provider.logger") + def test_run_scan_multiple_reports( + self, mock_logger, mock_runner_filter, mock_runner_registry + ): + """Test run_scan with multiple reports from different frameworks""" + # Setup mocks + mock_registry_instance = Mock() + mock_runner_registry.return_value = mock_registry_instance + + # Create multiple mock reports + mock_report1 = Mock() + mock_report1.check_type = "terraform" # Set the check_type attribute + mock_report1.failed_checks = [SAMPLE_FAILED_CHECK] + mock_report1.passed_checks = [] + mock_report1.skipped_checks = [] + + mock_report2 = Mock() + mock_report2.check_type = "kubernetes" # Set the check_type attribute + mock_report2.failed_checks = [] + mock_report2.passed_checks = [SAMPLE_PASSED_CHECK] + mock_report2.skipped_checks = [] + + mock_registry_instance.run.return_value = [mock_report1, mock_report2] + + provider = IacProvider() + result = provider.run_scan("/test/directory", ["terraform", "kubernetes"], []) + + # Verify results + assert len(result) == 2 + assert all(isinstance(report, CheckReportIAC) for report in result) + + # Check that we have one FAIL and one PASS report + statuses = [report.status for report in result] + assert "FAIL" in statuses + assert "PASS" in statuses + + @patch("prowler.providers.iac.iac_provider.RunnerRegistry") + @patch("prowler.providers.iac.iac_provider.RunnerFilter") + @patch("prowler.providers.iac.iac_provider.logger") + @patch("prowler.providers.iac.iac_provider.sys") + def test_run_scan_exception_handling( + self, mock_sys, mock_logger, mock_runner_filter, mock_runner_registry + ): + """Test run_scan exception handling""" + # Setup mocks to raise an exception + mock_registry_instance = Mock() + mock_runner_registry.return_value = mock_registry_instance + mock_registry_instance.run.side_effect = Exception("Test exception") + + # Configure sys.exit to raise SystemExit + mock_sys.exit.side_effect = SystemExit(1) + + provider = IacProvider() + + # The function should call sys.exit(1) when an exception occurs + with pytest.raises(SystemExit) as exc_info: + provider.run_scan("/test/directory", ["terraform"], []) + + assert exc_info.value.code == 1 + + # Verify logger was called with error information + mock_logger.critical.assert_called_once() + critical_call_args = mock_logger.critical.call_args[0][0] + assert "Exception" in critical_call_args + assert "Test exception" in critical_call_args + + @patch("prowler.providers.iac.iac_provider.RunnerRegistry") + @patch("prowler.providers.iac.iac_provider.RunnerFilter") + @patch("prowler.providers.iac.iac_provider.logger") + def test_run_scan_with_different_frameworks( + self, mock_logger, mock_runner_filter, mock_runner_registry + ): + """Test run_scan with different framework configurations""" + # Setup mocks + mock_registry_instance = Mock() + mock_runner_registry.return_value = mock_registry_instance + + mock_report = Mock() + mock_report.check_type = "terraform" # Set the check_type attribute + mock_report.failed_checks = [] + mock_report.passed_checks = [SAMPLE_PASSED_CHECK] + mock_report.skipped_checks = [] + + mock_registry_instance.run.return_value = [mock_report] + + provider = IacProvider() + + # Test with specific frameworks + frameworks = ["terraform", "kubernetes", "cloudformation"] + result = provider.run_scan("/test/directory", frameworks, []) + + # Verify RunnerFilter was created with correct frameworks + mock_runner_filter.assert_called_with(framework=frameworks, excluded_paths=[]) + + # Verify results + assert len(result) == 1 + assert result[0].status == "PASS" + + @patch("prowler.providers.iac.iac_provider.RunnerRegistry") + @patch("prowler.providers.iac.iac_provider.RunnerFilter") + @patch("prowler.providers.iac.iac_provider.logger") + def test_run_scan_with_exclude_paths( + self, mock_logger, mock_runner_filter, mock_runner_registry + ): + """Test run_scan with exclude paths""" + # Setup mocks + mock_registry_instance = Mock() + mock_runner_registry.return_value = mock_registry_instance + + mock_report = Mock() + mock_report.check_type = "terraform" # Set the check_type attribute + mock_report.failed_checks = [] + mock_report.passed_checks = [SAMPLE_PASSED_CHECK] + mock_report.skipped_checks = [] + + mock_registry_instance.run.return_value = [mock_report] + + provider = IacProvider() + + # Test with exclude paths + exclude_paths = ["node_modules", ".git", "vendor"] + result = provider.run_scan("/test/directory", ["all"], exclude_paths) + + # Verify RunnerFilter was created with correct exclude paths + mock_runner_filter.assert_called_with( + framework=["all"], excluded_paths=exclude_paths + ) + + # Verify results + assert len(result) == 1 + assert result[0].status == "PASS" + + @patch("prowler.providers.iac.iac_provider.RunnerRegistry") + @patch("prowler.providers.iac.iac_provider.RunnerFilter") + @patch("prowler.providers.iac.iac_provider.logger") + def test_run_scan_all_check_types( + self, mock_logger, mock_runner_filter, mock_runner_registry + ): + """Test run_scan with all types of checks (failed, passed, skipped)""" + # Setup mocks + mock_registry_instance = Mock() + mock_runner_registry.return_value = mock_registry_instance + + mock_report = Mock() + mock_report.check_type = "terraform" # Set the check_type attribute + mock_report.failed_checks = [SAMPLE_FAILED_CHECK, SAMPLE_HIGH_SEVERITY_CHECK] + mock_report.passed_checks = [SAMPLE_PASSED_CHECK, SAMPLE_CLOUDFORMATION_CHECK] + mock_report.skipped_checks = [SAMPLE_SKIPPED_CHECK] + + mock_registry_instance.run.return_value = [mock_report] + + provider = IacProvider() + result = provider.run_scan("/test/directory", ["all"], []) + + # Verify results + assert len(result) == 5 # 2 failed + 2 passed + 1 skipped + + # Check status distribution + statuses = [report.status for report in result] + assert statuses.count("FAIL") == 2 + assert statuses.count("PASS") == 2 + assert statuses.count("MUTED") == 1 + + # Check that muted reports have muted=True + muted_reports = [report for report in result if report.status == "MUTED"] + assert all(report.muted for report in muted_reports) + + @patch("prowler.providers.iac.iac_provider.RunnerRegistry") + @patch("prowler.providers.iac.iac_provider.RunnerFilter") + @patch("prowler.providers.iac.iac_provider.logger") + def test_run_scan_no_reports_returned( + self, mock_logger, mock_runner_filter, mock_runner_registry + ): + """Test run_scan when no reports are returned from registry""" + # Setup mocks + mock_registry_instance = Mock() + mock_runner_registry.return_value = mock_registry_instance + + # Return empty list of reports + mock_registry_instance.run.return_value = [] + + provider = IacProvider() + result = provider.run_scan("/test/directory", ["terraform"], []) + + # Verify results + assert len(result) == 0 + + @patch("prowler.providers.iac.iac_provider.RunnerRegistry") + @patch("prowler.providers.iac.iac_provider.RunnerFilter") + @patch("prowler.providers.iac.iac_provider.logger") + def test_run_scan_multiple_frameworks_with_different_checks( + self, mock_logger, mock_runner_filter, mock_runner_registry + ): + """Test run_scan with multiple frameworks and different types of checks""" + # Setup mocks + mock_registry_instance = Mock() + mock_runner_registry.return_value = mock_registry_instance + + # Create reports for different frameworks + terraform_report = Mock() + terraform_report.check_type = "terraform" + terraform_report.failed_checks = [ + SAMPLE_FAILED_CHECK, + SAMPLE_ANOTHER_FAILED_CHECK, + ] + terraform_report.passed_checks = [SAMPLE_PASSED_CHECK] + terraform_report.skipped_checks = [] + + kubernetes_report = Mock() + kubernetes_report.check_type = "kubernetes" + kubernetes_report.failed_checks = [SAMPLE_KUBERNETES_CHECK] + kubernetes_report.passed_checks = [] + kubernetes_report.skipped_checks = [SAMPLE_ANOTHER_SKIPPED_CHECK] + + cloudformation_report = Mock() + cloudformation_report.check_type = "cloudformation" + cloudformation_report.failed_checks = [] + cloudformation_report.passed_checks = [ + SAMPLE_CLOUDFORMATION_CHECK, + SAMPLE_ANOTHER_PASSED_CHECK, + ] + cloudformation_report.skipped_checks = [] + + mock_registry_instance.run.return_value = [ + terraform_report, + kubernetes_report, + cloudformation_report, + ] + + provider = IacProvider() + result = provider.run_scan( + "/test/directory", ["terraform", "kubernetes", "cloudformation"], [] + ) + + # Verify results + assert ( + len(result) == 7 + ) # 2 failed + 1 passed (terraform) + 1 failed + 1 skipped (kubernetes) + 2 passed (cloudformation) + + # Check status distribution + statuses = [report.status for report in result] + assert statuses.count("FAIL") == 3 + assert statuses.count("PASS") == 3 + assert statuses.count("MUTED") == 1 + + def test_run_method_calls_run_scan(self): + """Test that the run method calls run_scan with correct parameters""" + provider = IacProvider( + scan_path="/custom/path", frameworks=["terraform"], exclude_path=["exclude"] + ) + + with patch.object(provider, "run_scan") as mock_run_scan: + mock_run_scan.return_value = [] + provider.run() + + mock_run_scan.assert_called_once_with( + "/custom/path", ["terraform"], ["exclude"] + ) diff --git a/tests/providers/kubernetes/lib/mutelist/kubernetes_mutelist_test.py b/tests/providers/kubernetes/lib/mutelist/kubernetes_mutelist_test.py index 847c82aae8..366eb9ee3b 100644 --- a/tests/providers/kubernetes/lib/mutelist/kubernetes_mutelist_test.py +++ b/tests/providers/kubernetes/lib/mutelist/kubernetes_mutelist_test.py @@ -153,7 +153,7 @@ class TestKubernetesMutelist: account_uid="cluster_1", region="test-region", resource_uid="test_resource", - resource_tags=[], + resource_tags={}, muted=False, ) diff --git a/tests/providers/m365/lib/powershell/m365_powershell_test.py b/tests/providers/m365/lib/powershell/m365_powershell_test.py index 739dd7e8ce..1fa4753dad 100644 --- a/tests/providers/m365/lib/powershell/m365_powershell_test.py +++ b/tests/providers/m365/lib/powershell/m365_powershell_test.py @@ -257,7 +257,70 @@ class Testm365PowerShell: session.process.stdin.write = MagicMock() session.read_output = MagicMock(return_value="decrypted_password") - assert session.test_credentials(credentials) is False + with pytest.raises(Exception) as exc_info: + session.test_credentials(credentials) + assert ( + "Unexpected error: Acquiring token in behalf of user did not return a result." + in str(exc_info.value) + ) + + mock_msal.assert_called_once_with( + client_id="test_client_id", + client_credential="test_client_secret", + authority="https://login.microsoftonline.com/test_tenant_id", + ) + mock_msal_instance.acquire_token_by_username_password.assert_called_once_with( + username="test@contoso.onmicrosoft.com", + password="test_password", + scopes=["https://graph.microsoft.com/.default"], + ) + + session.close() + + @patch("subprocess.Popen") + @patch("msal.ConfidentialClientApplication") + def test_test_credentials_auth_failure_no_access_token(self, mock_msal, mock_popen): + mock_process = MagicMock() + mock_popen.return_value = mock_process + mock_msal_instance = MagicMock() + mock_msal.return_value = mock_msal_instance + mock_msal_instance.acquire_token_by_username_password.return_value = { + "error_description": "invalid_grant: authentication failed" + } + + 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", + ) + identity = M365IdentityInfo( + identity_id="test_id", + identity_type="User", + tenant_id="test_tenant", + tenant_domain="contoso.onmicrosoft.com", + tenant_domains=["contoso.onmicrosoft.com"], + location="test_location", + ) + session = M365PowerShell(credentials, identity) + + # Mock the execute method to return the decrypted password + def mock_execute(command, *args, **kwargs): + if "Write-Output" in command: + return "decrypted_password" + return None + + session.execute = MagicMock(side_effect=mock_execute) + session.process.stdin.write = MagicMock() + session.read_output = MagicMock(return_value="decrypted_password") + + with pytest.raises(Exception) as exc_info: + session.test_credentials(credentials) + assert "MsGraph Error invalid_grant: authentication failed" in str( + exc_info.value + ) mock_msal.assert_called_once_with( client_id="test_client_id", diff --git a/tests/providers/nhn/lib/mutelist/nhn_mutelist_test.py b/tests/providers/nhn/lib/mutelist/nhn_mutelist_test.py index 48444c622b..de0181c292 100644 --- a/tests/providers/nhn/lib/mutelist/nhn_mutelist_test.py +++ b/tests/providers/nhn/lib/mutelist/nhn_mutelist_test.py @@ -62,7 +62,7 @@ class TestNHNMutelist: finding.status = "FAIL" finding.resource_name = "test_resource" finding.location = "test_region" - finding.resource_tags = [] + finding.resource_tags = {} assert mutelist.is_finding_muted(finding) @@ -89,7 +89,7 @@ class TestNHNMutelist: account_uid="resource_1", region="test_region", resource_uid="test_resource", - resource_tags=[], + resource_tags={}, muted=False, ) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 31ea8a610a..917c9aaf81 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -20,18 +20,24 @@ All notable changes to the **Prowler UI** are documented in this file. - 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) - Lighthouse chat interface [(#7878)](https://github.com/prowler-cloud/prowler/pull/7878) - +- Google Tag Manager integration [(#8058)](https://github.com/prowler-cloud/prowler/pull/8058) +- SAML login integration [(#8094)](https://github.com/prowler-cloud/prowler/pull/8094) + ### 🔄 Changed - `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) +- Updated the provider details section in Scan and Findings detail pages [(#7968)](https://github.com/prowler-cloud/prowler/pull/7968) +- Improve filter behaviour and relationships between filters in findings page [(#8046)](https://github.com/prowler-cloud/prowler/pull/8046) +- Set filters panel to be always open by default [(#8085)](https://github.com/prowler-cloud/prowler/pull/8085) ### 🐞 Fixed - 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) +- Prevent console warnings for accessibility and SVG[(#8019)](https://github.com/prowler-cloud/prowler/pull/8019) --- @@ -72,7 +78,7 @@ All notable changes to the **Prowler UI** are documented in this file. - `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) - 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) ### 🐞 Fixed @@ -81,6 +87,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Added filter to retrieve only connected providers on the scan page [(#7723)](https://github.com/prowler-cloud/prowler/pull/7723) ### Removed + - Alias if not added from findings detail page [(#7751)](https://github.com/prowler-cloud/prowler/pull/7751) --- diff --git a/ui/Dockerfile b/ui/Dockerfile index 926cbfdfbd..a4c4d793f5 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -28,6 +28,8 @@ COPY . . ENV NEXT_TELEMETRY_DISABLED=1 ARG NEXT_PUBLIC_PROWLER_RELEASE_VERSION ENV NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${NEXT_PUBLIC_PROWLER_RELEASE_VERSION} +ARG NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID +ENV NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=${NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID} RUN \ if [ -f package-lock.json ]; then npm run build; \ diff --git a/ui/actions/integrations/index.ts b/ui/actions/integrations/index.ts new file mode 100644 index 0000000000..466fc73cb2 --- /dev/null +++ b/ui/actions/integrations/index.ts @@ -0,0 +1 @@ +export * from "./saml"; diff --git a/ui/actions/integrations/saml.ts b/ui/actions/integrations/saml.ts new file mode 100644 index 0000000000..2d645111ce --- /dev/null +++ b/ui/actions/integrations/saml.ts @@ -0,0 +1,210 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib/helper"; + +const samlConfigFormSchema = z.object({ + email_domain: z + .string() + .trim() + .min(1, { message: "Email domain is required" }), + metadata_xml: z + .string() + .trim() + .min(1, { message: "Metadata XML is required" }), +}); + +export const createSamlConfig = async (_prevState: any, formData: FormData) => { + const headers = await getAuthHeaders({ contentType: true }); + const formDataObject = Object.fromEntries(formData); + const validatedData = samlConfigFormSchema.safeParse(formDataObject); + + if (!validatedData.success) { + const formFieldErrors = validatedData.error.flatten().fieldErrors; + + return { + errors: { + email_domain: formFieldErrors?.email_domain?.[0], + metadata_xml: formFieldErrors?.metadata_xml?.[0], + }, + }; + } + + const { email_domain, metadata_xml } = validatedData.data; + + try { + const url = new URL(`${apiBaseUrl}/saml-config`); + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify({ + data: { + type: "saml-configurations", + attributes: { + email_domain: email_domain.trim(), + metadata_xml: metadata_xml.trim(), + }, + }, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.errors?.[0]?.detail || + `Failed to create SAML config: ${response.statusText}`, + ); + } + + await response.json(); + revalidatePath("/integrations"); + return { success: "SAML configuration created successfully!" }; + } catch (error) { + console.error("Error creating SAML config:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error creating SAML configuration. Please try again.", + }, + }; + } +}; + +export const updateSamlConfig = async (_prevState: any, formData: FormData) => { + const headers = await getAuthHeaders({ contentType: true }); + const formDataObject = Object.fromEntries(formData); + const validatedData = samlConfigFormSchema.safeParse(formDataObject); + + if (!validatedData.success) { + const formFieldErrors = validatedData.error.flatten().fieldErrors; + + return { + errors: { + email_domain: formFieldErrors?.email_domain?.[0], + metadata_xml: formFieldErrors?.metadata_xml?.[0], + }, + }; + } + + const { email_domain, metadata_xml } = validatedData.data; + + try { + const url = new URL(`${apiBaseUrl}/saml-config/${formDataObject.id}`); + const response = await fetch(url.toString(), { + method: "PATCH", + headers, + body: JSON.stringify({ + data: { + type: "saml-configurations", + id: formDataObject.id, + attributes: { + email_domain: email_domain.trim(), + metadata_xml: metadata_xml.trim(), + }, + }, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.errors?.[0]?.detail || + `Failed to update SAML config: ${response.statusText}`, + ); + } + + await response.json(); + revalidatePath("/integrations"); + return { success: "SAML configuration updated successfully!" }; + } catch (error) { + console.error("Error updating SAML config:", error); + return { + errors: { + general: + error instanceof Error + ? error.message + : "Error creating SAML configuration. Please try again.", + }, + }; + } +}; + +export const getSamlConfig = async () => { + const headers = await getAuthHeaders({ contentType: false }); + const url = new URL(`${apiBaseUrl}/saml-config`); + + try { + const response = await fetch(url.toString(), { + method: "GET", + headers, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch SAML config: ${response.statusText}`); + } + + const data = await response.json(); + const parsedData = parseStringify(data); + return parsedData; + } catch (error) { + console.error("Error fetching SAML config:", error); + return undefined; + } +}; + +export const initiateSamlAuth = async (email: string) => { + try { + const response = await fetch(`${apiBaseUrl}/auth/saml/initiate/`, { + method: "POST", + headers: { + "Content-Type": "application/vnd.api+json", + }, + body: JSON.stringify({ + data: { + type: "saml-initiate", + attributes: { + email_domain: email, + }, + }, + }), + redirect: "manual", + }); + + if (response.status === 302) { + const location = response.headers.get("Location"); + + if (location) { + return { + success: true, + redirectUrl: location, + }; + } + } + + if (response.status === 403) { + return { + success: false, + error: + "Domain is not authorized for SAML authentication or SAML certificates are missing.", + }; + } + + // Add error other error case: + const errorData = await response.json().catch(() => ({})); + return { + success: false, + error: + errorData.errors?.[0]?.detail || + "An error occurred during SAML authentication.", + }; + } catch (error) { + return { + success: false, + error: "Failed to connect to authentication service.", + }; + } +}; diff --git a/ui/actions/lighthouse/lighthouse.ts b/ui/actions/lighthouse/lighthouse.ts index 686dc179cd..38cca0ee7c 100644 --- a/ui/actions/lighthouse/lighthouse.ts +++ b/ui/actions/lighthouse/lighthouse.ts @@ -2,11 +2,12 @@ import { apiBaseUrl, getAuthHeaders } from "@/lib/helper"; -const getLighthouseConfigId = async (): Promise => { +export const getAIKey = async (): Promise => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL( - `${apiBaseUrl}/lighthouse-configurations?filter[name]=OpenAI`, + `${apiBaseUrl}/lighthouse-configurations?fields[lighthouse-config]=api_key`, ); + try { const response = await fetch(url.toString(), { method: "GET", @@ -17,35 +18,35 @@ const getLighthouseConfigId = async (): Promise => { // Check if data array exists and has at least one item if (data?.data && data.data.length > 0) { - return data.data[0].id; + return data.data[0].attributes.api_key || ""; } // Return empty string if no configuration found return ""; } catch (error) { - console.error("[Server] Error in getOpenAIConfigurationId:", error); + console.error("[Server] Error in getAIKey:", error); return ""; } }; -export const getAIKey = async (): Promise => { +export const checkLighthouseConnection = async (configId: string) => { const headers = await getAuthHeaders({ contentType: false }); - const configId = await getLighthouseConfigId(); - - if (!configId) { - return ""; - } - const url = new URL( - `${apiBaseUrl}/lighthouse-configurations/${configId}?fields[lighthouse-config]=api_key`, + `${apiBaseUrl}/lighthouse-configurations/${configId}/connection`, ); - const response = await fetch(url.toString(), { - method: "GET", - headers, - }); - const data = await response.json(); - return data.data.attributes.api_key; + try { + const response = await fetch(url.toString(), { + method: "POST", + headers, + }); + + const data = await response.json(); + return data; + } catch (error) { + console.error("[Server] Error in checkLighthouseConnection:", error); + return undefined; + } }; export const createLighthouseConfig = async (config: { @@ -74,29 +75,36 @@ export const createLighthouseConfig = async (config: { body: JSON.stringify(payload), }); const data = await response.json(); + + // Trigger connection check in background + if (data?.data?.id) { + checkLighthouseConnection(data.data.id); + } + return data; } catch (error) { - console.error("[Server] Error in createAIConfiguration:", error); + console.error("[Server] Error in createLighthouseConfig:", error); return undefined; } }; export const getLighthouseConfig = async () => { const headers = await getAuthHeaders({ contentType: false }); - const configId = await getLighthouseConfigId(); + const url = new URL(`${apiBaseUrl}/lighthouse-configurations`); - if (!configId) { - return undefined; - } - - const url = new URL(`${apiBaseUrl}/lighthouse-configurations/${configId}`); try { const response = await fetch(url.toString(), { method: "GET", headers, }); const data = await response.json(); - return data; + + // Check if data array exists and has at least one item + if (data?.data && data.data.length > 0) { + return data.data[0]; + } + + return undefined; } catch (error) { console.error("[Server] Error in getLighthouseConfig:", error); return undefined; @@ -109,14 +117,26 @@ export const updateLighthouseConfig = async (config: { businessContext: string; }) => { const headers = await getAuthHeaders({ contentType: true }); - const configId = await getLighthouseConfigId(); - - if (!configId) { - return undefined; - } + // Get the config ID from the list endpoint + const url = new URL(`${apiBaseUrl}/lighthouse-configurations`); try { - const url = new URL(`${apiBaseUrl}/lighthouse-configurations/${configId}`); + const response = await fetch(url.toString(), { + method: "GET", + headers: await getAuthHeaders({ contentType: false }), + }); + + const data = await response.json(); + + // Check if data array exists and has at least one item + if (!data?.data || data.data.length === 0) { + return undefined; + } + + const configId = data.data[0].id; + const updateUrl = new URL( + `${apiBaseUrl}/lighthouse-configurations/${configId}`, + ); // Prepare the request payload following the JSONAPI format const payload = { @@ -131,16 +151,22 @@ export const updateLighthouseConfig = async (config: { }, }; - const response = await fetch(url.toString(), { + const updateResponse = await fetch(updateUrl.toString(), { method: "PATCH", headers, body: JSON.stringify(payload), }); - const data = await response.json(); - return data; + const updateData = await updateResponse.json(); + + // Trigger connection check in background + if (updateData?.data?.id || configId) { + checkLighthouseConnection(configId); + } + + return updateData; } catch (error) { - console.error("[Server] Error in updateAIConfiguration:", error); + console.error("[Server] Error in updateLighthouseConfig:", error); return undefined; } }; diff --git a/ui/app/(auth)/layout.tsx b/ui/app/(auth)/layout.tsx index a9fdc8c394..7ff019c22f 100644 --- a/ui/app/(auth)/layout.tsx +++ b/ui/app/(auth)/layout.tsx @@ -1,5 +1,6 @@ import "@/styles/globals.css"; +import { GoogleTagManager } from "@next/third-parties/google"; import { Metadata, Viewport } from "next"; import { redirect } from "next/navigation"; @@ -53,6 +54,9 @@ export default async function RootLayout({ {children} + diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index 71e7706285..17636db9d4 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -7,13 +7,12 @@ import { getComplianceOverviewMetadataInfo, getComplianceRequirements, } from "@/actions/compliances"; -import { getProvider } from "@/actions/providers"; -import { getScans } from "@/actions/scans"; import { BarChart, BarChartSkeleton, ClientAccordionWrapper, ComplianceHeader, + ComplianceScanInfo, HeatmapChart, HeatmapChartSkeleton, PieChart, @@ -22,14 +21,15 @@ import { } 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 { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; import { Framework, RequirementsTotals } from "@/types/compliance"; +import { ScanEntity } from "@/types/scans"; interface ComplianceDetailSearchParams { complianceId: string; version?: string; scanId?: string; + scanData?: string; "filter[region__in]"?: string; "filter[cis_profile_level]"?: string; } @@ -47,7 +47,7 @@ const ComplianceIconSmall = ({ src={logoPath} alt={`${title} logo`} fill - className="h-10 w-10 min-w-10 rounded-md border-1 border-gray-300 bg-white object-contain p-[2px]" + className="h-8 w-8 min-w-8 rounded-md border-1 border-gray-300 bg-white object-contain p-[2px]" /> ); @@ -60,18 +60,8 @@ const ChartsWrapper = ({ logoPath?: string; }) => { return ( -
- {children && - React.Children.toArray(children).map( - (child: React.ReactNode, index: number) => ( -
- {child} -
- ), - )} +
+ {children}
); }; @@ -84,7 +74,7 @@ export default async function ComplianceDetail({ searchParams: ComplianceDetailSearchParams; }) { const { compliancetitle } = params; - const { complianceId, version, scanId } = searchParams; + const { complianceId, version, scanId, scanData } = searchParams; const regionFilter = searchParams["filter[region__in]"]; const cisProfileFilter = searchParams["filter[cis_profile_level]"]; const logoPath = getComplianceIcon(compliancetitle); @@ -97,51 +87,19 @@ export default async function ComplianceDetail({ ? `Compliance Details: ${formattedTitle} - ${version}` : `Compliance Details: ${formattedTitle}`; - // Fetch scans data - const scansData = await getScans({ - filters: { - "filter[state]": "completed", - }, - }); + let selectedScan: ScanEntity | null = null; - // 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 (scanData) { + selectedScan = JSON.parse(decodeURIComponent(scanData)); + } - if (!providerId) { - return { ...scan, providerInfo: null }; - } + const selectedScanId = scanId || selectedScan?.id || 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 ( @@ -155,8 +113,14 @@ export default async function ComplianceDetail({ ) } > + {selectedScanId && selectedScan && ( +
+ + +
+ )} { - if (!scanId) { + const [attributesData, requirementsData] = await Promise.all([ + getComplianceAttributes(complianceId), + getComplianceRequirements({ + complianceId, + scanId, + region, + }), + ]); + const type = requirementsData?.data?.[0]?.type; + + if (!scanId || type === "tasks") { return (
@@ -214,30 +188,14 @@ const SSRComplianceContent = async ({ ); } - // 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, @@ -246,14 +204,9 @@ const SSRComplianceContent = async ({ }), { 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 (
@@ -270,7 +223,7 @@ const SSRComplianceContent = async ({
); diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index 061af051d7..6a8326f334 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -12,7 +12,12 @@ import { } from "@/components/compliance"; import { ComplianceHeader } from "@/components/compliance/compliance-header/compliance-header"; import { ContentLayout } from "@/components/ui"; -import { ScanProps, SearchParamsProps } from "@/types"; +import { + ExpandedScanData, + ScanEntity, + ScanProps, + SearchParamsProps, +} from "@/types"; import { ComplianceOverviewData } from "@/types/compliance"; export default async function Compliance({ @@ -37,37 +42,49 @@ export default async function Compliance({ return ; } - // Expand scans with provider information - const expandedScansData = await Promise.all( - scansData.data.map(async (scan: ScanProps) => { - const providerId = scan.relationships?.provider?.data?.id; + // Expand scans with provider information - only include scans with valid provider + const expandedScansData: ExpandedScanData[] = await Promise.all( + scansData.data + .filter((scan: ScanProps) => scan.relationships?.provider?.data?.id) + .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 formData = new FormData(); - formData.append("id", providerId); + const providerData = await getProvider(formData); - 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, - }; - }), + return { + ...scan, + providerInfo: { + provider: providerData.data.attributes.provider, + uid: providerData.data.attributes.uid, + alias: providerData.data.attributes.alias, + }, + }; + }), ); const selectedScanId = searchParams.scanId || expandedScansData[0]?.id || null; const query = (filters["filter[search]"] as string) || ""; + // Find the selected scan + const selectedScan = expandedScansData.find( + (scan) => scan.id === selectedScanId, + ); + + const selectedScanData: ScanEntity | undefined = selectedScan?.providerInfo + ? { + id: selectedScan.id, + providerInfo: selectedScan.providerInfo, + attributes: { + name: selectedScan.attributes.name, + completed_at: selectedScan.attributes.completed_at, + }, + } + : undefined; + const metadataInfoData = await getComplianceOverviewMetadataInfo({ query, filters: { @@ -86,7 +103,10 @@ export default async function Compliance({ uniqueRegions={uniqueRegions} /> }> - + ) : ( @@ -98,8 +118,10 @@ export default async function Compliance({ const SSRComplianceGrid = async ({ searchParams, + selectedScan, }: { searchParams: SearchParamsProps; + selectedScan?: ScanEntity; }) => { const scanId = searchParams.scanId?.toString() || ""; const regionFilter = searchParams["filter[region__in]"]?.toString() || ""; @@ -118,11 +140,14 @@ const SSRComplianceGrid = async ({ query, }); + const type = compliancesData?.data?.[0]?.type; + // Check if the response contains no data if ( !compliancesData || !compliancesData.data || - compliancesData.data.length === 0 + compliancesData.data.length === 0 || + type === "tasks" ) { return (
@@ -161,6 +186,7 @@ const SSRComplianceGrid = async ({ scanId={scanId} complianceId={id} id={id} + selectedScan={selectedScan} /> ); })} diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index e8290a4167..a5d02f9db8 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -9,14 +9,13 @@ import { } from "@/actions/findings"; import { getProviders } from "@/actions/providers"; import { getScans } from "@/actions/scans"; -import { filterFindings } from "@/components/filters/data-filters"; -import { FilterControls } from "@/components/filters/filter-controls"; +import { FindingsFilters } from "@/components/findings/findings-filters"; import { ColumnFindings, SkeletonTableFindings, } from "@/components/findings/table"; import { ContentLayout } from "@/components/ui"; -import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; +import { DataTable } from "@/components/ui/table"; import { createDict, createScanDetailsMapping, @@ -28,7 +27,7 @@ import { createProviderDetailsMapping, extractProviderUIDs, } from "@/lib/provider-helpers"; -import { ScanProps } from "@/types"; +import { FilterEntity, ScanEntity, ScanProps } from "@/types"; import { FindingProps, SearchParamsProps } from "@/types/components"; export default async function Findings({ @@ -61,21 +60,11 @@ export default async function Findings({ // Extract provider UIDs and details using helper functions const providerUIDs = providersData ? extractProviderUIDs(providersData) : []; const providerDetails = providersData - ? createProviderDetailsMapping(providerUIDs, providersData) + ? (createProviderDetailsMapping(providerUIDs, providersData) as { + [uid: string]: FilterEntity; + }[]) : []; - // Update the Provider UID filter - const updatedFilters = filterFindings.map((filter) => { - if (filter.key === "provider_uid__in") { - return { - ...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) => @@ -86,45 +75,24 @@ export default async function Findings({ const completedScanIds = completedScans?.map((scan: ScanProps) => scan.id) || []; - const scanDetails = createScanDetailsMapping(completedScans, providersData); + const scanDetails = createScanDetailsMapping( + completedScans, + providersData, + ) as { [uid: string]: ScanEntity }[]; return ( - - - - }> diff --git a/ui/app/(prowler)/lighthouse/config/page.tsx b/ui/app/(prowler)/lighthouse/config/page.tsx index 5872f92d12..ce7b2017e7 100644 --- a/ui/app/(prowler)/lighthouse/config/page.tsx +++ b/ui/app/(prowler)/lighthouse/config/page.tsx @@ -6,12 +6,11 @@ export const dynamic = "force-dynamic"; export default async function ChatbotConfigPage() { const response = await getLighthouseConfig(); - - const initialValues = response?.data?.attributes + const initialValues = response?.attributes ? { - model: response.data.attributes.model, - apiKey: response.data.attributes.api_key || "", - businessContext: response.data.attributes.business_context || "", + model: response.attributes.model, + apiKey: response.attributes.api_key || "", + businessContext: response.attributes.business_context || "", } : { model: "gpt-4o", diff --git a/ui/app/(prowler)/lighthouse/page.tsx b/ui/app/(prowler)/lighthouse/page.tsx index 469303ed44..f283237f2f 100644 --- a/ui/app/(prowler)/lighthouse/page.tsx +++ b/ui/app/(prowler)/lighthouse/page.tsx @@ -1,13 +1,16 @@ -import { getAIKey } from "@/actions/lighthouse/lighthouse"; +import { getLighthouseConfig } from "@/actions/lighthouse/lighthouse"; import { Chat } from "@/components/lighthouse"; import { ContentLayout } from "@/components/ui"; export default async function AIChatbot() { - const apiKey = await getAIKey(); + const config = await getLighthouseConfig(); + + const hasConfig = !!config; + const isActive = config?.attributes?.is_active ?? false; return ( - - + + ); } diff --git a/ui/app/(prowler)/page.tsx b/ui/app/(prowler)/page.tsx index 3038a81c6b..7c6f2ac906 100644 --- a/ui/app/(prowler)/page.tsx +++ b/ui/app/(prowler)/page.tsx @@ -33,37 +33,35 @@ export default function Home({ const searchParamsKey = JSON.stringify(searchParams || {}); return ( - -
-
-
- }> - - -
-
- }> - - -
+
+
+ }> + + +
-
- }> - - -
+
+ }> + + +
-
- - } - > - - -
+
+ }> + + +
+ +
+ + } + > + +
diff --git a/ui/app/(prowler)/profile/page.tsx b/ui/app/(prowler)/profile/page.tsx index ff26a9db15..2e73bc0342 100644 --- a/ui/app/(prowler)/profile/page.tsx +++ b/ui/app/(prowler)/profile/page.tsx @@ -1,8 +1,10 @@ import React, { Suspense } from "react"; +import { getSamlConfig } from "@/actions/integrations/saml"; import { getAllTenants } from "@/actions/users/tenants"; import { getUserInfo } from "@/actions/users/users"; import { getUserMemberships } from "@/actions/users/users"; +import { SamlIntegrationCard } from "@/components/integrations/saml-integration-card"; import { ContentLayout } from "@/components/ui"; import { UserBasicInfoCard } from "@/components/users/profile"; import { MembershipsCard } from "@/components/users/profile/memberships-card"; @@ -22,6 +24,7 @@ export default async function Profile() { } const SSRDataUser = async () => { + const samlConfig = await getSamlConfig(); const userProfile = await getUserInfo(); if (!userProfile?.data) { return null; @@ -69,11 +72,11 @@ const SSRDataUser = async () => { return (
-
-
+
+
-
+
{ />
+
+ {samlConfig.data?.length > 0 && ( + + )} +
); }; diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx index 05ce3cf692..ff78664ca9 100644 --- a/ui/app/(prowler)/providers/page.tsx +++ b/ui/app/(prowler)/providers/page.tsx @@ -10,7 +10,7 @@ import { SkeletonTableProviders, } from "@/components/providers/table"; import { ContentLayout } from "@/components/ui"; -import { DataTable, DataTableFilterCustom } from "@/components/ui/table"; +import { DataTable } from "@/components/ui/table"; import { ProviderProps, SearchParamsProps } from "@/types"; export default async function Providers({ @@ -22,14 +22,12 @@ export default async function Providers({ return ( - +
- -
diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index bcf97dd47a..9a6214bf59 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -3,17 +3,17 @@ import { Suspense } from "react"; import { getProvider, getProviders } from "@/actions/providers"; import { getScans, getScansByState } from "@/actions/scans"; -import { FilterControls, filterScans } from "@/components/filters"; import { AutoRefresh, NoProvidersAdded, NoProvidersConnected, + ScansFilters, } from "@/components/scans"; import { LaunchScanWorkflow } from "@/components/scans/launch-workflow"; 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 { DataTable } from "@/components/ui/table"; import { createProviderDetailsMapping, extractProviderUIDs, @@ -49,8 +49,7 @@ export default async function Scans({ filters: { "filter[connected]": true }, pageSize: 50, }); - const thereIsNoProviders = - !providersCountConnected?.data || providersCountConnected.data.length === 0; + const thereIsNoProviders = !providersCountConnected?.data; const thereIsNoProvidersConnected = providersCountConnected?.data?.every( (provider: ProviderProps) => !provider.attributes.connection.connected, @@ -71,59 +70,37 @@ export default async function Scans({ ? 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; - }); + if (thereIsNoProviders) { + return ( + + + + ); + } return ( - <> - {thereIsNoProviders && ( - <> - - - - )} - - {!thereIsNoProviders && ( - <> - {thereIsNoProvidersConnected ? ( - - - - - - ) : ( - - - - - - )} - -
-
-
- - - -
- - }> - - -
-
- - )} - + + + <> + {thereIsNoProvidersConnected ? ( + <> + + + + + ) : ( + + )} + + + }> + + + + ); } diff --git a/ui/app/api/auth/callback/saml/route.ts b/ui/app/api/auth/callback/saml/route.ts new file mode 100644 index 0000000000..239dc98e3e --- /dev/null +++ b/ui/app/api/auth/callback/saml/route.ts @@ -0,0 +1,55 @@ +"use server"; + +import { NextResponse } from "next/server"; + +import { signIn } from "@/auth.config"; +import { apiBaseUrl, baseUrl } from "@/lib/helper"; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const id = searchParams.get("id"); + + if (!id) { + return NextResponse.json( + { error: "ID parameter is missing" }, + { status: 400 }, + ); + } + + try { + const response = await fetch(`${apiBaseUrl}/tokens/saml?id=${id}`, { + method: "POST", + headers: { + "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch tokens: ${response.statusText}`); + } + + const tokenData = await response.json(); + const { access, refresh } = tokenData.data; + + if (!access || !refresh) { + throw new Error("Tokens not found in response"); + } + + const result = await signIn("social-oauth", { + accessToken: access, + refreshToken: refresh, + redirect: false, + callbackUrl: `${baseUrl}/`, + }); + + if (result?.error) { + throw new Error(result.error); + } + + return NextResponse.redirect(new URL("/", baseUrl)); + } catch (error) { + console.error("SAML authentication failed:", error); + return NextResponse.redirect(new URL("/sign-in", baseUrl)); + } +} diff --git a/ui/components/auth/oss/auth-form.tsx b/ui/components/auth/oss/auth-form.tsx index 375ff7b70b..8113759b56 100644 --- a/ui/components/auth/oss/auth-form.tsx +++ b/ui/components/auth/oss/auth-form.tsx @@ -8,6 +8,7 @@ import { useForm } from "react-hook-form"; import { z } from "zod"; import { authenticate, createNewUser } from "@/actions/auth"; +import { initiateSamlAuth } from "@/actions/integrations/saml"; import { NotificationIcon, ProwlerExtended } from "@/components/icons"; import { ThemeSwitch } from "@/components/ThemeSwitch"; import { useToast } from "@/components/ui"; @@ -45,6 +46,7 @@ export const AuthForm = ({ defaultValues: { email: "", password: "", + isSamlMode: false, ...(type === "sign-up" && { name: "", company: "", @@ -56,9 +58,31 @@ export const AuthForm = ({ const isLoading = form.formState.isSubmitting; const { toast } = useToast(); + const isSamlMode = form.watch("isSamlMode"); const onSubmit = async (data: z.infer) => { if (type === "sign-in") { + if (data.isSamlMode) { + const email = data.email.toLowerCase(); + if (isSamlMode) { + form.setValue("password", ""); + } + + const result = await initiateSamlAuth(email); + + if (result.success && result.redirectUrl) { + window.location.href = result.redirectUrl; + } else { + toast({ + variant: "destructive", + title: "SAML Authentication Error", + description: + result.error || "An error occurred during SAML authentication.", + }); + } + return; + } + const result = await authenticate(null, { email: data.email.toLowerCase(), password: data.password, @@ -150,7 +174,11 @@ export const AuthForm = ({

- {type === "sign-in" ? "Sign In" : "Sign Up"} + {type === "sign-in" + ? isSamlMode + ? "Sign In with SAML SSO" + : "Sign In" + : "Sign Up"}

@@ -181,7 +209,6 @@ export const AuthForm = ({ /> )} - - - - + {!isSamlMode && type === "sign-in" && ( + + )} {/* {type === "sign-in" && (
@@ -265,14 +292,12 @@ export const AuthForm = ({ )} )} - {type === "sign-in" && form.formState.errors?.email && (

Invalid email or password

)} - - {!invitationToken && ( + {!invitationToken && type === "sign-in" && ( <>
@@ -302,76 +327,98 @@ export const AuthForm = ({
- - Social Login with Google is not enabled.{" "} - - Read the docs - -
- } - placement="right-start" - shadow="sm" - isDisabled={isGoogleOAuthEnabled} - className="w-96" - > - -
} - variant="bordered" - className="w-full" - as="a" - href={googleAuthUrl} - isDisabled={!isGoogleOAuthEnabled} + placement="right-start" + shadow="sm" + isDisabled={isGoogleOAuthEnabled} + className="w-96" > - Continue with Google - - - - - Social Login with Github is not enabled.{" "} - - Read the docs - -
- } - placement="right-start" - shadow="sm" - isDisabled={isGithubOAuthEnabled} - className="w-96" - > - - + + + + Social Login with Github is not enabled.{" "} + + Read the docs + +
} - variant="bordered" - className="w-full" - as="a" - href={githubAuthUrl} - isDisabled={!isGithubOAuthEnabled} + placement="right-start" + shadow="sm" + isDisabled={isGithubOAuthEnabled} + className="w-96" > - Continue with Github - - - + + + + + + )} +
)} diff --git a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx index 23bedb0459..638cd3300d 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-content.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-content.tsx @@ -11,7 +11,7 @@ import { import { Accordion } from "@/components/ui/accordion/Accordion"; import { DataTable } from "@/components/ui/table"; import { createDict } from "@/lib"; -import { getComplianceMapper } from "@/lib/compliance/commons"; +import { getComplianceMapper } from "@/lib/compliance/compliance-mapper"; import { Requirement } from "@/types/compliance"; import { FindingProps, FindingsResponse } from "@/types/components"; diff --git a/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx b/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx index 6288cf9171..8db5c96ecb 100644 --- a/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx +++ b/ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx @@ -3,7 +3,6 @@ import { useState } from "react"; import { Accordion, AccordionItemProps } from "@/components/ui"; -import { CustomButton } from "@/components/ui/custom"; export const ClientAccordionWrapper = ({ items, @@ -57,17 +56,15 @@ export const ClientAccordionWrapper = ({ }; return ( -
+
{!hideExpandButton && ( -
- +
)} = ({ @@ -30,6 +32,7 @@ export const ComplianceCard: React.FC = ({ scanId, complianceId, id, + selectedScan, }) => { const searchParams = useSearchParams(); const router = useRouter(); @@ -80,6 +83,17 @@ export const ComplianceCard: React.FC = ({ params.set("version", version); params.set("scanId", scanId); + if (selectedScan) { + params.set( + "scanData", + JSON.stringify({ + id: selectedScan.id, + providerInfo: selectedScan.providerInfo, + attributes: selectedScan.attributes, + }), + ); + } + router.push(`${path}?${params.toString()}`); }; const handleDownload = async () => { diff --git a/ui/components/compliance/compliance-charts/bar-chart.tsx b/ui/components/compliance/compliance-charts/bar-chart.tsx index 3afd02ee5d..2b0add1160 100644 --- a/ui/components/compliance/compliance-charts/bar-chart.tsx +++ b/ui/components/compliance/compliance-charts/bar-chart.tsx @@ -54,13 +54,13 @@ export const BarChart = ({ sections }: FailedSectionsListProps) => { const getTypeColor = (type: string) => { switch (type.toLowerCase()) { case "requisito": - return "#ff5356"; + return "#FB718F"; case "recomendacion": return "#FDC53A"; // Increased contrast from #FDDD8A case "refuerzo": return "#7FB5FF"; // Increased contrast from #B5D7FF default: - return "#ff5356"; + return "#FB718F"; } }; diff --git a/ui/components/compliance/compliance-charts/heatmap-chart.tsx b/ui/components/compliance/compliance-charts/heatmap-chart.tsx index 3a78faf346..0502162b8d 100644 --- a/ui/components/compliance/compliance-charts/heatmap-chart.tsx +++ b/ui/components/compliance/compliance-charts/heatmap-chart.tsx @@ -11,13 +11,12 @@ interface HeatmapChartProps { } 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"; + if (percentage === 0) return "#3CEC6D"; + if (percentage <= 25) return "#fcd34d"; + if (percentage <= 50) return "#FA7315"; + if (percentage <= 100) return "#F31260"; + return "#F31260"; }; - const capitalizeFirstLetter = (text: string): string => { const lowerText = text.toLowerCase(); const firstLetterIndex = lowerText.search(/[a-zA-Z]/); @@ -101,7 +100,7 @@ export const HeatmapChart = ({ categories = [] }: HeatmapChartProps) => { onMouseMove={handleMouseMove} onMouseLeave={handleMouseLeave} > -
+
- {showSearch && } - - {showProviders && } - {allFilters.length > 0 && ( + {(showProviders || showSearch) && ( <> - {showProviders && } - +
+ {showProviders && } + {showSearch && } +
)} - + {allFilters.length > 0 && } + ); }; diff --git a/ui/components/compliance/compliance-header/compliance-scan-info.tsx b/ui/components/compliance/compliance-header/compliance-scan-info.tsx index acaef4caa2..6f265a1a2f 100644 --- a/ui/components/compliance/compliance-header/compliance-scan-info.tsx +++ b/ui/components/compliance/compliance-header/compliance-scan-info.tsx @@ -1,5 +1,4 @@ import { Divider, Tooltip } from "@nextui-org/react"; -import React from "react"; import { DateWithTime, EntityInfoShort } from "@/components/ui/entities"; import { ProviderType } from "@/types"; @@ -18,9 +17,7 @@ interface ComplianceScanInfoProps { }; } -export const ComplianceScanInfo: React.FC = ({ - scan, -}) => { +export const ComplianceScanInfo = ({ scan }: ComplianceScanInfoProps) => { return (
{ }; return ( -
-
- -
+
+
); }; diff --git a/ui/components/compliance/compliance-header/scan-selector.tsx b/ui/components/compliance/compliance-header/scan-selector.tsx index 4739633cc6..cf4b4c2806 100644 --- a/ui/components/compliance/compliance-header/scan-selector.tsx +++ b/ui/components/compliance/compliance-header/scan-selector.tsx @@ -26,7 +26,8 @@ export const ScanSelector = ({ aria-label="Select a Scan" placeholder="Select a scan" classNames={{ - trigger: "w-full min-w-[365px]", + trigger: "w-full min-w-[365px] rounded-lg", + popoverContent: "rounded-lg", }} size="lg" labelPlacement="outside" diff --git a/ui/components/filters/clear-filters-button.tsx b/ui/components/filters/clear-filters-button.tsx new file mode 100644 index 0000000000..e30eda2f72 --- /dev/null +++ b/ui/components/filters/clear-filters-button.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { CrossIcon } from "@/components/icons"; +import { useUrlFilters } from "@/hooks/use-url-filters"; + +import { CustomButton } from "../ui/custom/custom-button"; + +export interface ClearFiltersButtonProps { + className?: string; + text?: string; + ariaLabel?: string; +} + +export const ClearFiltersButton = ({ + className = "w-full md:w-fit", + text = "Clear all filters", + ariaLabel = "Reset", +}: ClearFiltersButtonProps) => { + const { clearAllFilters, hasFilters } = useUrlFilters(); + + if (!hasFilters()) { + return null; + } + + return ( + } + radius="sm" + > + {text} + + ); +}; diff --git a/ui/components/filters/custom-select-provider.tsx b/ui/components/filters/custom-select-provider.tsx index 89bd537745..db614ea494 100644 --- a/ui/components/filters/custom-select-provider.tsx +++ b/ui/components/filters/custom-select-provider.tsx @@ -74,7 +74,7 @@ export const CustomSelectProvider: React.FC = () => { placeholder="Select a provider" classNames={{ selectorIcon: "right-2", - label: "!z-0", + label: "!z-0 mb-2", }} label="Provider" labelPlacement="inside" diff --git a/ui/components/filters/data-filters.ts b/ui/components/filters/data-filters.ts index ec964bbc26..92fa8d7523 100644 --- a/ui/components/filters/data-filters.ts +++ b/ui/components/filters/data-filters.ts @@ -1,3 +1,5 @@ +import { FilterType } from "@/types/filters"; + export const filterProviders = [ { key: "connected", @@ -12,6 +14,7 @@ export const filterScans = [ key: "provider_type__in", labelCheckboxGroup: "Cloud Provider", values: ["aws", "azure", "m365", "gcp", "kubernetes"], + index: 0, }, { key: "state__in", @@ -24,52 +27,43 @@ export const filterScans = [ "failed", "cancelled", ], + index: 2, }, { key: "trigger", labelCheckboxGroup: "Trigger", values: ["scheduled", "manual"], - }, - { - key: "provider_uid__in", - labelCheckboxGroup: "Provider UID", - values: [], + index: 3, }, // Add more filter categories as needed ]; +//Static filters for findings export const filterFindings = [ { - key: "severity__in", + key: FilterType.SEVERITY, labelCheckboxGroup: "Severity", values: ["critical", "high", "medium", "low", "informational"], + index: 0, + }, + { + key: FilterType.STATUS, + labelCheckboxGroup: "Status", + values: ["PASS", "FAIL", "MANUAL"], index: 1, }, { - key: "status__in", - labelCheckboxGroup: "Status", - values: ["PASS", "FAIL", "MANUAL"], - index: 2, - }, - { - key: "provider_type__in", + key: FilterType.PROVIDER_TYPE, labelCheckboxGroup: "Cloud Provider", values: ["aws", "azure", "m365", "gcp", "kubernetes"], - index: 4, + index: 5, }, { - key: "provider_uid__in", - labelCheckboxGroup: "Provider UID", - values: [], - index: 8, - }, - { - key: "delta__in", + key: FilterType.DELTA, labelCheckboxGroup: "Delta", values: ["new", "changed"], - index: 3, + index: 2, }, - // Add more filter categories as needed ]; export const filterUsers = [ diff --git a/ui/components/filters/filter-controls.tsx b/ui/components/filters/filter-controls.tsx index 35dddb0b37..b460999807 100644 --- a/ui/components/filters/filter-controls.tsx +++ b/ui/components/filters/filter-controls.tsx @@ -1,14 +1,13 @@ "use client"; +import { Spacer } from "@nextui-org/react"; import { useSearchParams } from "next/navigation"; import React, { useEffect, useState } from "react"; -import { useUrlFilters } from "@/hooks/use-url-filters"; import { FilterControlsProps } from "@/types"; -import { CrossIcon } from "../icons"; -import { CustomButton } from "../ui/custom"; import { DataTableFilterCustom } from "../ui/table"; +import { ClearFiltersButton } from "./clear-filters-button"; import { CustomAccountSelection } from "./custom-account-selection"; import { CustomCheckboxMutedFindings } from "./custom-checkbox-muted-findings"; import { CustomDatePicker } from "./custom-date-picker"; @@ -26,7 +25,6 @@ export const FilterControls: React.FC = ({ customFilters, }) => { const searchParams = useSearchParams(); - const { clearAllFilters } = useUrlFilters(); const [showClearButton, setShowClearButton] = useState(false); useEffect(() => { @@ -37,7 +35,7 @@ export const FilterControls: React.FC = ({ }, [searchParams]); return ( -
+
{search && } {providers && } @@ -45,22 +43,16 @@ export const FilterControls: React.FC = ({ {regions && } {accounts && } {mutedFindings && } - - {showClearButton && ( - } - radius="sm" - > - Clear all filters - - )} + {!customFilters && showClearButton && }
- {customFilters && } + + {customFilters && ( + + )}
); }; diff --git a/ui/components/filters/index.ts b/ui/components/filters/index.ts index 915bbd3aec..5d9f577590 100644 --- a/ui/components/filters/index.ts +++ b/ui/components/filters/index.ts @@ -1,3 +1,4 @@ +export * from "./clear-filters-button"; export * from "./custom-account-selection"; export * from "./custom-checkbox-muted-findings"; export * from "./custom-date-picker"; diff --git a/ui/components/findings/findings-filters.tsx b/ui/components/findings/findings-filters.tsx new file mode 100644 index 0000000000..0dc025084b --- /dev/null +++ b/ui/components/findings/findings-filters.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { filterFindings } from "@/components/filters/data-filters"; +import { FilterControls } from "@/components/filters/filter-controls"; +import { useRelatedFilters } from "@/hooks"; +import { FilterEntity, FilterType, ScanEntity, ScanProps } from "@/types"; + +interface FindingsFiltersProps { + providerUIDs: string[]; + providerDetails: { [uid: string]: FilterEntity }[]; + completedScans: ScanProps[]; + completedScanIds: string[]; + scanDetails: { [key: string]: ScanEntity }[]; + uniqueRegions: string[]; + uniqueServices: string[]; + uniqueResourceTypes: string[]; +} + +export const FindingsFilters = ({ + providerUIDs, + providerDetails, + completedScanIds, + scanDetails, + uniqueRegions, + uniqueServices, + uniqueResourceTypes, +}: FindingsFiltersProps) => { + const { availableProviderUIDs, availableScans } = useRelatedFilters({ + providerUIDs, + providerDetails, + completedScanIds, + scanDetails, + enableScanRelation: true, + }); + + return ( + <> + + + ); +}; diff --git a/ui/components/findings/table/finding-detail.tsx b/ui/components/findings/table/finding-detail.tsx index e232b2a648..92c7bcb9e1 100644 --- a/ui/components/findings/table/finding-detail.tsx +++ b/ui/components/findings/table/finding-detail.tsx @@ -4,9 +4,8 @@ import { Snippet } from "@nextui-org/react"; import Link from "next/link"; import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; -import { InfoField } from "@/components/ui/entities"; +import { EntityInfoShort, InfoField } from "@/components/ui/entities"; import { DateWithTime } from "@/components/ui/entities/date-with-time"; -import { getProviderLogo } from "@/components/ui/entities/get-provider-logo"; import { SeverityBadge } from "@/components/ui/table/severity-badge"; import { FindingProps, ProviderType } from "@/types"; @@ -56,7 +55,7 @@ export const FindingDetail = ({ const attributes = finding.attributes; const resource = finding.relationships.resource.attributes; const scan = finding.relationships.scan.attributes; - const provider = finding.relationships.provider.attributes; + const providerDetails = finding.relationships.provider.attributes; return (
@@ -87,11 +86,12 @@ export const FindingDetail = ({ {/* Check Metadata */}
- - {getProviderLogo( - attributes.check_metadata.provider as ProviderType, - )} - + {attributes.check_metadata.servicename} @@ -259,7 +259,7 @@ export const FindingDetail = ({ {/* Add new Scan Details section */}
- {scan.name} + {scan.name || "N/A"} {scan.unique_resource_count} @@ -294,31 +294,6 @@ export const FindingDetail = ({ )}
- - {/* Provider Details section */} -
-
- - {getProviderLogo( - attributes.check_metadata.provider as ProviderType, - )} - - {provider.uid} -
- -
- {provider.alias && ( - {provider.alias} - )} - - - {provider.connection.connected ? "Connected" : "Disconnected"} - - -
-
); }; diff --git a/ui/components/icons/Icons.tsx b/ui/components/icons/Icons.tsx index 0cb893040f..f2cfc22c6e 100644 --- a/ui/components/icons/Icons.tsx +++ b/ui/components/icons/Icons.tsx @@ -1002,9 +1002,9 @@ export const AzureIcon: React.FC = ({ {...props} > ); diff --git a/ui/components/integrations/forms/index.ts b/ui/components/integrations/forms/index.ts new file mode 100644 index 0000000000..fc10a1a721 --- /dev/null +++ b/ui/components/integrations/forms/index.ts @@ -0,0 +1 @@ +export * from "./saml-config-form"; diff --git a/ui/components/integrations/forms/saml-config-form.tsx b/ui/components/integrations/forms/saml-config-form.tsx new file mode 100644 index 0000000000..579bf17075 --- /dev/null +++ b/ui/components/integrations/forms/saml-config-form.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react"; +import { useFormState } from "react-dom"; + +import { createSamlConfig, updateSamlConfig } from "@/actions/integrations"; +import { AddIcon } from "@/components/icons"; +import { useToast } from "@/components/ui"; +import { CustomButton, CustomServerInput } from "@/components/ui/custom"; +import { SnippetChip } from "@/components/ui/entities"; +import { FormButtons } from "@/components/ui/form"; + +export const SamlConfigForm = ({ + setIsOpen, + id, +}: { + setIsOpen: Dispatch>; + id: string; +}) => { + const [state, formAction, isPending] = useFormState( + id ? updateSamlConfig : createSamlConfig, + null, + ); + const [emailDomain, setEmailDomain] = useState(""); + const [uploadedFile, setUploadedFile] = useState<{ + name: string; + uploaded: boolean; + }>({ name: "", uploaded: false }); + const formRef = useRef(null); + const { toast } = useToast(); + + useEffect(() => { + if (state?.success) { + toast({ + title: "Configuration saved successfully", + description: state.success, + }); + setIsOpen(false); + } else if (state?.errors?.general) { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: state.errors.general, + }); + } + }, [state, toast, setIsOpen]); + + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { + setUploadedFile({ name: "", uploaded: false }); + return; + } + + // Check file extension + const isXmlFile = + file.name.toLowerCase().endsWith(".xml") || + file.type === "text/xml" || + file.type === "application/xml"; + + if (!isXmlFile) { + toast({ + variant: "destructive", + title: "Invalid file type", + description: "Please select a valid XML file (.xml extension).", + }); + // Clear the file input + event.target.value = ""; + setUploadedFile({ name: "", uploaded: false }); + + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result as string; + + // Basic XML validation + if (!content.trim().startsWith("<") || !content.includes(" { + toast({ + variant: "destructive", + title: "File read error", + description: "Failed to read the selected file.", + }); + // Clear the file input + event.target.value = ""; + setUploadedFile({ name: "", uploaded: false }); + }; + + reader.readAsText(file); + }; + + const acsUrl = emailDomain + ? `https://app.prowler.pro/saml/sp/consume/${emailDomain}` + : "https://app.prowler.pro/saml/sp/consume/your-domain.com"; + + return ( +
+ +
+ ) => { + setEmailDomain(e.target.value); + }} + /> + +
+ + Metadata XML File * + + { + const fileInput = document.getElementById( + "metadata_xml_file", + ) as HTMLInputElement; + if (fileInput) { + fileInput.click(); + } + }} + startContent={} + className={`h-10 justify-start rounded-medium border-2 text-default-500 ${ + state?.errors?.metadata_xml + ? "border-red-500" + : uploadedFile.uploaded + ? "border-green-500 bg-green-50 dark:bg-green-900/20" + : "border-default-200" + }`} + > + + {uploadedFile.uploaded ? ( + + {uploadedFile.name} + + ) : ( + "Choose File" + )} + + + + + +

+ Upload your Identity Provider's SAML metadata XML file +

+ + {state?.errors?.metadata_xml} + +
+
+ +
+

+ Identity Provider Configuration +

+ +
+
+ + ACS URL: + + +
+ +
+ + Audience: + + +
+ +
+ + Name ID Format: + + +
+ +
+ + Supported Assertion Attributes: + +
    +
  • • firstName
  • +
  • • lastName
  • +
  • • userType
  • +
  • • organization
  • +
+

+ Note: The userType attribute will be used to + assign the user's role. If the role does not exist, one will + be created with minimal permissions. You can assign permissions to + roles on the Roles page. +

+
+
+
+ + + + ); +}; diff --git a/ui/components/integrations/index.ts b/ui/components/integrations/index.ts new file mode 100644 index 0000000000..fc77086ec6 --- /dev/null +++ b/ui/components/integrations/index.ts @@ -0,0 +1,2 @@ +export * from "./forms"; +export * from "./saml-integration-card"; diff --git a/ui/components/integrations/saml-integration-card.tsx b/ui/components/integrations/saml-integration-card.tsx new file mode 100644 index 0000000000..2c7640b767 --- /dev/null +++ b/ui/components/integrations/saml-integration-card.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Card, CardBody, CardHeader } from "@nextui-org/react"; +import { CheckIcon } from "lucide-react"; +import { useState } from "react"; + +import { CustomAlertModal, CustomButton } from "@/components/ui/custom"; + +import { SamlConfigForm } from "./forms"; + +export const SamlIntegrationCard = ({ id }: { id: string }) => { + const [isSamlModalOpen, setIsSamlModalOpen] = useState(false); + + return ( + <> + + + + + + +
+
+

SAML SSO Integration

+ {id && } +
+

+ {id + ? "SAML Single Sign-On is enabled for this organization" + : "Configure SAML Single Sign-On for secure authentication"} +

+
+
+ +
+
+ Status: + + {id ? "Enabled" : "Disabled"} + +
+ setIsSamlModalOpen(true)} + > + {id ? "Update" : "Enable"} + +
+
+
+ + ); +}; diff --git a/ui/components/lighthouse/chat.tsx b/ui/components/lighthouse/chat.tsx index 4ed1aefd1b..cfc350f4da 100644 --- a/ui/components/lighthouse/chat.tsx +++ b/ui/components/lighthouse/chat.tsx @@ -16,14 +16,15 @@ interface SuggestedAction { } interface ChatProps { - hasApiKey: boolean; + hasConfig: boolean; + isActive: boolean; } interface ChatFormData { message: string; } -export const Chat = ({ hasApiKey }: ChatProps) => { +export const Chat = ({ hasConfig, isActive }: ChatProps) => { const { messages, handleSubmit, handleInputChange, append, status } = useChat( { api: "/api/lighthouse/analyst", @@ -119,17 +120,23 @@ export const Chat = ({ hasApiKey }: ChatProps) => { }, ]; + // Determine if chat should be disabled + const shouldDisableChat = !hasConfig || !isActive; + return (
- {!hasApiKey && ( + {shouldDisableChat && (

- OpenAI API Key Required + {!hasConfig + ? "OpenAI API Key Required" + : "OpenAI API Key Invalid"}

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

= ({ }; return ( -
-
-
-
- - {getProviderLogo(provider)} - -
-
{getIcon()}
- - {providerAlias || providerUID} - -
-
-
+
+
+ {getProviderLogo(provider)} + {getIcon()} + {providerAlias || providerUID}
); 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 d7c2d0bef1..a34b8ff166 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 @@ -59,7 +59,7 @@ export const M365CredentialsForm = ({ labelPlacement="inside" placeholder="Enter the User" variant="bordered" - isRequired + isRequired={false} isInvalid={!!control._formState.errors.user} /> diff --git a/ui/components/scans/index.ts b/ui/components/scans/index.ts index 9da9587028..b365e95f16 100644 --- a/ui/components/scans/index.ts +++ b/ui/components/scans/index.ts @@ -2,3 +2,4 @@ export * from "./auto-refresh"; export * from "./link-to-findings-from-scan"; export * from "./no-providers-added"; export * from "./no-providers-connected"; +export * from "./scans-filters"; diff --git a/ui/components/scans/no-providers-added.tsx b/ui/components/scans/no-providers-added.tsx index dd1015908e..5abc68a0ab 100644 --- a/ui/components/scans/no-providers-added.tsx +++ b/ui/components/scans/no-providers-added.tsx @@ -8,7 +8,7 @@ import { CustomButton } from "../ui/custom"; export const NoProvidersAdded = () => { return ( -
+
diff --git a/ui/components/scans/scans-filters.tsx b/ui/components/scans/scans-filters.tsx new file mode 100644 index 0000000000..caeff4e93a --- /dev/null +++ b/ui/components/scans/scans-filters.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { filterScans } from "@/components/filters/data-filters"; +import { FilterControls } from "@/components/filters/filter-controls"; +import { useRelatedFilters } from "@/hooks"; +import { FilterEntity, FilterType } from "@/types"; + +interface ScansFiltersProps { + providerUIDs: string[]; + providerDetails: { [uid: string]: FilterEntity }[]; +} + +export const ScansFilters = ({ + providerUIDs, + providerDetails, +}: ScansFiltersProps) => { + const { availableProviderUIDs } = useRelatedFilters({ + providerUIDs, + providerDetails, + enableScanRelation: false, + }); + + return ( + + ); +}; diff --git a/ui/components/scans/table/scan-detail.tsx b/ui/components/scans/table/scan-detail.tsx index f45662b9db..a2bf81c90d 100644 --- a/ui/components/scans/table/scan-detail.tsx +++ b/ui/components/scans/table/scan-detail.tsx @@ -2,15 +2,13 @@ import { Snippet } from "@nextui-org/react"; -import { ConnectionTrue } from "@/components/icons"; -import { ConnectionFalse } from "@/components/icons/Icons"; import { DateWithTime, EntityInfoShort, InfoField, } from "@/components/ui/entities"; import { StatusBadge } from "@/components/ui/table/status-badge"; -import { ProviderProps, ScanProps, TaskDetails } from "@/types"; +import { ProviderProps, ProviderType, ScanProps, TaskDetails } from "@/types"; const renderValue = (value: string | null | undefined) => { return value && value.trim() !== "" ? value : "-"; @@ -50,6 +48,7 @@ export const ScanDetail = ({ }: { scanDetails: ScanProps & { taskDetails?: TaskDetails; + // TODO: Remove the "?" once we have a proper provider details type providerDetails?: ProviderProps; }; }) => { @@ -60,12 +59,20 @@ export const ScanDetail = ({ return (
{/* Header */} -
- +
+ +
+
@@ -127,42 +134,6 @@ export const ScanDetail = ({
- - {/* Provider Details */} -
- {providerDetails ? ( -
- - - {providerDetails.connection.connected ? ( - - ) : ( - - )} - - - - -
- ) : ( - - No provider details available - - )} -
); }; diff --git a/ui/components/ui/custom/custom-dropdown-filter.tsx b/ui/components/ui/custom/custom-dropdown-filter.tsx index 9330b5306b..30bfea3a29 100644 --- a/ui/components/ui/custom/custom-dropdown-filter.tsx +++ b/ui/components/ui/custom/custom-dropdown-filter.tsx @@ -282,7 +282,10 @@ export const CustomDropdownFilter = ({ onValueChange={onSelectionChange} className="font-bold" > - {filter?.showSelectAll !== false && ( + {filterValues.length === 0 && ( + No results found + )} + {filter?.showSelectAll !== false && filterValues.length > 0 && ( <> )} - - {filterValues.map((value) => { - const entity: FilterEntity | undefined = - filter.valueLabelMapping?.find((entry) => entry[value])?.[ - value - ]; - - return ( - - {entity ? ( - isScanEntity(entity as ScanEntity) ? ( - - ) : ( - - ) - ) : ( + {filterValues.length > 0 && ( + + {filterValues.map((value) => { + const entity: FilterEntity | undefined = + filter.valueLabelMapping?.find((entry) => entry[value])?.[ value - )} - - ); - })} - + ]; + + return ( + + {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 index 58318d96c9..fea22923ac 100644 --- a/ui/components/ui/custom/custom-server-input.tsx +++ b/ui/components/ui/custom/custom-server-input.tsx @@ -13,6 +13,8 @@ interface CustomServerInputProps { isRequired?: boolean; isInvalid?: boolean; errorMessage?: string; + value?: string; + onChange?: (e: React.ChangeEvent) => void; } /** @@ -28,6 +30,8 @@ export const CustomServerInput = ({ isRequired = false, isInvalid = false, errorMessage, + value, + onChange, }: CustomServerInputProps) => { return (
@@ -42,6 +46,8 @@ export const CustomServerInput = ({ isRequired={isRequired} isInvalid={isInvalid} errorMessage={errorMessage} + value={value} + onChange={onChange} classNames={{ label: "tracking-tight font-light !text-default-500 text-xs !z-0", input: "text-default-500 text-small", diff --git a/ui/components/ui/entities/entity-info-short.tsx b/ui/components/ui/entities/entity-info-short.tsx index 2b2ca7d6f0..6d0a266678 100644 --- a/ui/components/ui/entities/entity-info-short.tsx +++ b/ui/components/ui/entities/entity-info-short.tsx @@ -13,6 +13,7 @@ interface EntityInfoProps { entityId?: string; hideCopyButton?: boolean; snippetWidth?: string; + showConnectionStatus?: boolean; } export const EntityInfoShort: React.FC = ({ @@ -20,11 +21,26 @@ export const EntityInfoShort: React.FC = ({ entityAlias, entityId, hideCopyButton = false, + showConnectionStatus = false, }) => { return (
-
{getProviderLogo(cloudProvider)}
+
+ {getProviderLogo(cloudProvider)} + {showConnectionStatus && ( + + + + )} +
{entityAlias && ( diff --git a/ui/components/ui/form/form-buttons.tsx b/ui/components/ui/form/form-buttons.tsx new file mode 100644 index 0000000000..302560df5a --- /dev/null +++ b/ui/components/ui/form/form-buttons.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { Dispatch, SetStateAction } from "react"; +import { useFormStatus } from "react-dom"; + +import { SaveIcon } from "@/components/icons"; + +import { CustomButton } from "../custom"; + +interface FormCancelButtonProps { + setIsOpen: Dispatch>; + children?: React.ReactNode; +} + +interface FormSubmitButtonProps { + children?: React.ReactNode; + loadingText?: string; +} + +interface FormButtonsProps { + setIsOpen: Dispatch>; + submitText?: string; + cancelText?: string; + loadingText?: string; +} + +export const FormCancelButton = ({ + setIsOpen, + children = "Cancel", +}: FormCancelButtonProps) => { + return ( + setIsOpen(false)} + > + {children} + + ); +}; + +export const FormSubmitButton = ({ + children = "Save", + loadingText = "Loading", +}: FormSubmitButtonProps) => { + const { pending } = useFormStatus(); + + return ( + } + > + {pending ? <>{loadingText} : {children}} + + ); +}; + +export const FormButtons = ({ + setIsOpen, + submitText = "Save", + cancelText = "Cancel", + loadingText = "Loading", +}: FormButtonsProps) => { + return ( +
+ {cancelText} + + + {submitText} + +
+ ); +}; diff --git a/ui/components/ui/form/index.ts b/ui/components/ui/form/index.ts index ddee43fd25..696b86aca3 100644 --- a/ui/components/ui/form/index.ts +++ b/ui/components/ui/form/index.ts @@ -1,2 +1,3 @@ export * from "./Form"; +export * from "./form-buttons"; export * from "./Label"; diff --git a/ui/components/ui/nav-bar/navbar.tsx b/ui/components/ui/nav-bar/navbar.tsx index 38906f72bf..6d7c633e17 100644 --- a/ui/components/ui/nav-bar/navbar.tsx +++ b/ui/components/ui/nav-bar/navbar.tsx @@ -1,4 +1,9 @@ +"use client"; + import { Icon } from "@iconify/react"; +import { BreadcrumbItem, Breadcrumbs } from "@nextui-org/react"; +import Link from "next/link"; +import { usePathname, useSearchParams } from "next/navigation"; import { ReactNode } from "react"; import { ThemeSwitch } from "@/components/ThemeSwitch"; @@ -13,25 +18,102 @@ interface NavbarProps { user: UserProfileProps; } +interface BreadcrumbItem { + name: string; + path: string; + isLast: boolean; +} + export function Navbar({ title, icon, user }: NavbarProps) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const generateBreadcrumbs = (): BreadcrumbItem[] => { + const pathSegments = pathname + .split("/") + .filter((segment) => segment !== ""); + + if (pathSegments.length === 0) { + return [{ name: "Home", path: "/", isLast: true }]; + } + + const breadcrumbs: BreadcrumbItem[] = []; + let currentPath = ""; + + pathSegments.forEach((segment, index) => { + currentPath += `/${segment}`; + const isLast = index === pathSegments.length - 1; + let displayName = segment.charAt(0).toUpperCase() + segment.slice(1); + + //special cases: + if (segment.includes("-")) { + displayName = segment + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); + } + + breadcrumbs.push({ + name: displayName, + path: currentPath, + isLast, + }); + }); + + return breadcrumbs; + }; + + const buildNavigationUrl = (paramToPreserve: string, path: string) => { + const paramValue = searchParams.get(paramToPreserve); + if (path === "/compliance" && paramValue) { + return `/compliance?${paramToPreserve}=${paramValue}`; + } + + return path; + }; + + const renderTitleWithIcon = (titleText: string, isLink: boolean = false) => ( + <> + {typeof icon === "string" ? ( + + ) : ( +
+ {icon} +
+ )} +

+ {titleText} +

+ + ); + + const breadcrumbs = generateBreadcrumbs(); + return (
- {typeof icon === "string" ? ( - - ) : ( -
- {icon} -
- )} -

{title}

+ + {breadcrumbs.map((breadcrumb) => ( + + {breadcrumb.isLast ? ( + renderTitleWithIcon(title) + ) : ( + +

+ {breadcrumb.name} +

+ + )} +
+ ))} +
diff --git a/ui/components/ui/sidebar/sheet-menu.tsx b/ui/components/ui/sidebar/sheet-menu.tsx index 43c2dc40c4..b3537d5dbf 100644 --- a/ui/components/ui/sidebar/sheet-menu.tsx +++ b/ui/components/ui/sidebar/sheet-menu.tsx @@ -5,7 +5,9 @@ import { ProwlerExtended } from "@/components/icons"; import { Sheet, SheetContent, + SheetDescription, SheetHeader, + SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; import { Menu } from "@/components/ui/sidebar/menu"; @@ -25,6 +27,8 @@ export function SheetMenu() { side="left" > + Sidebar +