Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c282626216 | ||
|
|
e81c7a3893 | ||
|
|
d6f26df2e8 | ||
|
|
ece74e15fd | ||
|
|
eea6d07259 | ||
|
|
4a6d7a5be2 | ||
|
|
883c5d4e56 | ||
|
|
f1f998c2fa | ||
|
|
5276e38f1d | ||
|
|
ad98a4747f | ||
|
|
5798321dc6 | ||
|
|
bf58728d29 | ||
|
|
fcea3b6570 | ||
|
|
965111245a | ||
|
|
f78a29206c | ||
|
|
c719d705e0 | ||
|
|
8948ee6868 | ||
|
|
24fb31e98f | ||
|
|
c8b193e658 | ||
|
|
6d27738c4d | ||
|
|
17b7becfdf | ||
|
|
cfa7f271d2 | ||
|
|
e61a97cb65 | ||
|
|
cd4a1ad8a7 | ||
|
|
e650d19a30 | ||
|
|
f930739a3d | ||
|
|
89fc698a0e | ||
|
|
6acb6bbf8e | ||
|
|
971424f822 | ||
|
|
9ba1ae1ced | ||
|
|
062db4cc70 | ||
|
|
dc4db10c41 | ||
|
|
68a542ef64 | ||
|
|
32f3787e18 | ||
|
|
6792bea319 | ||
|
|
ae4b43c137 | ||
|
|
d576c4f1c4 | ||
|
|
ddc0596aa2 | ||
|
|
636bdb6d0a | ||
|
|
4a839b0146 | ||
|
|
73e244dce5 | ||
|
|
d8ed70236b | ||
|
|
bcc96ab4f2 | ||
|
|
fd53a8c9d0 | ||
|
|
7b58d1dd56 | ||
|
|
7858c147f7 | ||
|
|
8e635b3bd4 | ||
|
|
2e97e37316 | ||
|
|
cd804836a1 | ||
|
|
d102ee2fd5 | ||
|
|
325e5739a2 | ||
|
|
98da3059b4 | ||
|
|
80fd5d1ba6 | ||
|
|
85242c7909 | ||
|
|
ea6ab406c8 | ||
|
|
cbf2a28bac | ||
|
|
5b1e7bb7f9 | ||
|
|
e108b2caed | ||
|
|
df1abb2152 | ||
|
|
e0465f2aa2 | ||
|
|
51467767cd | ||
|
|
bc71e7fb3b | ||
|
|
6a331c05e8 | ||
|
|
7ab503a096 | ||
|
|
b368190c9f | ||
|
|
8915fdff18 | ||
|
|
9bf108e9cc | ||
|
|
87708e39cf | ||
|
|
44927c44e9 | ||
|
|
71aa29cf24 | ||
|
|
aa14daf0db |
No files matched your search
@@ -6,6 +6,7 @@
|
||||
PROWLER_UI_VERSION="stable"
|
||||
AUTH_URL=http://localhost:3000
|
||||
API_BASE_URL=http://prowler-api:8080/api/v1
|
||||
NEXT_PUBLIC_API_BASE_URL=${API_BASE_URL}
|
||||
NEXT_PUBLIC_API_DOCS_URL=http://prowler-api:8080/api/v1/docs
|
||||
AUTH_TRUST_HOST=true
|
||||
UI_PORT=3000
|
||||
@@ -130,7 +131,7 @@ SENTRY_ENVIRONMENT=local
|
||||
SENTRY_RELEASE=local
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.6.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.7.5
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
@@ -142,8 +143,7 @@ SOCIAL_GITHUB_OAUTH_CLIENT_ID=""
|
||||
SOCIAL_GITHUB_OAUTH_CLIENT_SECRET=""
|
||||
|
||||
# Single Sign-On (SSO)
|
||||
SAML_PUBLIC_CERT=""
|
||||
SAML_PRIVATE_KEY=""
|
||||
SAML_SSO_CALLBACK_URL="${AUTH_URL}/api/auth/callback/saml"
|
||||
|
||||
# Lighthouse tracing
|
||||
LANGSMITH_TRACING=false
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Build and push container image (latest)
|
||||
# Comment the following line for testing
|
||||
|
||||
@@ -48,12 +48,12 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ./.github/codeql/api-codeql-config.yml
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -169,9 +169,9 @@ jobs:
|
||||
- name: Safety
|
||||
working-directory: ./api
|
||||
if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true'
|
||||
# 76352 and 76353 come from SDK, but they cannot upgrade it yet. It does not affect API
|
||||
# 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,76352,76353
|
||||
poetry run safety check --ignore 70612,66963,74429,76352,76353,77323
|
||||
|
||||
- name: Vulture
|
||||
working-directory: ./api
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
files_ignore: ${{ env.IGNORE_FILES }}
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true'
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
- name: Build Container
|
||||
if: steps.are-non-ignored-files-changed.outputs.any_changed == 'true'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: TruffleHog OSS
|
||||
uses: trufflesecurity/trufflehog@90694bf9af66e7536abc5824e7a87246dbf933cb # v3.88.35
|
||||
uses: trufflesecurity/trufflehog@6641d4ba5b684fffe195b9820345de1bf19f3181 # v3.89.2
|
||||
with:
|
||||
path: ./
|
||||
base: ${{ github.event.repository.default_branch }}
|
||||
|
||||
@@ -9,6 +9,8 @@ jobs:
|
||||
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"
|
||||
@@ -45,6 +47,7 @@ jobs:
|
||||
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:
|
||||
@@ -53,7 +56,7 @@ jobs:
|
||||
body-includes: '<!-- changelog-check -->'
|
||||
|
||||
- name: Comment on PR if changelog is missing
|
||||
if: steps.check_folders.outputs.missing_changelogs != ''
|
||||
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 }}
|
||||
@@ -67,7 +70,7 @@ jobs:
|
||||
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: steps.check_folders.outputs.missing_changelogs == ''
|
||||
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 }}
|
||||
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
AWS_REGION: ${{ env.AWS_REGION }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Build and push container image (latest)
|
||||
if: github.event_name == 'push'
|
||||
|
||||
@@ -56,12 +56,12 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ./.github/codeql/sdk-codeql-config.yml
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -30,6 +30,7 @@ env:
|
||||
# Container Registries
|
||||
PROWLERCLOUD_DOCKERHUB_REPOSITORY: prowlercloud
|
||||
PROWLERCLOUD_DOCKERHUB_IMAGE: prowler-ui
|
||||
NEXT_PUBLIC_API_BASE_URL: http://prowler-api:8080/api/v1
|
||||
|
||||
jobs:
|
||||
repository-check:
|
||||
@@ -76,7 +77,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Build and push container image (latest)
|
||||
# Comment the following line for testing
|
||||
@@ -86,6 +87,7 @@ jobs:
|
||||
context: ${{ env.WORKING_DIRECTORY }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${{ env.SHORT_SHA }}
|
||||
NEXT_PUBLIC_API_BASE_URL=${{ env.NEXT_PUBLIC_API_BASE_URL }}
|
||||
# Set push: false for testing
|
||||
push: true
|
||||
tags: |
|
||||
@@ -101,6 +103,7 @@ jobs:
|
||||
context: ${{ env.WORKING_DIRECTORY }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${{ env.RELEASE_TAG }}
|
||||
NEXT_PUBLIC_API_BASE_URL=${{ env.NEXT_PUBLIC_API_BASE_URL }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.RELEASE_TAG }}
|
||||
|
||||
@@ -48,12 +48,12 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||
uses: github/codeql-action/init@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ./.github/codeql/ui-codeql-config.yml
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18
|
||||
uses: github/codeql-action/analyze@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -34,21 +34,70 @@ 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:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
- name: Build Container
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
|
||||
@@ -6,7 +6,8 @@ LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget libicu72 \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget libicu72 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PowerShell
|
||||
@@ -46,10 +47,6 @@ ENV PATH="${HOME}/.local/bin:${PATH}"
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir poetry
|
||||
|
||||
# By default poetry does not compile Python source files to bytecode during installation.
|
||||
# This speeds up the installation process, but the first execution may take a little more
|
||||
# time because Python then compiles source files to bytecode automatically. If you want to
|
||||
# compile source files to bytecode during installation, you can use the --compile option
|
||||
RUN poetry install --compile && \
|
||||
rm -rf ~/.cache/pip
|
||||
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [v1.9.0] (Prowler UNRELEASED)
|
||||
## [v1.10.0] (Prowler UNRELEASED)
|
||||
|
||||
---
|
||||
|
||||
## [v1.9.0] (Prowler v5.8.0)
|
||||
|
||||
### Added
|
||||
- SSO with SAML support [(#7822)](https://github.com/prowler-cloud/prowler/pull/7822)
|
||||
- Support GCP Service Account key [(#7824)](https://github.com/prowler-cloud/prowler/pull/7824)
|
||||
- `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)
|
||||
- Optional `user` and `password` for M365 provider [(#7992)](https://github.com/prowler-cloud/prowler/pull/7992)
|
||||
|
||||
### Fixed
|
||||
- Scheduled scans are no longer deleted when their daily schedule run is disabled [(#8082)](https://github.com/prowler-cloud/prowler/pull/8082)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "about-time"
|
||||
@@ -880,7 +880,6 @@ description = "Foreign Function Interface for Python calling C code."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
markers = "platform_python_implementation != \"PyPy\""
|
||||
files = [
|
||||
{file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
|
||||
{file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
|
||||
@@ -950,6 +949,7 @@ files = [
|
||||
{file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"},
|
||||
{file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"},
|
||||
]
|
||||
markers = {dev = "platform_python_implementation != \"PyPy\""}
|
||||
|
||||
[package.dependencies]
|
||||
pycparser = "*"
|
||||
@@ -3854,26 +3854,26 @@ 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]]
|
||||
name = "prowler"
|
||||
version = "5.6.0"
|
||||
version = "5.8.0"
|
||||
description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks."
|
||||
optional = false
|
||||
python-versions = ">3.9.1,<3.13"
|
||||
@@ -3921,6 +3921,7 @@ numpy = "2.0.2"
|
||||
pandas = "2.2.3"
|
||||
py-ocsf-models = "0.3.1"
|
||||
pydantic = "1.10.21"
|
||||
pygithub = "2.5.0"
|
||||
python-dateutil = ">=2.9.0.post0,<3.0.0"
|
||||
pytz = "2025.1"
|
||||
schema = "0.7.7"
|
||||
@@ -3933,7 +3934,7 @@ tzlocal = "5.3.1"
|
||||
type = "git"
|
||||
url = "https://github.com/prowler-cloud/prowler.git"
|
||||
reference = "master"
|
||||
resolved_reference = "9828824b737b8deda61f4a6646b54e0ad45033b9"
|
||||
resolved_reference = "ea97de7f43a2063476b49f7697bb6c7b51137c11"
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
@@ -4110,11 +4111,11 @@ description = "C parser in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
markers = "platform_python_implementation != \"PyPy\""
|
||||
files = [
|
||||
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
|
||||
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
|
||||
]
|
||||
markers = {dev = "platform_python_implementation != \"PyPy\""}
|
||||
|
||||
[[package]]
|
||||
name = "pycurl"
|
||||
@@ -4225,6 +4226,26 @@ typing-extensions = ">=4.2.0"
|
||||
dotenv = ["python-dotenv (>=0.10.4)"]
|
||||
email = ["email-validator (>=1.0.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "pygithub"
|
||||
version = "2.5.0"
|
||||
description = "Use the full Github API v3"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "PyGithub-2.5.0-py3-none-any.whl", hash = "sha256:b0b635999a658ab8e08720bdd3318893ff20e2275f6446fcf35bf3f44f2c0fd2"},
|
||||
{file = "pygithub-2.5.0.tar.gz", hash = "sha256:e1613ac508a9be710920d26eb18b1905ebd9926aa49398e88151c1b526aad3cf"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
Deprecated = "*"
|
||||
pyjwt = {version = ">=2.4.0", extras = ["crypto"]}
|
||||
pynacl = ">=1.4.0"
|
||||
requests = ">=2.14.0"
|
||||
typing-extensions = ">=4.0.0"
|
||||
urllib3 = ">=1.26.0"
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.1"
|
||||
@@ -4289,6 +4310,33 @@ tomlkit = ">=0.10.1"
|
||||
spelling = ["pyenchant (>=3.2,<4.0)"]
|
||||
testutils = ["gitpython (>3)"]
|
||||
|
||||
[[package]]
|
||||
name = "pynacl"
|
||||
version = "1.5.0"
|
||||
description = "Python binding to the Networking and Cryptography (NaCl) library"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"},
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"},
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394"},
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d"},
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858"},
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b"},
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff"},
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543"},
|
||||
{file = "PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93"},
|
||||
{file = "PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
cffi = ">=1.4.1"
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]
|
||||
tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.2.3"
|
||||
|
||||
@@ -3,14 +3,7 @@ from django.db import transaction
|
||||
|
||||
from api.db_router import MainRouter
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import (
|
||||
Membership,
|
||||
Role,
|
||||
SAMLConfiguration,
|
||||
Tenant,
|
||||
User,
|
||||
UserRoleRelationship,
|
||||
)
|
||||
from api.models import Membership, Role, Tenant, User, UserRoleRelationship
|
||||
|
||||
|
||||
class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
@@ -24,8 +17,8 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
def pre_social_login(self, request, sociallogin):
|
||||
# Link existing accounts with the same email address
|
||||
email = sociallogin.account.extra_data.get("email")
|
||||
if sociallogin.account.provider == "saml":
|
||||
email = sociallogin.user.email
|
||||
# if sociallogin.account.provider == "saml":
|
||||
# email = sociallogin.user.email
|
||||
if email:
|
||||
existing_user = self.get_user_by_email(email)
|
||||
if existing_user:
|
||||
@@ -38,86 +31,98 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
"""
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
user = super().save_user(request, sociallogin, form)
|
||||
provider = sociallogin.account.provider
|
||||
extra = sociallogin.account.extra_data
|
||||
|
||||
if provider == "saml":
|
||||
# Handle SAML-specific logic
|
||||
user.first_name = extra.get("firstName", [""])[0]
|
||||
user.last_name = extra.get("lastName", [""])[0]
|
||||
user.company_name = extra.get("organization", [""])[0]
|
||||
user.name = f"{user.first_name} {user.last_name}".strip()
|
||||
# if provider == "saml":
|
||||
# # Handle SAML-specific logic
|
||||
# user.first_name = (
|
||||
# extra.get("firstName", [""])[0] if extra.get("firstName") else ""
|
||||
# )
|
||||
# user.last_name = (
|
||||
# extra.get("lastName", [""])[0] if extra.get("lastName") else ""
|
||||
# )
|
||||
# user.company_name = (
|
||||
# extra.get("organization", [""])[0]
|
||||
# if extra.get("organization")
|
||||
# else ""
|
||||
# )
|
||||
# user.name = f"{user.first_name} {user.last_name}".strip()
|
||||
# if user.name == "":
|
||||
# user.name = "N/A"
|
||||
# user.save(using=MainRouter.admin_db)
|
||||
|
||||
# email_domain = user.email.split("@")[-1]
|
||||
# tenant = (
|
||||
# SAMLConfiguration.objects.using(MainRouter.admin_db)
|
||||
# .get(email_domain=email_domain)
|
||||
# .tenant
|
||||
# )
|
||||
|
||||
# with rls_transaction(str(tenant.id)):
|
||||
# role_name = (
|
||||
# extra.get("userType", ["saml_default_role"])[0].strip()
|
||||
# if extra.get("userType")
|
||||
# else "saml_default_role"
|
||||
# )
|
||||
|
||||
# try:
|
||||
# role = Role.objects.using(MainRouter.admin_db).get(
|
||||
# name=role_name, tenant_id=tenant.id
|
||||
# )
|
||||
# except Role.DoesNotExist:
|
||||
# role = Role.objects.using(MainRouter.admin_db).create(
|
||||
# name=role_name,
|
||||
# tenant_id=tenant.id,
|
||||
# manage_users=False,
|
||||
# manage_account=False,
|
||||
# manage_billing=False,
|
||||
# manage_providers=False,
|
||||
# manage_integrations=False,
|
||||
# manage_scans=False,
|
||||
# unlimited_visibility=False,
|
||||
# )
|
||||
|
||||
# Membership.objects.using(MainRouter.admin_db).create(
|
||||
# user=user,
|
||||
# tenant=tenant,
|
||||
# role=Membership.RoleChoices.MEMBER,
|
||||
# )
|
||||
|
||||
# UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
# user=user,
|
||||
# role=role,
|
||||
# tenant_id=tenant.id,
|
||||
# )
|
||||
|
||||
# Handle other providers (e.g., GitHub, Google)
|
||||
user.save(using=MainRouter.admin_db)
|
||||
social_account_name = extra.get("name")
|
||||
if social_account_name:
|
||||
user.name = social_account_name
|
||||
user.save(using=MainRouter.admin_db)
|
||||
|
||||
email_domain = user.email.split("@")[-1]
|
||||
tenant = (
|
||||
SAMLConfiguration.objects.using(MainRouter.admin_db)
|
||||
.get(email_domain=email_domain)
|
||||
.tenant
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(
|
||||
name=f"{user.email.split('@')[0]} default tenant"
|
||||
)
|
||||
with rls_transaction(str(tenant.id)):
|
||||
Membership.objects.using(MainRouter.admin_db).create(
|
||||
user=user, tenant=tenant, role=Membership.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
with rls_transaction(str(tenant.id)):
|
||||
role_name = extra.get("userType", ["saml_default_role"])[0].strip()
|
||||
|
||||
try:
|
||||
role = Role.objects.using(MainRouter.admin_db).get(
|
||||
name=role_name, tenant_id=tenant.id
|
||||
)
|
||||
except Role.DoesNotExist:
|
||||
role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name=role_name,
|
||||
tenant_id=tenant.id,
|
||||
manage_users=False,
|
||||
manage_account=False,
|
||||
manage_billing=False,
|
||||
manage_providers=False,
|
||||
manage_integrations=False,
|
||||
manage_scans=False,
|
||||
unlimited_visibility=False,
|
||||
)
|
||||
|
||||
Membership.objects.using(MainRouter.admin_db).create(
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
role=Membership.RoleChoices.MEMBER,
|
||||
)
|
||||
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user,
|
||||
role=role,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
else:
|
||||
# Handle other providers (e.g., GitHub, Google)
|
||||
user.save(using=MainRouter.admin_db)
|
||||
social_account_name = extra.get("name")
|
||||
if social_account_name:
|
||||
user.name = social_account_name
|
||||
user.save(using=MainRouter.admin_db)
|
||||
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(
|
||||
name=f"{user.email.split('@')[0]} default tenant"
|
||||
role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name="admin",
|
||||
tenant_id=tenant.id,
|
||||
manage_users=True,
|
||||
manage_account=True,
|
||||
manage_billing=True,
|
||||
manage_providers=True,
|
||||
manage_integrations=True,
|
||||
manage_scans=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user,
|
||||
role=role,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
with rls_transaction(str(tenant.id)):
|
||||
Membership.objects.using(MainRouter.admin_db).create(
|
||||
user=user, tenant=tenant, role=Membership.RoleChoices.OWNER
|
||||
)
|
||||
role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name="admin",
|
||||
tenant_id=tenant.id,
|
||||
manage_users=True,
|
||||
manage_account=True,
|
||||
manage_billing=True,
|
||||
manage_providers=True,
|
||||
manage_integrations=True,
|
||||
manage_scans=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user,
|
||||
role=role,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
return user
|
||||
@@ -11,7 +11,7 @@ import api.rls
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0030_samlconfigurations"),
|
||||
("api", "0029_findings_check_index_parent"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -54,6 +54,7 @@ class Migration(migrations.Migration):
|
||||
("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,
|
||||
),
|
||||
@@ -1,120 +0,0 @@
|
||||
# Generated by Django 5.1.8 on 2025-05-15 09:54
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
import api.rls
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0029_findings_check_index_parent"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SAMLDomainIndex",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("email_domain", models.CharField(max_length=254, unique=True)),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "saml_domain_index",
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="samldomainindex",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("email_domain", "tenant"),
|
||||
name="unique_resources_by_email_domain",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="samldomainindex",
|
||||
constraint=api.rls.BaseSecurityConstraint(
|
||||
name="statements_on_samldomainindex",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SAMLConfiguration",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"email_domain",
|
||||
models.CharField(
|
||||
help_text="Email domain used to identify the tenant, e.g. prowlerdemo.com",
|
||||
max_length=254,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"metadata_xml",
|
||||
models.TextField(
|
||||
help_text="Raw IdP metadata XML to configure SingleSignOnService, certificates, etc."
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"tenant",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "saml_configurations",
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="samlconfiguration",
|
||||
constraint=api.rls.RowLevelSecurityConstraint(
|
||||
"tenant_id",
|
||||
name="rls_on_samlconfiguration",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="samlconfiguration",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("tenant",), name="unique_samlconfig_per_tenant"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="integration",
|
||||
name="integration_type",
|
||||
field=api.db_utils.IntegrationTypeEnumField(
|
||||
choices=[
|
||||
("amazon_s3", "Amazon S3"),
|
||||
("aws_security_hub", "AWS Security Hub"),
|
||||
("jira", "JIRA"),
|
||||
("slack", "Slack"),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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", "0030_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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,20 +1,15 @@
|
||||
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, InvalidToken
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractBaseUser
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.contrib.postgres.search import SearchVector, SearchVectorField
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinLengthValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
@@ -26,7 +21,6 @@ from psqlextra.models import PostgresPartitionedModel
|
||||
from psqlextra.types import PostgresPartitioningMethod
|
||||
from uuid6 import uuid7
|
||||
|
||||
from api.db_router import MainRouter
|
||||
from api.db_utils import (
|
||||
CustomUserManager,
|
||||
FindingDeltaEnumField,
|
||||
@@ -437,7 +431,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)
|
||||
|
||||
@@ -1370,219 +1364,242 @@ class IntegrationProviderRelationship(RowLevelSecurityProtectedModel):
|
||||
]
|
||||
|
||||
|
||||
class SAMLDomainIndex(models.Model):
|
||||
"""
|
||||
Public index of SAML domains. No RLS. Used for fast lookup in SAML login flow.
|
||||
"""
|
||||
# class SAMLToken(models.Model):
|
||||
# id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
# inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
# updated_at = models.DateTimeField(auto_now=True, editable=False)
|
||||
# expires_at = models.DateTimeField(editable=False)
|
||||
# token = models.JSONField(unique=True)
|
||||
# user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
email_domain = models.CharField(max_length=254, unique=True)
|
||||
tenant = models.ForeignKey("Tenant", on_delete=models.CASCADE)
|
||||
# class Meta:
|
||||
# db_table = "saml_tokens"
|
||||
|
||||
class Meta:
|
||||
db_table = "saml_domain_index"
|
||||
# def save(self, *args, **kwargs):
|
||||
# if not self.expires_at:
|
||||
# self.expires_at = datetime.now(timezone.utc) + timedelta(seconds=15)
|
||||
# super().save(*args, **kwargs)
|
||||
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("email_domain", "tenant"),
|
||||
name="unique_resources_by_email_domain",
|
||||
),
|
||||
BaseSecurityConstraint(
|
||||
name="statements_on_%(class)s",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
]
|
||||
# def is_expired(self) -> bool:
|
||||
# return datetime.now(timezone.utc) >= self.expires_at
|
||||
|
||||
|
||||
class SAMLConfiguration(RowLevelSecurityProtectedModel):
|
||||
"""
|
||||
Stores per-tenant SAML settings, including email domain and IdP metadata.
|
||||
Automatically syncs to a SocialApp instance on save.
|
||||
# class SAMLDomainIndex(models.Model):
|
||||
# """
|
||||
# Public index of SAML domains. No RLS. Used for fast lookup in SAML login flow.
|
||||
# """
|
||||
|
||||
Note:
|
||||
This model exists to provide a tenant-aware abstraction over SAML configuration.
|
||||
It supports row-level security, custom validation, and metadata parsing, enabling
|
||||
Prowler to expose a clean API and admin interface for managing SAML integrations.
|
||||
# email_domain = models.CharField(max_length=254, unique=True)
|
||||
# tenant = models.ForeignKey("Tenant", on_delete=models.CASCADE)
|
||||
|
||||
Although Django Allauth uses the SocialApp model to store provider configuration,
|
||||
it is not designed for multi-tenant use. SocialApp lacks support for tenant scoping,
|
||||
email domain mapping, and structured metadata handling.
|
||||
# class Meta:
|
||||
# db_table = "saml_domain_index"
|
||||
|
||||
By managing SAMLConfiguration separately, we ensure:
|
||||
- Strong isolation between tenants via RLS.
|
||||
- Ownership of raw IdP metadata and its validation.
|
||||
- An explicit link between SAML config and business-level identifiers (e.g. email domain).
|
||||
- Programmatic transformation into the SocialApp format used by Allauth.
|
||||
# constraints = [
|
||||
# models.UniqueConstraint(
|
||||
# fields=("email_domain", "tenant"),
|
||||
# name="unique_resources_by_email_domain",
|
||||
# ),
|
||||
# BaseSecurityConstraint(
|
||||
# name="statements_on_%(class)s",
|
||||
# statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
# ),
|
||||
# ]
|
||||
|
||||
In short, this model acts as a secure and user-friendly layer over Allauth's lower-level primitives.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
email_domain = models.CharField(
|
||||
max_length=254,
|
||||
unique=True,
|
||||
help_text="Email domain used to identify the tenant, e.g. prowlerdemo.com",
|
||||
)
|
||||
metadata_xml = models.TextField(
|
||||
help_text="Raw IdP metadata XML to configure SingleSignOnService, certificates, etc."
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
# class SAMLConfiguration(RowLevelSecurityProtectedModel):
|
||||
# """
|
||||
# Stores per-tenant SAML settings, including email domain and IdP metadata.
|
||||
# Automatically syncs to a SocialApp instance on save.
|
||||
|
||||
class JSONAPIMeta:
|
||||
resource_name = "saml-configurations"
|
||||
# Note:
|
||||
# This model exists to provide a tenant-aware abstraction over SAML configuration.
|
||||
# It supports row-level security, custom validation, and metadata parsing, enabling
|
||||
# Prowler to expose a clean API and admin interface for managing SAML integrations.
|
||||
|
||||
class Meta:
|
||||
db_table = "saml_configurations"
|
||||
# Although Django Allauth uses the SocialApp model to store provider configuration,
|
||||
# it is not designed for multi-tenant use. SocialApp lacks support for tenant scoping,
|
||||
# email domain mapping, and structured metadata handling.
|
||||
|
||||
constraints = [
|
||||
RowLevelSecurityConstraint(
|
||||
field="tenant_id",
|
||||
name="rls_on_%(class)s",
|
||||
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
),
|
||||
# 1 config per tenant
|
||||
models.UniqueConstraint(
|
||||
fields=["tenant"],
|
||||
name="unique_samlconfig_per_tenant",
|
||||
),
|
||||
]
|
||||
# By managing SAMLConfiguration separately, we ensure:
|
||||
# - Strong isolation between tenants via RLS.
|
||||
# - Ownership of raw IdP metadata and its validation.
|
||||
# - An explicit link between SAML config and business-level identifiers (e.g. email domain).
|
||||
# - Programmatic transformation into the SocialApp format used by Allauth.
|
||||
|
||||
def clean(self, old_email_domain=None):
|
||||
# Domain must not contain @
|
||||
if "@" in self.email_domain:
|
||||
raise ValidationError({"email_domain": "Domain must not contain @"})
|
||||
# In short, this model acts as a secure and user-friendly layer over Allauth's lower-level primitives.
|
||||
# """
|
||||
|
||||
# Enforce at most one config per tenant
|
||||
qs = SAMLConfiguration.objects.filter(tenant=self.tenant)
|
||||
# Exclude ourselves in case of update
|
||||
if self.pk:
|
||||
qs = qs.exclude(pk=self.pk)
|
||||
if qs.exists():
|
||||
raise ValidationError(
|
||||
{"tenant": "A SAML configuration already exists for this tenant."}
|
||||
)
|
||||
# id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
# email_domain = models.CharField(
|
||||
# max_length=254,
|
||||
# unique=True,
|
||||
# help_text="Email domain used to identify the tenant, e.g. prowlerdemo.com",
|
||||
# )
|
||||
# metadata_xml = models.TextField(
|
||||
# help_text="Raw IdP metadata XML to configure SingleSignOnService, certificates, etc."
|
||||
# )
|
||||
# created_at = models.DateTimeField(auto_now_add=True)
|
||||
# updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# The email domain must be unique in the entire system
|
||||
qs = SAMLConfiguration.objects.using(MainRouter.admin_db).filter(
|
||||
email_domain__iexact=self.email_domain
|
||||
)
|
||||
if qs.exists() and old_email_domain != self.email_domain:
|
||||
raise ValidationError(
|
||||
{"tenant": "There is a problem with your email domain."}
|
||||
)
|
||||
# class JSONAPIMeta:
|
||||
# resource_name = "saml-configurations"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.email_domain = self.email_domain.strip().lower()
|
||||
is_create = not SAMLConfiguration.objects.filter(pk=self.pk).exists()
|
||||
# class Meta:
|
||||
# db_table = "saml_configurations"
|
||||
|
||||
if not is_create:
|
||||
old = SAMLConfiguration.objects.get(pk=self.pk)
|
||||
old_email_domain = old.email_domain
|
||||
old_metadata_xml = old.metadata_xml
|
||||
else:
|
||||
old_email_domain = None
|
||||
old_metadata_xml = None
|
||||
# constraints = [
|
||||
# RowLevelSecurityConstraint(
|
||||
# field="tenant_id",
|
||||
# name="rls_on_%(class)s",
|
||||
# statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
|
||||
# ),
|
||||
# # 1 config per tenant
|
||||
# models.UniqueConstraint(
|
||||
# fields=["tenant"],
|
||||
# name="unique_samlconfig_per_tenant",
|
||||
# ),
|
||||
# ]
|
||||
|
||||
self.clean(old_email_domain)
|
||||
super().save(*args, **kwargs)
|
||||
# def clean(self, old_email_domain=None):
|
||||
# # Domain must not contain @
|
||||
# if "@" in self.email_domain:
|
||||
# raise ValidationError({"email_domain": "Domain must not contain @"})
|
||||
|
||||
if is_create or (
|
||||
old_email_domain != self.email_domain
|
||||
or old_metadata_xml != self.metadata_xml
|
||||
):
|
||||
self._sync_social_app(old_email_domain)
|
||||
# # Enforce at most one config per tenant
|
||||
# qs = SAMLConfiguration.objects.filter(tenant=self.tenant)
|
||||
# # Exclude ourselves in case of update
|
||||
# if self.pk:
|
||||
# qs = qs.exclude(pk=self.pk)
|
||||
# if qs.exists():
|
||||
# raise ValidationError(
|
||||
# {"tenant": "A SAML configuration already exists for this tenant."}
|
||||
# )
|
||||
|
||||
# Sync the public index
|
||||
if not is_create and old_email_domain and old_email_domain != self.email_domain:
|
||||
SAMLDomainIndex.objects.filter(email_domain=old_email_domain).delete()
|
||||
# # The email domain must be unique in the entire system
|
||||
# qs = SAMLConfiguration.objects.using(MainRouter.admin_db).filter(
|
||||
# email_domain__iexact=self.email_domain
|
||||
# )
|
||||
# if qs.exists() and old_email_domain != self.email_domain:
|
||||
# raise ValidationError(
|
||||
# {"tenant": "There is a problem with your email domain."}
|
||||
# )
|
||||
|
||||
# Create/update the new domain index
|
||||
SAMLDomainIndex.objects.update_or_create(
|
||||
email_domain=self.email_domain, defaults={"tenant": self.tenant}
|
||||
)
|
||||
# def save(self, *args, **kwargs):
|
||||
# self.email_domain = self.email_domain.strip().lower()
|
||||
# is_create = not SAMLConfiguration.objects.filter(pk=self.pk).exists()
|
||||
|
||||
def _parse_metadata(self):
|
||||
"""
|
||||
Parse the raw IdP metadata XML and extract:
|
||||
- entity_id
|
||||
- sso_url
|
||||
- slo_url (may be None)
|
||||
- x509cert (required)
|
||||
"""
|
||||
ns = {
|
||||
"md": "urn:oasis:names:tc:SAML:2.0:metadata",
|
||||
"ds": "http://www.w3.org/2000/09/xmldsig#",
|
||||
}
|
||||
try:
|
||||
root = ET.fromstring(self.metadata_xml)
|
||||
except ET.ParseError as e:
|
||||
raise ValidationError({"metadata_xml": f"Invalid XML: {e}"})
|
||||
# if not is_create:
|
||||
# old = SAMLConfiguration.objects.get(pk=self.pk)
|
||||
# old_email_domain = old.email_domain
|
||||
# old_metadata_xml = old.metadata_xml
|
||||
# else:
|
||||
# old_email_domain = None
|
||||
# old_metadata_xml = None
|
||||
|
||||
# Entity ID
|
||||
entity_id = root.attrib.get("entityID")
|
||||
# self.clean(old_email_domain)
|
||||
# super().save(*args, **kwargs)
|
||||
|
||||
# SSO endpoint (must exist)
|
||||
sso = root.find(".//md:IDPSSODescriptor/md:SingleSignOnService", ns)
|
||||
if sso is None or "Location" not in sso.attrib:
|
||||
raise ValidationError(
|
||||
{"metadata_xml": "Missing SingleSignOnService in metadata."}
|
||||
)
|
||||
sso_url = sso.attrib["Location"]
|
||||
# if is_create or (
|
||||
# old_email_domain != self.email_domain
|
||||
# or old_metadata_xml != self.metadata_xml
|
||||
# ):
|
||||
# self._sync_social_app(old_email_domain)
|
||||
|
||||
# SLO endpoint (optional)
|
||||
slo = root.find(".//md:IDPSSODescriptor/md:SingleLogoutService", ns)
|
||||
slo_url = slo.attrib.get("Location") if slo is not None else None
|
||||
# # Sync the public index
|
||||
# if not is_create and old_email_domain and old_email_domain != self.email_domain:
|
||||
# SAMLDomainIndex.objects.filter(email_domain=old_email_domain).delete()
|
||||
|
||||
# X.509 certificate (required)
|
||||
cert = root.find(
|
||||
'.//md:KeyDescriptor[@use="signing"]/ds:KeyInfo/ds:X509Data/ds:X509Certificate',
|
||||
ns,
|
||||
)
|
||||
if cert is None or not cert.text or not cert.text.strip():
|
||||
raise ValidationError(
|
||||
{
|
||||
"metadata_xml": 'Metadata must include a <ds:X509Certificate> under <KeyDescriptor use="signing">.'
|
||||
}
|
||||
)
|
||||
x509cert = cert.text.strip()
|
||||
# # Create/update the new domain index
|
||||
# SAMLDomainIndex.objects.update_or_create(
|
||||
# email_domain=self.email_domain, defaults={"tenant": self.tenant}
|
||||
# )
|
||||
|
||||
return {
|
||||
"entity_id": entity_id,
|
||||
"sso_url": sso_url,
|
||||
"slo_url": slo_url,
|
||||
"x509cert": x509cert,
|
||||
}
|
||||
# def _parse_metadata(self):
|
||||
# """
|
||||
# Parse the raw IdP metadata XML and extract:
|
||||
# - entity_id
|
||||
# - sso_url
|
||||
# - slo_url (may be None)
|
||||
# - x509cert (required)
|
||||
# """
|
||||
# ns = {
|
||||
# "md": "urn:oasis:names:tc:SAML:2.0:metadata",
|
||||
# "ds": "http://www.w3.org/2000/09/xmldsig#",
|
||||
# }
|
||||
# try:
|
||||
# root = ET.fromstring(self.metadata_xml)
|
||||
# except ET.ParseError as e:
|
||||
# raise ValidationError({"metadata_xml": f"Invalid XML: {e}"})
|
||||
|
||||
def _sync_social_app(self, previous_email_domain=None):
|
||||
"""
|
||||
Create or update the corresponding SocialApp based on email_domain.
|
||||
If the domain changed, update the matching SocialApp.
|
||||
"""
|
||||
idp_settings = self._parse_metadata()
|
||||
settings_dict = SOCIALACCOUNT_PROVIDERS["saml"].copy()
|
||||
settings_dict["idp"] = idp_settings
|
||||
# # Entity ID
|
||||
# entity_id = root.attrib.get("entityID")
|
||||
|
||||
current_site = Site.objects.get(id=settings.SITE_ID)
|
||||
# # SSO endpoint (must exist)
|
||||
# sso = root.find(".//md:IDPSSODescriptor/md:SingleSignOnService", ns)
|
||||
# if sso is None or "Location" not in sso.attrib:
|
||||
# raise ValidationError(
|
||||
# {"metadata_xml": "Missing SingleSignOnService in metadata."}
|
||||
# )
|
||||
# sso_url = sso.attrib["Location"]
|
||||
|
||||
social_app_qs = SocialApp.objects.filter(
|
||||
provider="saml", client_id=previous_email_domain or self.email_domain
|
||||
)
|
||||
# # SLO endpoint (optional)
|
||||
# slo = root.find(".//md:IDPSSODescriptor/md:SingleLogoutService", ns)
|
||||
# slo_url = slo.attrib.get("Location") if slo is not None else None
|
||||
|
||||
if social_app_qs.exists():
|
||||
social_app = social_app_qs.first()
|
||||
social_app.client_id = self.email_domain
|
||||
social_app.name = f"{self.tenant.name} SAML ({self.email_domain})"
|
||||
social_app.settings = settings_dict
|
||||
social_app.save()
|
||||
social_app.sites.set([current_site])
|
||||
else:
|
||||
social_app = SocialApp.objects.create(
|
||||
provider="saml",
|
||||
client_id=self.email_domain,
|
||||
name=f"{self.tenant.name} SAML ({self.email_domain})",
|
||||
settings=settings_dict,
|
||||
)
|
||||
social_app.sites.set([current_site])
|
||||
# # X.509 certificate (required)
|
||||
# cert = root.find(
|
||||
# './/md:KeyDescriptor[@use="signing"]/ds:KeyInfo/ds:X509Data/ds:X509Certificate',
|
||||
# ns,
|
||||
# )
|
||||
# if cert is None or not cert.text or not cert.text.strip():
|
||||
# raise ValidationError(
|
||||
# {
|
||||
# "metadata_xml": 'Metadata must include a <ds:X509Certificate> under <KeyDescriptor use="signing">.'
|
||||
# }
|
||||
# )
|
||||
# x509cert = cert.text.strip()
|
||||
|
||||
# return {
|
||||
# "entity_id": entity_id,
|
||||
# "sso_url": sso_url,
|
||||
# "slo_url": slo_url,
|
||||
# "x509cert": x509cert,
|
||||
# }
|
||||
|
||||
# def _sync_social_app(self, previous_email_domain=None):
|
||||
# """
|
||||
# Create or update the corresponding SocialApp based on email_domain.
|
||||
# If the domain changed, update the matching SocialApp.
|
||||
# """
|
||||
# idp_settings = self._parse_metadata()
|
||||
# settings_dict = SOCIALACCOUNT_PROVIDERS["saml"].copy()
|
||||
# settings_dict["idp"] = idp_settings
|
||||
|
||||
# current_site = Site.objects.get(id=settings.SITE_ID)
|
||||
|
||||
# social_app_qs = SocialApp.objects.filter(
|
||||
# provider="saml", client_id=previous_email_domain or self.email_domain
|
||||
# )
|
||||
|
||||
# client_id = self.email_domain[:191]
|
||||
# name = f"SAML-{self.email_domain}"[:40]
|
||||
|
||||
# if social_app_qs.exists():
|
||||
# social_app = social_app_qs.first()
|
||||
# social_app.client_id = client_id
|
||||
# social_app.name = name
|
||||
# social_app.settings = settings_dict
|
||||
# social_app.save()
|
||||
# social_app.sites.set([current_site])
|
||||
# else:
|
||||
# social_app = SocialApp.objects.create(
|
||||
# provider="saml",
|
||||
# client_id=client_id,
|
||||
# name=name,
|
||||
# settings=settings_dict,
|
||||
# )
|
||||
# social_app.sites.set([current_site])
|
||||
|
||||
|
||||
class ResourceScanSummary(RowLevelSecurityProtectedModel):
|
||||
|
||||
@@ -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
|
||||
@@ -4978,199 +5152,6 @@ paths:
|
||||
responses:
|
||||
'204':
|
||||
description: Relationship deleted successfully
|
||||
/api/v1/saml-config:
|
||||
get:
|
||||
operationId: saml_config_list
|
||||
description: Returns all the SAML-based SSO configurations associated with the
|
||||
current tenant.
|
||||
summary: List all SSO configurations
|
||||
parameters:
|
||||
- in: query
|
||||
name: fields[saml-configurations]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- email_domain
|
||||
- metadata_xml
|
||||
- created_at
|
||||
- updated_at
|
||||
description: endpoint return only specific fields in the response on a per-type
|
||||
basis by including a fields[TYPE] query parameter.
|
||||
explode: false
|
||||
- name: filter[search]
|
||||
required: false
|
||||
in: query
|
||||
description: A search term.
|
||||
schema:
|
||||
type: string
|
||||
- name: page[number]
|
||||
required: false
|
||||
in: query
|
||||
description: A page number within the paginated result set.
|
||||
schema:
|
||||
type: integer
|
||||
- name: page[size]
|
||||
required: false
|
||||
in: query
|
||||
description: Number of results to return per page.
|
||||
schema:
|
||||
type: integer
|
||||
- name: sort
|
||||
required: false
|
||||
in: query
|
||||
description: '[list of fields to sort by](https://jsonapi.org/format/#fetching-sorting)'
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- id
|
||||
- -id
|
||||
- email_domain
|
||||
- -email_domain
|
||||
- metadata_xml
|
||||
- -metadata_xml
|
||||
- created_at
|
||||
- -created_at
|
||||
- updated_at
|
||||
- -updated_at
|
||||
explode: false
|
||||
tags:
|
||||
- SAML
|
||||
security:
|
||||
- jwtAuth: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PaginatedSAMLConfigurationList'
|
||||
description: ''
|
||||
post:
|
||||
operationId: saml_config_create
|
||||
description: Creates a new SAML SSO configuration for the current tenant, including
|
||||
email domain and metadata XML.
|
||||
summary: Create the SSO configuration
|
||||
tags:
|
||||
- SAML
|
||||
requestBody:
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SAMLConfigurationRequest'
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SAMLConfigurationRequest'
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SAMLConfigurationRequest'
|
||||
required: true
|
||||
security:
|
||||
- jwtAuth: []
|
||||
responses:
|
||||
'201':
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SAMLConfigurationResponse'
|
||||
description: ''
|
||||
/api/v1/saml-config/{id}:
|
||||
get:
|
||||
operationId: saml_config_retrieve
|
||||
description: Returns the details of a specific SAML configuration belonging
|
||||
to the current tenant.
|
||||
summary: Retrieve SSO configuration details
|
||||
parameters:
|
||||
- in: query
|
||||
name: fields[saml-configurations]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- email_domain
|
||||
- metadata_xml
|
||||
- created_at
|
||||
- updated_at
|
||||
description: endpoint return only specific fields in the response on a per-type
|
||||
basis by including a fields[TYPE] query parameter.
|
||||
explode: false
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: A UUID string identifying this saml configuration.
|
||||
required: true
|
||||
tags:
|
||||
- SAML
|
||||
security:
|
||||
- jwtAuth: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SAMLConfigurationResponse'
|
||||
description: ''
|
||||
patch:
|
||||
operationId: saml_config_partial_update
|
||||
description: Partially updates an existing SAML SSO configuration. Supports
|
||||
changes to email domain and metadata XML.
|
||||
summary: Update the SSO configuration
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: A UUID string identifying this saml configuration.
|
||||
required: true
|
||||
tags:
|
||||
- SAML
|
||||
requestBody:
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatchedSAMLConfigurationRequest'
|
||||
application/x-www-form-urlencoded:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatchedSAMLConfigurationRequest'
|
||||
multipart/form-data:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PatchedSAMLConfigurationRequest'
|
||||
required: true
|
||||
security:
|
||||
- jwtAuth: []
|
||||
responses:
|
||||
'200':
|
||||
content:
|
||||
application/vnd.api+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SAMLConfigurationResponse'
|
||||
description: ''
|
||||
delete:
|
||||
operationId: saml_config_destroy
|
||||
description: Deletes an existing SAML SSO configuration associated with the
|
||||
current tenant.
|
||||
summary: Delete the SSO configuration
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: A UUID string identifying this saml configuration.
|
||||
required: true
|
||||
tags:
|
||||
- SAML
|
||||
security:
|
||||
- jwtAuth: []
|
||||
responses:
|
||||
'204':
|
||||
description: No response body
|
||||
/api/v1/scans:
|
||||
get:
|
||||
operationId: scans_list
|
||||
@@ -8464,6 +8445,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 +9095,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:
|
||||
@@ -8884,15 +9158,6 @@ components:
|
||||
$ref: '#/components/schemas/Role'
|
||||
required:
|
||||
- data
|
||||
PaginatedSAMLConfigurationList:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/SAMLConfiguration'
|
||||
required:
|
||||
- data
|
||||
PaginatedScanList:
|
||||
type: object
|
||||
properties:
|
||||
@@ -9153,6 +9418,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:
|
||||
@@ -9710,52 +10042,6 @@ components:
|
||||
readOnly: true
|
||||
required:
|
||||
- data
|
||||
PatchedSAMLConfigurationRequest:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- id
|
||||
additionalProperties: false
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common attributes
|
||||
and relationships.
|
||||
enum:
|
||||
- saml-configurations
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
email_domain:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Email domain used to identify the tenant, e.g. prowlerdemo.com
|
||||
maxLength: 254
|
||||
metadata_xml:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Raw IdP metadata XML to configure SingleSignOnService,
|
||||
certificates, etc.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
required:
|
||||
- email_domain
|
||||
- metadata_xml
|
||||
required:
|
||||
- data
|
||||
PatchedScanUpdateRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -11869,97 +12155,6 @@ components:
|
||||
$ref: '#/components/schemas/Role'
|
||||
required:
|
||||
- data
|
||||
SAMLConfiguration:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
- id
|
||||
additionalProperties: false
|
||||
properties:
|
||||
type:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SAMLConfigurationTypeEnum'
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common attributes
|
||||
and relationships.
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
email_domain:
|
||||
type: string
|
||||
description: Email domain used to identify the tenant, e.g. prowlerdemo.com
|
||||
maxLength: 254
|
||||
metadata_xml:
|
||||
type: string
|
||||
description: Raw IdP metadata XML to configure SingleSignOnService,
|
||||
certificates, etc.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
required:
|
||||
- email_domain
|
||||
- metadata_xml
|
||||
SAMLConfigurationRequest:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
required:
|
||||
- type
|
||||
additionalProperties: false
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
description: The [type](https://jsonapi.org/format/#document-resource-object-identification)
|
||||
member is used to describe resource objects that share common attributes
|
||||
and relationships.
|
||||
enum:
|
||||
- saml-configurations
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
email_domain:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Email domain used to identify the tenant, e.g. prowlerdemo.com
|
||||
maxLength: 254
|
||||
metadata_xml:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Raw IdP metadata XML to configure SingleSignOnService,
|
||||
certificates, etc.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
readOnly: true
|
||||
required:
|
||||
- email_domain
|
||||
- metadata_xml
|
||||
required:
|
||||
- data
|
||||
SAMLConfigurationResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SAMLConfiguration'
|
||||
required:
|
||||
- data
|
||||
SAMLConfigurationTypeEnum:
|
||||
type: string
|
||||
enum:
|
||||
- saml-configurations
|
||||
Scan:
|
||||
type: object
|
||||
required:
|
||||
@@ -12649,6 +12844,10 @@ components:
|
||||
type: string
|
||||
enum:
|
||||
- provider-groups
|
||||
Type4bfEnum:
|
||||
type: string
|
||||
enum:
|
||||
- lighthouse-configurations
|
||||
Type6bbEnum:
|
||||
type: string
|
||||
enum:
|
||||
@@ -12970,3 +13169,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.
|
||||
@@ -5,8 +5,6 @@ from allauth.socialaccount.models import SocialLogin
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from api.adapters import ProwlerSocialAccountAdapter
|
||||
from api.db_router import MainRouter
|
||||
from api.models import Membership, SAMLConfiguration, Tenant
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -22,24 +20,24 @@ class TestProwlerSocialAccountAdapter:
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
assert adapter.get_user_by_email("notfound@example.com") is None
|
||||
|
||||
def test_pre_social_login_links_existing_user(self, create_test_user, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
# def test_pre_social_login_links_existing_user(self, create_test_user, rf):
|
||||
# adapter = ProwlerSocialAccountAdapter()
|
||||
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.account.provider = "saml"
|
||||
sociallogin.account.extra_data = {}
|
||||
sociallogin.user = create_test_user
|
||||
sociallogin.connect = MagicMock()
|
||||
# sociallogin = MagicMock(spec=SocialLogin)
|
||||
# sociallogin.account = MagicMock()
|
||||
# sociallogin.account.provider = "saml"
|
||||
# sociallogin.account.extra_data = {}
|
||||
# sociallogin.user = create_test_user
|
||||
# sociallogin.connect = MagicMock()
|
||||
|
||||
adapter.pre_social_login(rf.get("/"), sociallogin)
|
||||
# adapter.pre_social_login(rf.get("/"), sociallogin)
|
||||
|
||||
call_args = sociallogin.connect.call_args
|
||||
assert call_args is not None
|
||||
# call_args = sociallogin.connect.call_args
|
||||
# assert call_args is not None
|
||||
|
||||
called_request, called_user = call_args[0]
|
||||
assert called_request.path == "/"
|
||||
assert called_user.email == create_test_user.email
|
||||
# called_request, called_user = call_args[0]
|
||||
# assert called_request.path == "/"
|
||||
# assert called_user.email == create_test_user.email
|
||||
|
||||
def test_pre_social_login_no_link_if_email_missing(self, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
@@ -54,29 +52,37 @@ class TestProwlerSocialAccountAdapter:
|
||||
|
||||
sociallogin.connect.assert_not_called()
|
||||
|
||||
def test_save_user_saml_flow(
|
||||
self,
|
||||
rf,
|
||||
saml_setup,
|
||||
saml_sociallogin,
|
||||
):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.get("/")
|
||||
saml_sociallogin.user.email = saml_setup["email"]
|
||||
# def test_save_user_saml_flow(
|
||||
# self,
|
||||
# rf,
|
||||
# saml_setup,
|
||||
# saml_sociallogin,
|
||||
# ):
|
||||
# adapter = ProwlerSocialAccountAdapter()
|
||||
# request = rf.get("/")
|
||||
# saml_sociallogin.user.email = saml_setup["email"]
|
||||
# saml_sociallogin.account.extra_data = {
|
||||
# "firstName": [],
|
||||
# "lastName": [],
|
||||
# "organization": [],
|
||||
# "userType": [],
|
||||
# }
|
||||
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).get(
|
||||
id=saml_setup["tenant_id"]
|
||||
)
|
||||
saml_config = SAMLConfiguration.objects.using(MainRouter.admin_db).get(
|
||||
tenant=tenant
|
||||
)
|
||||
assert saml_config.email_domain == saml_setup["domain"]
|
||||
# tenant = Tenant.objects.using(MainRouter.admin_db).get(
|
||||
# id=saml_setup["tenant_id"]
|
||||
# )
|
||||
# saml_config = SAMLConfiguration.objects.using(MainRouter.admin_db).get(
|
||||
# tenant=tenant
|
||||
# )
|
||||
# assert saml_config.email_domain == saml_setup["domain"]
|
||||
|
||||
user = adapter.save_user(request, saml_sociallogin)
|
||||
# user = adapter.save_user(request, saml_sociallogin)
|
||||
|
||||
assert user.email == saml_setup["email"]
|
||||
assert (
|
||||
Membership.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, tenant=tenant)
|
||||
.exists()
|
||||
)
|
||||
# assert user.name == "N/A"
|
||||
# assert user.company_name == ""
|
||||
# assert user.email == saml_setup["email"]
|
||||
# assert (
|
||||
# Membership.objects.using(MainRouter.admin_db)
|
||||
# .filter(user=user, tenant=tenant)
|
||||
# .exists()
|
||||
# )
|
||||
@@ -1,9 +1,6 @@
|
||||
import pytest
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from api.db_router import MainRouter
|
||||
from api.models import Resource, ResourceTag, SAMLConfiguration, Tenant
|
||||
from api.models import Resource, ResourceTag
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -125,147 +122,147 @@ class TestResourceModel:
|
||||
# assert Finding.objects.filter(uid=long_uid).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSAMLConfigurationModel:
|
||||
VALID_METADATA = """<?xml version='1.0' encoding='UTF-8'?>
|
||||
<md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
<md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
<md:KeyDescriptor use='signing'>
|
||||
<ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>FAKECERTDATA</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</md:KeyDescriptor>
|
||||
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://idp.test/sso'/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>
|
||||
"""
|
||||
# @pytest.mark.django_db
|
||||
# class TestSAMLConfigurationModel:
|
||||
# VALID_METADATA = """<?xml version='1.0' encoding='UTF-8'?>
|
||||
# <md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
# <md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
# <md:KeyDescriptor use='signing'>
|
||||
# <ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
# <ds:X509Data>
|
||||
# <ds:X509Certificate>FAKECERTDATA</ds:X509Certificate>
|
||||
# </ds:X509Data>
|
||||
# </ds:KeyInfo>
|
||||
# </md:KeyDescriptor>
|
||||
# <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://idp.test/sso'/>
|
||||
# </md:IDPSSODescriptor>
|
||||
# </md:EntityDescriptor>
|
||||
# """
|
||||
|
||||
def test_creates_valid_configuration(self):
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant A")
|
||||
config = SAMLConfiguration.objects.using(MainRouter.admin_db).create(
|
||||
email_domain="ssoexample.com",
|
||||
metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
tenant=tenant,
|
||||
)
|
||||
# def test_creates_valid_configuration(self):
|
||||
# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant A")
|
||||
# config = SAMLConfiguration.objects.using(MainRouter.admin_db).create(
|
||||
# email_domain="ssoexample.com",
|
||||
# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
# tenant=tenant,
|
||||
# )
|
||||
|
||||
assert config.email_domain == "ssoexample.com"
|
||||
assert SocialApp.objects.filter(client_id="ssoexample.com").exists()
|
||||
# assert config.email_domain == "ssoexample.com"
|
||||
# assert SocialApp.objects.filter(client_id="ssoexample.com").exists()
|
||||
|
||||
def test_email_domain_with_at_symbol_fails(self):
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant B")
|
||||
config = SAMLConfiguration(
|
||||
email_domain="invalid@domain.com",
|
||||
metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
tenant=tenant,
|
||||
)
|
||||
# def test_email_domain_with_at_symbol_fails(self):
|
||||
# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant B")
|
||||
# config = SAMLConfiguration(
|
||||
# email_domain="invalid@domain.com",
|
||||
# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
# tenant=tenant,
|
||||
# )
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
config.clean()
|
||||
# with pytest.raises(ValidationError) as exc_info:
|
||||
# config.clean()
|
||||
|
||||
errors = exc_info.value.message_dict
|
||||
assert "email_domain" in errors
|
||||
assert "Domain must not contain @" in errors["email_domain"][0]
|
||||
# errors = exc_info.value.message_dict
|
||||
# assert "email_domain" in errors
|
||||
# assert "Domain must not contain @" in errors["email_domain"][0]
|
||||
|
||||
def test_duplicate_email_domain_fails(self):
|
||||
tenant1 = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant C1")
|
||||
tenant2 = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant C2")
|
||||
# def test_duplicate_email_domain_fails(self):
|
||||
# tenant1 = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant C1")
|
||||
# tenant2 = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant C2")
|
||||
|
||||
SAMLConfiguration.objects.using(MainRouter.admin_db).create(
|
||||
email_domain="duplicate.com",
|
||||
metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
tenant=tenant1,
|
||||
)
|
||||
# SAMLConfiguration.objects.using(MainRouter.admin_db).create(
|
||||
# email_domain="duplicate.com",
|
||||
# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
# tenant=tenant1,
|
||||
# )
|
||||
|
||||
config = SAMLConfiguration(
|
||||
email_domain="duplicate.com",
|
||||
metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
tenant=tenant2,
|
||||
)
|
||||
# config = SAMLConfiguration(
|
||||
# email_domain="duplicate.com",
|
||||
# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
# tenant=tenant2,
|
||||
# )
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
config.clean()
|
||||
# with pytest.raises(ValidationError) as exc_info:
|
||||
# config.clean()
|
||||
|
||||
errors = exc_info.value.message_dict
|
||||
assert "tenant" in errors
|
||||
assert "There is a problem with your email domain." in errors["tenant"][0]
|
||||
# errors = exc_info.value.message_dict
|
||||
# assert "tenant" in errors
|
||||
# assert "There is a problem with your email domain." in errors["tenant"][0]
|
||||
|
||||
def test_duplicate_tenant_config_fails(self):
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant D")
|
||||
# def test_duplicate_tenant_config_fails(self):
|
||||
# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant D")
|
||||
|
||||
SAMLConfiguration.objects.using(MainRouter.admin_db).create(
|
||||
email_domain="unique1.com",
|
||||
metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
tenant=tenant,
|
||||
)
|
||||
# SAMLConfiguration.objects.using(MainRouter.admin_db).create(
|
||||
# email_domain="unique1.com",
|
||||
# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
# tenant=tenant,
|
||||
# )
|
||||
|
||||
config = SAMLConfiguration(
|
||||
email_domain="unique2.com",
|
||||
metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
tenant=tenant,
|
||||
)
|
||||
# config = SAMLConfiguration(
|
||||
# email_domain="unique2.com",
|
||||
# metadata_xml=TestSAMLConfigurationModel.VALID_METADATA,
|
||||
# tenant=tenant,
|
||||
# )
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
config.clean()
|
||||
# with pytest.raises(ValidationError) as exc_info:
|
||||
# config.clean()
|
||||
|
||||
errors = exc_info.value.message_dict
|
||||
assert "tenant" in errors
|
||||
assert (
|
||||
"A SAML configuration already exists for this tenant."
|
||||
in errors["tenant"][0]
|
||||
)
|
||||
# errors = exc_info.value.message_dict
|
||||
# assert "tenant" in errors
|
||||
# assert (
|
||||
# "A SAML configuration already exists for this tenant."
|
||||
# in errors["tenant"][0]
|
||||
# )
|
||||
|
||||
def test_invalid_metadata_xml_fails(self):
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant E")
|
||||
config = SAMLConfiguration(
|
||||
email_domain="brokenxml.com",
|
||||
metadata_xml="<bad<xml>",
|
||||
tenant=tenant,
|
||||
)
|
||||
# def test_invalid_metadata_xml_fails(self):
|
||||
# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant E")
|
||||
# config = SAMLConfiguration(
|
||||
# email_domain="brokenxml.com",
|
||||
# metadata_xml="<bad<xml>",
|
||||
# tenant=tenant,
|
||||
# )
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
config._parse_metadata()
|
||||
# with pytest.raises(ValidationError) as exc_info:
|
||||
# config._parse_metadata()
|
||||
|
||||
errors = exc_info.value.message_dict
|
||||
assert "metadata_xml" in errors
|
||||
assert "Invalid XML" in errors["metadata_xml"][0]
|
||||
assert "not well-formed" in errors["metadata_xml"][0]
|
||||
# errors = exc_info.value.message_dict
|
||||
# assert "metadata_xml" in errors
|
||||
# assert "Invalid XML" in errors["metadata_xml"][0]
|
||||
# assert "not well-formed" in errors["metadata_xml"][0]
|
||||
|
||||
def test_metadata_missing_sso_fails(self):
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant F")
|
||||
xml = """<md:EntityDescriptor entityID="x" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
|
||||
<md:IDPSSODescriptor></md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>"""
|
||||
config = SAMLConfiguration(
|
||||
email_domain="nosso.com",
|
||||
metadata_xml=xml,
|
||||
tenant=tenant,
|
||||
)
|
||||
# def test_metadata_missing_sso_fails(self):
|
||||
# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant F")
|
||||
# xml = """<md:EntityDescriptor entityID="x" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
|
||||
# <md:IDPSSODescriptor></md:IDPSSODescriptor>
|
||||
# </md:EntityDescriptor>"""
|
||||
# config = SAMLConfiguration(
|
||||
# email_domain="nosso.com",
|
||||
# metadata_xml=xml,
|
||||
# tenant=tenant,
|
||||
# )
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
config._parse_metadata()
|
||||
# with pytest.raises(ValidationError) as exc_info:
|
||||
# config._parse_metadata()
|
||||
|
||||
errors = exc_info.value.message_dict
|
||||
assert "metadata_xml" in errors
|
||||
assert "Missing SingleSignOnService" in errors["metadata_xml"][0]
|
||||
# errors = exc_info.value.message_dict
|
||||
# assert "metadata_xml" in errors
|
||||
# assert "Missing SingleSignOnService" in errors["metadata_xml"][0]
|
||||
|
||||
def test_metadata_missing_certificate_fails(self):
|
||||
tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant G")
|
||||
xml = """<md:EntityDescriptor entityID="x" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
|
||||
<md:IDPSSODescriptor>
|
||||
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://example.com/sso"/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>"""
|
||||
config = SAMLConfiguration(
|
||||
email_domain="nocert.com",
|
||||
metadata_xml=xml,
|
||||
tenant=tenant,
|
||||
)
|
||||
# def test_metadata_missing_certificate_fails(self):
|
||||
# tenant = Tenant.objects.using(MainRouter.admin_db).create(name="Tenant G")
|
||||
# xml = """<md:EntityDescriptor entityID="x" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
|
||||
# <md:IDPSSODescriptor>
|
||||
# <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://example.com/sso"/>
|
||||
# </md:IDPSSODescriptor>
|
||||
# </md:EntityDescriptor>"""
|
||||
# config = SAMLConfiguration(
|
||||
# email_domain="nocert.com",
|
||||
# metadata_xml=xml,
|
||||
# tenant=tenant,
|
||||
# )
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
config._parse_metadata()
|
||||
# with pytest.raises(ValidationError) as exc_info:
|
||||
# config._parse_metadata()
|
||||
|
||||
errors = exc_info.value.message_dict
|
||||
assert "metadata_xml" in errors
|
||||
assert "X509Certificate" in errors["metadata_xml"][0]
|
||||
# errors = exc_info.value.message_dict
|
||||
# assert "metadata_xml" in errors
|
||||
# assert "X509Certificate" in errors["metadata_xml"][0]
|
||||
@@ -9,19 +9,15 @@ from unittest.mock import ANY, MagicMock, Mock, patch
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from allauth.socialaccount.models import SocialAccount, SocialApp
|
||||
from botocore.exceptions import ClientError, NoCredentialsError
|
||||
from conftest import API_JSON_CONTENT_TYPE, TEST_PASSWORD, TEST_USER
|
||||
from django.conf import settings
|
||||
from django.http import JsonResponse
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
from django_celery_results.models import TaskResult
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from api.compliance import get_compliance_frameworks
|
||||
from api.db_router import MainRouter
|
||||
from api.models import (
|
||||
Integration,
|
||||
Invitation,
|
||||
@@ -32,7 +28,6 @@ from api.models import (
|
||||
ProviderSecret,
|
||||
Role,
|
||||
RoleProviderGroupRelationship,
|
||||
SAMLConfiguration,
|
||||
Scan,
|
||||
StateChoices,
|
||||
Task,
|
||||
@@ -40,7 +35,7 @@ from api.models import (
|
||||
UserRoleRelationship,
|
||||
)
|
||||
from api.rls import Tenant
|
||||
from api.v1.views import ComplianceOverviewViewSet, TenantFinishACSView
|
||||
from api.v1.views import ComplianceOverviewViewSet
|
||||
|
||||
TODAY = str(datetime.today().date())
|
||||
|
||||
@@ -1241,10 +1236,10 @@ class TestProviderViewSet:
|
||||
("uid.icontains", "1", 5),
|
||||
("alias", "aws_testing_1", 1),
|
||||
("alias.icontains", "aws", 2),
|
||||
("inserted_at", TODAY, 5),
|
||||
("inserted_at.gte", "2024-01-01", 5),
|
||||
("inserted_at", TODAY, 6),
|
||||
("inserted_at.gte", "2024-01-01", 6),
|
||||
("inserted_at.lte", "2024-01-01", 0),
|
||||
("updated_at.gte", "2024-01-01", 5),
|
||||
("updated_at.gte", "2024-01-01", 6),
|
||||
("updated_at.lte", "2024-01-01", 0),
|
||||
]
|
||||
),
|
||||
@@ -1723,6 +1718,50 @@ class TestProviderSecretViewSet:
|
||||
"kubeconfig_content": "kubeconfig-content",
|
||||
},
|
||||
),
|
||||
# M365 with STATIC secret - no user or password
|
||||
(
|
||||
Provider.ProviderChoices.M365.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
{
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"tenant_id": "tenant-id",
|
||||
},
|
||||
),
|
||||
# M365 with user only
|
||||
(
|
||||
Provider.ProviderChoices.M365.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
{
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"tenant_id": "tenant-id",
|
||||
"user": "test@domain.com",
|
||||
},
|
||||
),
|
||||
# M365 with password only
|
||||
(
|
||||
Provider.ProviderChoices.M365.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
{
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"tenant_id": "tenant-id",
|
||||
"password": "supersecret",
|
||||
},
|
||||
),
|
||||
# M365 with user and password
|
||||
(
|
||||
Provider.ProviderChoices.M365.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
{
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"tenant_id": "tenant-id",
|
||||
"user": "test@domain.com",
|
||||
"password": "supersecret",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_provider_secrets_create_valid(
|
||||
@@ -1734,7 +1773,10 @@ class TestProviderSecretViewSet:
|
||||
secret_data,
|
||||
):
|
||||
# Get the provider from the fixture and set its type
|
||||
provider = Provider.objects.filter(provider=provider_type)[0]
|
||||
try:
|
||||
provider = Provider.objects.filter(provider=provider_type)[0]
|
||||
except IndexError:
|
||||
print(f"Provider {provider_type} not found")
|
||||
|
||||
data = {
|
||||
"data": {
|
||||
@@ -5561,265 +5603,334 @@ class TestIntegrationViewSet:
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSAMLInitiateAPIView:
|
||||
def test_valid_email_domain_and_certificates(
|
||||
self, authenticated_client, saml_setup, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("SAML_PUBLIC_CERT", "fake_cert")
|
||||
monkeypatch.setenv("SAML_PRIVATE_KEY", "fake_key")
|
||||
# @pytest.mark.django_db
|
||||
# class TestSAMLTokenValidation:
|
||||
# def test_valid_token_returns_tokens(self, authenticated_client, create_test_user):
|
||||
# user = create_test_user
|
||||
# valid_token_data = {
|
||||
# "access": "mock_access_token",
|
||||
# "refresh": "mock_refresh_token",
|
||||
# }
|
||||
# saml_token = SAMLToken.objects.create(
|
||||
# token=valid_token_data,
|
||||
# user=user,
|
||||
# expires_at=datetime.now(timezone.utc) + timedelta(seconds=10),
|
||||
# )
|
||||
|
||||
url = reverse("api_saml_initiate")
|
||||
payload = {"email_domain": saml_setup["email"]}
|
||||
# url = reverse("token-saml")
|
||||
# response = authenticated_client.post(f"{url}?id={saml_token.id}")
|
||||
|
||||
response = authenticated_client.post(url, data=payload, format="json")
|
||||
# assert response.status_code == status.HTTP_200_OK
|
||||
# assert response.json() == {"data": valid_token_data}
|
||||
# assert not SAMLToken.objects.filter(id=saml_token.id).exists()
|
||||
|
||||
assert response.status_code == status.HTTP_302_FOUND
|
||||
assert f"email={saml_setup['email']}" in response.url
|
||||
assert (
|
||||
reverse("saml_login", kwargs={"organization_slug": saml_setup["domain"]})
|
||||
in response.url
|
||||
)
|
||||
# def test_invalid_token_id_returns_404(self, authenticated_client):
|
||||
# url = reverse("token-saml")
|
||||
# response = authenticated_client.post(f"{url}?id={str(uuid4())}")
|
||||
|
||||
def test_invalid_email_domain(self, authenticated_client):
|
||||
url = reverse("api_saml_initiate")
|
||||
payload = {"email_domain": "user@unauthorized.com"}
|
||||
# assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
# assert response.json()["errors"]["detail"] == "Invalid token ID."
|
||||
|
||||
response = authenticated_client.post(url, data=payload, format="json")
|
||||
# def test_expired_token_returns_400(self, authenticated_client, create_test_user):
|
||||
# user = create_test_user
|
||||
# expired_token_data = {
|
||||
# "access": "expired_access_token",
|
||||
# "refresh": "expired_refresh_token",
|
||||
# }
|
||||
# saml_token = SAMLToken.objects.create(
|
||||
# token=expired_token_data,
|
||||
# user=user,
|
||||
# expires_at=datetime.now(timezone.utc) - timedelta(seconds=1),
|
||||
# )
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.json()["errors"]["detail"] == "Unauthorized domain."
|
||||
# url = reverse("token-saml")
|
||||
# response = authenticated_client.post(f"{url}?id={saml_token.id}")
|
||||
|
||||
def test_missing_certificates(self, authenticated_client, saml_setup, monkeypatch):
|
||||
monkeypatch.setenv("SAML_PUBLIC_CERT", "")
|
||||
monkeypatch.setenv("SAML_PRIVATE_KEY", "")
|
||||
# assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
# assert response.json()["errors"]["detail"] == "Token expired."
|
||||
# assert SAMLToken.objects.filter(id=saml_token.id).exists()
|
||||
|
||||
url = reverse("api_saml_initiate")
|
||||
payload = {"email_domain": saml_setup["email"]}
|
||||
# def test_token_can_be_used_only_once(self, authenticated_client, create_test_user):
|
||||
# user = create_test_user
|
||||
# token_data = {
|
||||
# "access": "single_use_token",
|
||||
# "refresh": "single_use_refresh",
|
||||
# }
|
||||
# saml_token = SAMLToken.objects.create(
|
||||
# token=token_data,
|
||||
# user=user,
|
||||
# expires_at=datetime.now(timezone.utc) + timedelta(seconds=10),
|
||||
# )
|
||||
|
||||
response = authenticated_client.post(url, data=payload, format="json")
|
||||
# url = reverse("token-saml")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert (
|
||||
response.json()["errors"]["detail"]
|
||||
== "SAML configuration is invalid: missing certificates."
|
||||
)
|
||||
# # First use: should succeed
|
||||
# response1 = authenticated_client.post(f"{url}?id={saml_token.id}")
|
||||
# assert response1.status_code == status.HTTP_200_OK
|
||||
|
||||
# # Second use: should fail (already deleted)
|
||||
# response2 = authenticated_client.post(f"{url}?id={saml_token.id}")
|
||||
# assert response2.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSAMLConfigurationViewSet:
|
||||
def test_list_saml_configurations(self, authenticated_client, saml_setup):
|
||||
config = SAMLConfiguration.objects.get(
|
||||
email_domain=saml_setup["email"].split("@")[-1]
|
||||
)
|
||||
response = authenticated_client.get(reverse("saml-config-list"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert (
|
||||
response.json()["data"][0]["attributes"]["email_domain"]
|
||||
== config.email_domain
|
||||
)
|
||||
# @pytest.mark.django_db
|
||||
# class TestSAMLInitiateAPIView:
|
||||
# def test_valid_email_domain_and_certificates(
|
||||
# self, authenticated_client, saml_setup, monkeypatch
|
||||
# ):
|
||||
# monkeypatch.setenv("SAML_PUBLIC_CERT", "fake_cert")
|
||||
# monkeypatch.setenv("SAML_PRIVATE_KEY", "fake_key")
|
||||
|
||||
def test_retrieve_saml_configuration(self, authenticated_client, saml_setup):
|
||||
config = SAMLConfiguration.objects.get(
|
||||
email_domain=saml_setup["email"].split("@")[-1]
|
||||
)
|
||||
response = authenticated_client.get(
|
||||
reverse("saml-config-detail", kwargs={"pk": config.id})
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert (
|
||||
response.json()["data"]["attributes"]["metadata_xml"] == config.metadata_xml
|
||||
)
|
||||
# url = reverse("api_saml_initiate")
|
||||
# payload = {"email_domain": saml_setup["email"]}
|
||||
|
||||
def test_create_saml_configuration(self, authenticated_client, tenants_fixture):
|
||||
payload = {
|
||||
"email_domain": "newdomain.com",
|
||||
"metadata_xml": """<?xml version='1.0' encoding='UTF-8'?>
|
||||
<md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
<md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
<md:KeyDescriptor use='signing'>
|
||||
<ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>TEST</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</md:KeyDescriptor>
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
|
||||
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>
|
||||
""",
|
||||
}
|
||||
response = authenticated_client.post(
|
||||
reverse("saml-config-list"), data=payload, format="json"
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert SAMLConfiguration.objects.filter(email_domain="newdomain.com").exists()
|
||||
# response = authenticated_client.post(url, data=payload, format="json")
|
||||
|
||||
def test_update_saml_configuration(self, authenticated_client, saml_setup):
|
||||
config = SAMLConfiguration.objects.get(
|
||||
email_domain=saml_setup["email"].split("@")[-1]
|
||||
)
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "saml-configurations",
|
||||
"id": str(config.id),
|
||||
"attributes": {
|
||||
"metadata_xml": """<?xml version='1.0' encoding='UTF-8'?>
|
||||
<md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
<md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
<md:KeyDescriptor use='signing'>
|
||||
<ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>TEST2</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</md:KeyDescriptor>
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
|
||||
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>
|
||||
"""
|
||||
},
|
||||
}
|
||||
}
|
||||
response = authenticated_client.patch(
|
||||
reverse("saml-config-detail", kwargs={"pk": config.id}),
|
||||
data=payload,
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
config.refresh_from_db()
|
||||
assert (
|
||||
config.metadata_xml.strip()
|
||||
== payload["data"]["attributes"]["metadata_xml"].strip()
|
||||
)
|
||||
# assert response.status_code == status.HTTP_302_FOUND
|
||||
# assert (
|
||||
# reverse("saml_login", kwargs={"organization_slug": saml_setup["domain"]})
|
||||
# in response.url
|
||||
# )
|
||||
# assert "SAMLRequest" not in response.url
|
||||
|
||||
def test_delete_saml_configuration(self, authenticated_client, saml_setup):
|
||||
config = SAMLConfiguration.objects.get(
|
||||
email_domain=saml_setup["email"].split("@")[-1]
|
||||
)
|
||||
response = authenticated_client.delete(
|
||||
reverse("saml-config-detail", kwargs={"pk": config.id})
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not SAMLConfiguration.objects.filter(id=config.id).exists()
|
||||
# def test_invalid_email_domain(self, authenticated_client):
|
||||
# url = reverse("api_saml_initiate")
|
||||
# payload = {"email_domain": "user@unauthorized.com"}
|
||||
|
||||
# response = authenticated_client.post(url, data=payload, format="json")
|
||||
|
||||
# assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
# assert response.json()["errors"]["detail"] == "Unauthorized domain."
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestTenantFinishACSView:
|
||||
def test_dispatch_skips_if_user_not_authenticated(self):
|
||||
request = RequestFactory().get(
|
||||
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
)
|
||||
request.user = type("Anonymous", (), {"is_authenticated": False})()
|
||||
# @pytest.mark.django_db
|
||||
# class TestSAMLConfigurationViewSet:
|
||||
# def test_list_saml_configurations(self, authenticated_client, saml_setup):
|
||||
# config = SAMLConfiguration.objects.get(
|
||||
# email_domain=saml_setup["email"].split("@")[-1]
|
||||
# )
|
||||
# response = authenticated_client.get(reverse("saml-config-list"))
|
||||
# assert response.status_code == status.HTTP_200_OK
|
||||
# assert (
|
||||
# response.json()["data"][0]["attributes"]["email_domain"]
|
||||
# == config.email_domain
|
||||
# )
|
||||
|
||||
with patch(
|
||||
"allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
) as mock_get_app:
|
||||
mock_get_app.return_value = SocialApp(
|
||||
provider="saml",
|
||||
client_id="testtenant",
|
||||
name="Test App",
|
||||
settings={},
|
||||
)
|
||||
# def test_retrieve_saml_configuration(self, authenticated_client, saml_setup):
|
||||
# config = SAMLConfiguration.objects.get(
|
||||
# email_domain=saml_setup["email"].split("@")[-1]
|
||||
# )
|
||||
# response = authenticated_client.get(
|
||||
# reverse("saml-config-detail", kwargs={"pk": config.id})
|
||||
# )
|
||||
# assert response.status_code == status.HTTP_200_OK
|
||||
# assert (
|
||||
# response.json()["data"]["attributes"]["metadata_xml"] == config.metadata_xml
|
||||
# )
|
||||
|
||||
view = TenantFinishACSView.as_view()
|
||||
response = view(request, organization_slug="testtenant")
|
||||
# def test_create_saml_configuration(self, authenticated_client, tenants_fixture):
|
||||
# payload = {
|
||||
# "email_domain": "newdomain.com",
|
||||
# "metadata_xml": """<?xml version='1.0' encoding='UTF-8'?>
|
||||
# <md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
# <md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
# <md:KeyDescriptor use='signing'>
|
||||
# <ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
# <ds:X509Data>
|
||||
# <ds:X509Certificate>TEST</ds:X509Certificate>
|
||||
# </ds:X509Data>
|
||||
# </ds:KeyInfo>
|
||||
# </md:KeyDescriptor>
|
||||
# <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
# <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
|
||||
# <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
|
||||
# </md:IDPSSODescriptor>
|
||||
# </md:EntityDescriptor>
|
||||
# """,
|
||||
# }
|
||||
# response = authenticated_client.post(
|
||||
# reverse("saml-config-list"), data=payload, format="json"
|
||||
# )
|
||||
# assert response.status_code == status.HTTP_201_CREATED
|
||||
# assert SAMLConfiguration.objects.filter(email_domain="newdomain.com").exists()
|
||||
|
||||
assert response.status_code in [200, 302]
|
||||
# def test_update_saml_configuration(self, authenticated_client, saml_setup):
|
||||
# config = SAMLConfiguration.objects.get(
|
||||
# email_domain=saml_setup["email"].split("@")[-1]
|
||||
# )
|
||||
# payload = {
|
||||
# "data": {
|
||||
# "type": "saml-configurations",
|
||||
# "id": str(config.id),
|
||||
# "attributes": {
|
||||
# "metadata_xml": """<?xml version='1.0' encoding='UTF-8'?>
|
||||
# <md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
# <md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
# <md:KeyDescriptor use='signing'>
|
||||
# <ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
# <ds:X509Data>
|
||||
# <ds:X509Certificate>TEST2</ds:X509Certificate>
|
||||
# </ds:X509Data>
|
||||
# </ds:KeyInfo>
|
||||
# </md:KeyDescriptor>
|
||||
# <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
# <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
|
||||
# <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
|
||||
# </md:IDPSSODescriptor>
|
||||
# </md:EntityDescriptor>
|
||||
# """
|
||||
# },
|
||||
# }
|
||||
# }
|
||||
# response = authenticated_client.patch(
|
||||
# reverse("saml-config-detail", kwargs={"pk": config.id}),
|
||||
# data=payload,
|
||||
# content_type="application/vnd.api+json",
|
||||
# )
|
||||
# assert response.status_code == status.HTTP_200_OK
|
||||
# config.refresh_from_db()
|
||||
# assert (
|
||||
# config.metadata_xml.strip()
|
||||
# == payload["data"]["attributes"]["metadata_xml"].strip()
|
||||
# )
|
||||
|
||||
def test_dispatch_skips_if_social_app_not_found(self, users_fixture):
|
||||
request = RequestFactory().get(
|
||||
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
)
|
||||
request.user = users_fixture[0]
|
||||
# def test_delete_saml_configuration(self, authenticated_client, saml_setup):
|
||||
# config = SAMLConfiguration.objects.get(
|
||||
# email_domain=saml_setup["email"].split("@")[-1]
|
||||
# )
|
||||
# response = authenticated_client.delete(
|
||||
# reverse("saml-config-detail", kwargs={"pk": config.id})
|
||||
# )
|
||||
# assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
# assert not SAMLConfiguration.objects.filter(id=config.id).exists()
|
||||
|
||||
with patch(
|
||||
"allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
) as mock_get_app:
|
||||
mock_get_app.return_value = SocialApp(
|
||||
provider="saml",
|
||||
client_id="testtenant",
|
||||
name="Test App",
|
||||
settings={},
|
||||
)
|
||||
|
||||
view = TenantFinishACSView.as_view()
|
||||
response = view(request, organization_slug="testtenant")
|
||||
# @pytest.mark.django_db
|
||||
# class TestTenantFinishACSView:
|
||||
# def test_dispatch_skips_if_user_not_authenticated(self):
|
||||
# request = RequestFactory().get(
|
||||
# reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
# )
|
||||
# request.user = type("Anonymous", (), {"is_authenticated": False})()
|
||||
|
||||
assert isinstance(response, JsonResponse) or response.status_code in [200, 302]
|
||||
# with patch(
|
||||
# "allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
# ) as mock_get_app:
|
||||
# mock_get_app.return_value = SocialApp(
|
||||
# provider="saml",
|
||||
# client_id="testtenant",
|
||||
# name="Test App",
|
||||
# settings={},
|
||||
# )
|
||||
|
||||
def test_dispatch_sets_user_profile_and_assigns_role(
|
||||
self, create_test_user, tenants_fixture, saml_setup
|
||||
):
|
||||
user = create_test_user
|
||||
original_email = user.email
|
||||
original_name = user.name
|
||||
original_company = user.company_name
|
||||
user.email = f"doe@{saml_setup['email']}"
|
||||
# view = TenantFinishACSView.as_view()
|
||||
# response = view(request, organization_slug="testtenant")
|
||||
|
||||
social_account = SocialAccount(
|
||||
user=user,
|
||||
provider="saml",
|
||||
extra_data={
|
||||
"firstName": ["John"],
|
||||
"lastName": ["Doe"],
|
||||
"organization": ["TestOrg"],
|
||||
"userType": ["saml_default_role"],
|
||||
},
|
||||
)
|
||||
# assert response.status_code in [200, 302]
|
||||
|
||||
request = RequestFactory().get(
|
||||
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
)
|
||||
request.user = user
|
||||
# def test_dispatch_skips_if_social_app_not_found(self, users_fixture):
|
||||
# request = RequestFactory().get(
|
||||
# reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
# )
|
||||
# request.user = users_fixture[0]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
) as mock_get_app_or_404,
|
||||
patch("allauth.socialaccount.models.SocialApp.objects.get"),
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialAccount.objects.get"
|
||||
) as mock_socialaccount_get,
|
||||
patch("api.v1.serializers.TokenSocialLoginSerializer") as mock_serializer,
|
||||
):
|
||||
mock_get_app_or_404.return_value = MagicMock(
|
||||
provider="saml", client_id="testtenant", name="Test App", settings={}
|
||||
)
|
||||
# with patch(
|
||||
# "allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
# ) as mock_get_app:
|
||||
# mock_get_app.return_value = SocialApp(
|
||||
# provider="saml",
|
||||
# client_id="testtenant",
|
||||
# name="Test App",
|
||||
# settings={},
|
||||
# )
|
||||
|
||||
mock_socialaccount_get.return_value = social_account
|
||||
# view = TenantFinishACSView.as_view()
|
||||
# response = view(request, organization_slug="testtenant")
|
||||
|
||||
mock_instance = mock_serializer.return_value
|
||||
mock_instance.is_valid.return_value = True
|
||||
mock_instance.validated_data = {
|
||||
"token": "mocktoken",
|
||||
"refresh_token": "mockrefresh",
|
||||
}
|
||||
# assert isinstance(response, JsonResponse) or response.status_code in [200, 302]
|
||||
|
||||
view = TenantFinishACSView.as_view()
|
||||
response = view(request, organization_slug="testtenant")
|
||||
# def test_dispatch_sets_user_profile_and_assigns_role_and_creates_token(
|
||||
# self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch
|
||||
# ):
|
||||
# monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
# user = create_test_user
|
||||
# original_email = user.email
|
||||
# original_name = user.name
|
||||
# original_company = user.company_name
|
||||
# user.email = f"doe@{saml_setup['email']}"
|
||||
|
||||
assert response.status_code == 200
|
||||
user.refresh_from_db()
|
||||
assert user.name == "John Doe"
|
||||
assert user.company_name == "TestOrg"
|
||||
# social_account = SocialAccount(
|
||||
# user=user,
|
||||
# provider="saml",
|
||||
# extra_data={
|
||||
# "firstName": ["John"],
|
||||
# "lastName": ["Doe"],
|
||||
# "organization": ["TestOrg"],
|
||||
# "userType": ["saml_default_role"],
|
||||
# },
|
||||
# )
|
||||
|
||||
role = Role.objects.using(MainRouter.admin_db).get(name="saml_default_role")
|
||||
assert role.tenant == tenants_fixture[0]
|
||||
# request = RequestFactory().get(
|
||||
# reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
# )
|
||||
# request.user = user
|
||||
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, tenant_id=tenants_fixture[0].id)
|
||||
.exists()
|
||||
)
|
||||
user.email = original_email
|
||||
user.name = original_name
|
||||
user.company_name = original_company
|
||||
user.save()
|
||||
# with (
|
||||
# patch(
|
||||
# "allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
# ) as mock_get_app_or_404,
|
||||
# patch("allauth.socialaccount.models.SocialApp.objects.get"),
|
||||
# patch(
|
||||
# "allauth.socialaccount.models.SocialAccount.objects.get"
|
||||
# ) as mock_sa_get,
|
||||
# ):
|
||||
# mock_get_app_or_404.return_value = MagicMock(
|
||||
# provider="saml", client_id="testtenant", name="Test App", settings={}
|
||||
# )
|
||||
# mock_sa_get.return_value = social_account
|
||||
|
||||
# view = TenantFinishACSView.as_view()
|
||||
# response = view(request, organization_slug="testtenant")
|
||||
|
||||
# assert response.status_code == 302
|
||||
|
||||
# expected_callback_host = "localhost"
|
||||
# parsed_url = urlparse(response.url)
|
||||
# assert parsed_url.netloc == expected_callback_host
|
||||
# query_params = parse_qs(parsed_url.query)
|
||||
# assert "id" in query_params
|
||||
|
||||
# token_id = query_params["id"][0]
|
||||
# token_obj = SAMLToken.objects.get(id=token_id)
|
||||
# assert token_obj.user == user
|
||||
# assert not token_obj.is_expired()
|
||||
|
||||
# user.refresh_from_db()
|
||||
# assert user.name == "John Doe"
|
||||
# assert user.company_name == "TestOrg"
|
||||
|
||||
# role = Role.objects.using(MainRouter.admin_db).get(name="saml_default_role")
|
||||
# assert role.tenant == tenants_fixture[0]
|
||||
|
||||
# assert (
|
||||
# UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
# .filter(user=user, tenant_id=tenants_fixture[0].id)
|
||||
# .exists()
|
||||
# )
|
||||
|
||||
# # Membership should have been created with default role
|
||||
# membership = Membership.objects.using(MainRouter.admin_db).get(
|
||||
# user=user, tenant=tenants_fixture[0]
|
||||
# )
|
||||
# assert membership.role == Membership.RoleChoices.MEMBER
|
||||
# assert membership.user == user
|
||||
# assert membership.tenant == tenants_fixture[0]
|
||||
|
||||
# # Restore original user state
|
||||
# user.email = original_email
|
||||
# user.name = original_name
|
||||
# user.company_name = original_company
|
||||
# user.save()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@@ -29,7 +29,6 @@ from api.models import (
|
||||
ResourceTag,
|
||||
Role,
|
||||
RoleProviderGroupRelationship,
|
||||
SAMLConfiguration,
|
||||
Scan,
|
||||
StateChoices,
|
||||
StatusChoices,
|
||||
@@ -1200,8 +1199,8 @@ class M365ProviderSecret(serializers.Serializer):
|
||||
client_id = serializers.CharField()
|
||||
client_secret = serializers.CharField()
|
||||
tenant_id = serializers.CharField()
|
||||
user = serializers.EmailField()
|
||||
password = serializers.CharField()
|
||||
user = serializers.EmailField(required=False)
|
||||
password = serializers.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
resource_name = "provider-secrets"
|
||||
@@ -2068,23 +2067,23 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
|
||||
# SSO
|
||||
|
||||
|
||||
class SamlInitiateSerializer(serializers.Serializer):
|
||||
email_domain = serializers.CharField()
|
||||
# class SamlInitiateSerializer(serializers.Serializer):
|
||||
# email_domain = serializers.CharField()
|
||||
|
||||
class JSONAPIMeta:
|
||||
resource_name = "saml-initiate"
|
||||
# class JSONAPIMeta:
|
||||
# resource_name = "saml-initiate"
|
||||
|
||||
|
||||
class SamlMetadataSerializer(serializers.Serializer):
|
||||
class JSONAPIMeta:
|
||||
resource_name = "saml-meta"
|
||||
# class SamlMetadataSerializer(serializers.Serializer):
|
||||
# class JSONAPIMeta:
|
||||
# resource_name = "saml-meta"
|
||||
|
||||
|
||||
class SAMLConfigurationSerializer(RLSSerializer):
|
||||
class Meta:
|
||||
model = SAMLConfiguration
|
||||
fields = ["id", "email_domain", "metadata_xml", "created_at", "updated_at"]
|
||||
read_only_fields = ["id", "created_at", "updated_at"]
|
||||
# class SAMLConfigurationSerializer(RLSSerializer):
|
||||
# class Meta:
|
||||
# model = SAMLConfiguration
|
||||
# fields = ["id", "email_domain", "metadata_xml", "created_at", "updated_at"]
|
||||
# read_only_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class LighthouseConfigSerializer(RLSSerializer):
|
||||
|
||||
@@ -23,13 +23,10 @@ from api.v1.views import (
|
||||
ResourceViewSet,
|
||||
RoleProviderGroupRelationshipView,
|
||||
RoleViewSet,
|
||||
SAMLConfigurationViewSet,
|
||||
SAMLInitiateAPIView,
|
||||
ScanViewSet,
|
||||
ScheduleViewSet,
|
||||
SchemaView,
|
||||
TaskViewSet,
|
||||
TenantFinishACSView,
|
||||
TenantMembersViewSet,
|
||||
TenantViewSet,
|
||||
UserRoleRelationshipView,
|
||||
@@ -53,7 +50,7 @@ router.register(
|
||||
router.register(r"overviews", OverviewViewSet, basename="overview")
|
||||
router.register(r"schedules", ScheduleViewSet, basename="schedule")
|
||||
router.register(r"integrations", IntegrationViewSet, basename="integration")
|
||||
router.register(r"saml-config", SAMLConfigurationViewSet, basename="saml-config")
|
||||
# router.register(r"saml-config", SAMLConfigurationViewSet, basename="saml-config")
|
||||
router.register(
|
||||
r"lighthouse-configurations",
|
||||
LighthouseConfigViewSet,
|
||||
@@ -122,17 +119,24 @@ urlpatterns = [
|
||||
),
|
||||
name="provider_group-providers-relationship",
|
||||
),
|
||||
# API endpoint to start SAML SSO flow
|
||||
path(
|
||||
"auth/saml/initiate/", SAMLInitiateAPIView.as_view(), name="api_saml_initiate"
|
||||
),
|
||||
# Allauth SAML endpoints for tenants
|
||||
path("accounts/", include("allauth.urls")),
|
||||
path(
|
||||
"api/v1/accounts/saml/<organization_slug>/acs/finish/",
|
||||
TenantFinishACSView.as_view(),
|
||||
name="saml_finish_acs",
|
||||
),
|
||||
# API endpoint to start SAML SSO flow (WIP)
|
||||
# path(
|
||||
# "auth/saml/initiate/", SAMLInitiateAPIView.as_view(), name="api_saml_initiate"
|
||||
# ),
|
||||
# # Custom SAML endpoints (must come before allauth.urls) (WIP)
|
||||
# path(
|
||||
# "accounts/saml/<organization_slug>/login/",
|
||||
# CustomSAMLLoginView.as_view(),
|
||||
# name="saml_login",
|
||||
# ),
|
||||
# path(
|
||||
# "accounts/saml/<organization_slug>/acs/finish/",
|
||||
# TenantFinishACSView.as_view(),
|
||||
# name="saml_finish_acs",
|
||||
# ),
|
||||
# Allauth SAML endpoints for tenants (WIP)
|
||||
# path("accounts/", include("allauth.urls")),
|
||||
# path("tokens/saml", SAMLTokenValidateView.as_view(), name="token-saml"),
|
||||
path("tokens/google", GoogleSocialLoginView.as_view(), name="token-google"),
|
||||
path("tokens/github", GithubSocialLoginView.as_view(), name="token-github"),
|
||||
path("", include(router.urls)),
|
||||
|
||||
@@ -3,10 +3,8 @@ import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import sentry_sdk
|
||||
from allauth.socialaccount.models import SocialAccount, SocialApp
|
||||
from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
|
||||
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
|
||||
from allauth.socialaccount.providers.saml.views import FinishACSView
|
||||
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
|
||||
from celery.result import AsyncResult
|
||||
from config.env import env
|
||||
@@ -21,8 +19,7 @@ from django.contrib.postgres.search import SearchQuery
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Exists, F, OuterRef, Prefetch, Q, Sum
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.utils.decorators import method_decorator
|
||||
@@ -67,7 +64,6 @@ from api.compliance import (
|
||||
get_compliance_frameworks,
|
||||
)
|
||||
from api.db_router import MainRouter
|
||||
from api.db_utils import rls_transaction
|
||||
from api.exceptions import TaskFailedException
|
||||
from api.filters import (
|
||||
ComplianceOverviewFilter,
|
||||
@@ -105,8 +101,6 @@ from api.models import (
|
||||
ResourceScanSummary,
|
||||
Role,
|
||||
RoleProviderGroupRelationship,
|
||||
SAMLConfiguration,
|
||||
SAMLDomainIndex,
|
||||
Scan,
|
||||
ScanSummary,
|
||||
SeverityChoices,
|
||||
@@ -163,8 +157,6 @@ from api.v1.serializers import (
|
||||
RoleProviderGroupRelationshipSerializer,
|
||||
RoleSerializer,
|
||||
RoleUpdateSerializer,
|
||||
SAMLConfigurationSerializer,
|
||||
SamlInitiateSerializer,
|
||||
ScanComplianceReportSerializer,
|
||||
ScanCreateSerializer,
|
||||
ScanReportSerializer,
|
||||
@@ -401,161 +393,240 @@ class GithubSocialLoginView(SocialLoginView):
|
||||
return original_response
|
||||
|
||||
|
||||
@extend_schema(exclude=True)
|
||||
class SAMLInitiateAPIView(GenericAPIView):
|
||||
serializer_class = SamlInitiateSerializer
|
||||
permission_classes = []
|
||||
# @extend_schema(exclude=True)
|
||||
# class SAMLTokenValidateView(GenericAPIView):
|
||||
# resource_name = "tokens"
|
||||
# http_method_names = ["post"]
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
email = serializer.validated_data["email_domain"]
|
||||
domain = email.split("@", 1)[-1].lower()
|
||||
# def post(self, request):
|
||||
# token_id = request.query_params.get("id", "invalid")
|
||||
# try:
|
||||
# saml_token = SAMLToken.objects.using(MainRouter.admin_db).get(id=token_id)
|
||||
# except SAMLToken.DoesNotExist:
|
||||
# return Response({"detail": "Invalid token ID."}, status=404)
|
||||
|
||||
try:
|
||||
check = SAMLDomainIndex.objects.get(email_domain=domain)
|
||||
with rls_transaction(str(check.tenant_id)):
|
||||
config = SAMLConfiguration.objects.get(tenant_id=str(check.tenant_id))
|
||||
except (SAMLDomainIndex.DoesNotExist, SAMLConfiguration.DoesNotExist):
|
||||
return Response(
|
||||
{"detail": "Unauthorized domain."}, status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
# if saml_token.is_expired():
|
||||
# return Response({"detail": "Token expired."}, status=400)
|
||||
|
||||
# Check certificates are not empty
|
||||
saml_public_cert = os.getenv("SAML_PUBLIC_CERT", "").strip()
|
||||
saml_private_key = os.getenv("SAML_PRIVATE_KEY", "").strip()
|
||||
# token_data = saml_token.token
|
||||
# # Currently we don't store the tokens in the database, so we delete the token after use
|
||||
# saml_token.delete()
|
||||
|
||||
if not saml_public_cert or not saml_private_key:
|
||||
return Response(
|
||||
{"detail": "SAML configuration is invalid: missing certificates."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
saml_login_url = reverse(
|
||||
"saml_login", kwargs={"organization_slug": config.email_domain}
|
||||
)
|
||||
return redirect(f"{saml_login_url}?email={email}")
|
||||
# return Response(token_data, status=200)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
tags=["SAML"],
|
||||
summary="List all SSO configurations",
|
||||
description="Returns all the SAML-based SSO configurations associated with the current tenant.",
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
tags=["SAML"],
|
||||
summary="Retrieve SSO configuration details",
|
||||
description="Returns the details of a specific SAML configuration belonging to the current tenant.",
|
||||
),
|
||||
create=extend_schema(
|
||||
tags=["SAML"],
|
||||
summary="Create the SSO configuration",
|
||||
description="Creates a new SAML SSO configuration for the current tenant, including email domain and metadata XML.",
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
tags=["SAML"],
|
||||
summary="Update the SSO configuration",
|
||||
description="Partially updates an existing SAML SSO configuration. Supports changes to email domain and metadata XML.",
|
||||
),
|
||||
destroy=extend_schema(
|
||||
tags=["SAML"],
|
||||
summary="Delete the SSO configuration",
|
||||
description="Deletes an existing SAML SSO configuration associated with the current tenant.",
|
||||
),
|
||||
)
|
||||
@method_decorator(CACHE_DECORATOR, name="retrieve")
|
||||
@method_decorator(CACHE_DECORATOR, name="list")
|
||||
class SAMLConfigurationViewSet(BaseRLSViewSet):
|
||||
"""
|
||||
ViewSet for managing SAML SSO configurations per tenant.
|
||||
# @extend_schema(exclude=True)
|
||||
# class CustomSAMLLoginView(LoginView):
|
||||
# def dispatch(self, request, *args, **kwargs):
|
||||
# """
|
||||
# Convert GET requests to POST to bypass allauth's confirmation screen.
|
||||
|
||||
This endpoint allows authorized users to perform CRUD operations on SAMLConfiguration,
|
||||
which define how a tenant integrates with an external SAML Identity Provider (IdP).
|
||||
# Why this is necessary:
|
||||
# - django-allauth requires POST for social logins to prevent open redirect attacks
|
||||
# - SAML login links typically use GET requests (e.g., <a href="...">)
|
||||
# - This conversion allows seamless login without user-facing confirmation
|
||||
|
||||
Typical use cases include:
|
||||
- Listing all existing configurations for auditing or UI display.
|
||||
- Retrieving a single configuration to show setup details.
|
||||
- Creating or updating a configuration to onboard or modify SAML integration.
|
||||
- Deleting a configuration when deactivating SAML for a tenant.
|
||||
"""
|
||||
# Security considerations:
|
||||
# 1. Preserves CSRF protection: Original POST handling remains intact
|
||||
# 2. Avoids global SOCIALACCOUNT_LOGIN_ON_GET=True which would:
|
||||
# - Enable GET logins for ALL providers (security risk)
|
||||
# - Potentially expose open redirect vulnerabilities
|
||||
# 3. SAML payloads remain signed/encrypted regardless of HTTP method
|
||||
# 4. No sensitive parameters are exposed in URLs (copied to POST body)
|
||||
|
||||
serializer_class = SAMLConfigurationSerializer
|
||||
required_permissions = [Permissions.MANAGE_INTEGRATIONS]
|
||||
queryset = SAMLConfiguration.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
# If called during schema generation, return an empty queryset
|
||||
if getattr(self, "swagger_fake_view", False):
|
||||
return SAMLConfiguration.objects.none()
|
||||
return SAMLConfiguration.objects.filter(tenant=self.request.tenant_id)
|
||||
# This approach maintains security while providing better UX.
|
||||
# """
|
||||
# if request.method == "GET":
|
||||
# # Convert GET to POST while preserving parameters
|
||||
# request.method = "POST"
|
||||
# # Safe because SAML validates signatures
|
||||
# request.POST = request.GET.copy()
|
||||
# return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class TenantFinishACSView(FinishACSView):
|
||||
def dispatch(self, request, organization_slug):
|
||||
response = super().dispatch(request, organization_slug)
|
||||
user = getattr(request, "user", None)
|
||||
if not user or not user.is_authenticated:
|
||||
return response
|
||||
# @extend_schema(exclude=True)
|
||||
# class SAMLInitiateAPIView(GenericAPIView):
|
||||
# serializer_class = SamlInitiateSerializer
|
||||
# permission_classes = []
|
||||
|
||||
try:
|
||||
social_app = SocialApp.objects.get(
|
||||
provider="saml", client_id=organization_slug
|
||||
)
|
||||
social_account = SocialAccount.objects.get(
|
||||
user=user, provider=social_app.provider
|
||||
)
|
||||
except (SocialApp.DoesNotExist, SocialAccount.DoesNotExist):
|
||||
return response
|
||||
# def post(self, request, *args, **kwargs):
|
||||
# # Validate the input payload and extract the domain
|
||||
# serializer = self.get_serializer(data=request.data)
|
||||
# serializer.is_valid(raise_exception=True)
|
||||
# email = serializer.validated_data["email_domain"]
|
||||
# domain = email.split("@", 1)[-1].lower()
|
||||
|
||||
extra = social_account.extra_data
|
||||
user.first_name = extra.get("firstName", [""])[0]
|
||||
user.last_name = extra.get("lastName", [""])[0]
|
||||
user.company_name = extra.get("organization", [""])[0]
|
||||
user.name = f"{user.first_name} {user.last_name}".strip()
|
||||
user.save()
|
||||
# # Retrieve the SAML configuration for the given email domain
|
||||
# try:
|
||||
# check = SAMLDomainIndex.objects.get(email_domain=domain)
|
||||
# with rls_transaction(str(check.tenant_id)):
|
||||
# config = SAMLConfiguration.objects.get(tenant_id=str(check.tenant_id))
|
||||
# except (SAMLDomainIndex.DoesNotExist, SAMLConfiguration.DoesNotExist):
|
||||
# return Response(
|
||||
# {"detail": "Unauthorized domain."}, status=status.HTTP_403_FORBIDDEN
|
||||
# )
|
||||
|
||||
email_domain = user.email.split("@")[-1]
|
||||
tenant = (
|
||||
SAMLConfiguration.objects.using(MainRouter.admin_db)
|
||||
.get(email_domain=email_domain)
|
||||
.tenant
|
||||
)
|
||||
role_name = extra.get("userType", ["saml_default_role"])[0].strip()
|
||||
try:
|
||||
role = Role.objects.using(MainRouter.admin_db).get(
|
||||
name=role_name, tenant=tenant
|
||||
)
|
||||
except Role.DoesNotExist:
|
||||
role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name=role_name,
|
||||
tenant=tenant,
|
||||
manage_users=False,
|
||||
manage_account=False,
|
||||
manage_billing=False,
|
||||
manage_providers=False,
|
||||
manage_integrations=False,
|
||||
manage_scans=False,
|
||||
unlimited_visibility=False,
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).filter(
|
||||
user=user,
|
||||
tenant_id=tenant.id,
|
||||
).delete()
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user,
|
||||
role=role,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
# # Check certificates are not empty (TODO: Validate certificates)
|
||||
# # saml_public_cert = os.getenv("SAML_PUBLIC_CERT", "").strip()
|
||||
# # saml_private_key = os.getenv("SAML_PRIVATE_KEY", "").strip()
|
||||
|
||||
serializer = TokenSocialLoginSerializer(data={"email": user.email})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return JsonResponse(
|
||||
{
|
||||
"type": "saml-social-tokens",
|
||||
"attributes": serializer.validated_data,
|
||||
}
|
||||
)
|
||||
# # if not saml_public_cert or not saml_private_key:
|
||||
# # return Response(
|
||||
# # {"detail": "SAML configuration is invalid: missing certificates."},
|
||||
# # status=status.HTTP_403_FORBIDDEN,
|
||||
# # )
|
||||
|
||||
# # Build the SAML login URL using the configured API host
|
||||
# api_host = os.getenv("API_BASE_URL")
|
||||
# login_path = reverse(
|
||||
# "saml_login", kwargs={"organization_slug": config.email_domain}
|
||||
# )
|
||||
# login_url = urljoin(api_host, login_path)
|
||||
|
||||
# return redirect(login_url)
|
||||
|
||||
|
||||
# @extend_schema_view(
|
||||
# list=extend_schema(
|
||||
# tags=["SAML"],
|
||||
# summary="List all SSO configurations",
|
||||
# description="Returns all the SAML-based SSO configurations associated with the current tenant.",
|
||||
# ),
|
||||
# retrieve=extend_schema(
|
||||
# tags=["SAML"],
|
||||
# summary="Retrieve SSO configuration details",
|
||||
# description="Returns the details of a specific SAML configuration belonging to the current tenant.",
|
||||
# ),
|
||||
# create=extend_schema(
|
||||
# tags=["SAML"],
|
||||
# summary="Create the SSO configuration",
|
||||
# description="Creates a new SAML SSO configuration for the current tenant, including email domain and metadata XML.",
|
||||
# ),
|
||||
# partial_update=extend_schema(
|
||||
# tags=["SAML"],
|
||||
# summary="Update the SSO configuration",
|
||||
# description="Partially updates an existing SAML SSO configuration. Supports changes to email domain and metadata XML.",
|
||||
# ),
|
||||
# destroy=extend_schema(
|
||||
# tags=["SAML"],
|
||||
# summary="Delete the SSO configuration",
|
||||
# description="Deletes an existing SAML SSO configuration associated with the current tenant.",
|
||||
# ),
|
||||
# )
|
||||
# @method_decorator(CACHE_DECORATOR, name="retrieve")
|
||||
# @method_decorator(CACHE_DECORATOR, name="list")
|
||||
# class SAMLConfigurationViewSet(BaseRLSViewSet):
|
||||
# """
|
||||
# ViewSet for managing SAML SSO configurations per tenant.
|
||||
|
||||
# This endpoint allows authorized users to perform CRUD operations on SAMLConfiguration,
|
||||
# which define how a tenant integrates with an external SAML Identity Provider (IdP).
|
||||
|
||||
# Typical use cases include:
|
||||
# - Listing all existing configurations for auditing or UI display.
|
||||
# - Retrieving a single configuration to show setup details.
|
||||
# - Creating or updating a configuration to onboard or modify SAML integration.
|
||||
# - Deleting a configuration when deactivating SAML for a tenant.
|
||||
# """
|
||||
|
||||
# serializer_class = SAMLConfigurationSerializer
|
||||
# required_permissions = [Permissions.MANAGE_INTEGRATIONS]
|
||||
# queryset = SAMLConfiguration.objects.all()
|
||||
|
||||
# def get_queryset(self):
|
||||
# # If called during schema generation, return an empty queryset
|
||||
# if getattr(self, "swagger_fake_view", False):
|
||||
# return SAMLConfiguration.objects.none()
|
||||
# return SAMLConfiguration.objects.filter(tenant=self.request.tenant_id)
|
||||
|
||||
|
||||
# class TenantFinishACSView(FinishACSView):
|
||||
# def dispatch(self, request, organization_slug):
|
||||
# response = super().dispatch(request, organization_slug)
|
||||
# user = getattr(request, "user", None)
|
||||
# if not user or not user.is_authenticated:
|
||||
# return response
|
||||
|
||||
# try:
|
||||
# social_app = SocialApp.objects.get(
|
||||
# provider="saml", client_id=organization_slug
|
||||
# )
|
||||
# social_account = SocialAccount.objects.get(
|
||||
# user=user, provider=social_app.provider
|
||||
# )
|
||||
# except (SocialApp.DoesNotExist, SocialAccount.DoesNotExist):
|
||||
# return response
|
||||
|
||||
# extra = social_account.extra_data
|
||||
# user.first_name = (
|
||||
# extra.get("firstName", [""])[0] if extra.get("firstName") else ""
|
||||
# )
|
||||
# user.last_name = extra.get("lastName", [""])[0] if extra.get("lastName") else ""
|
||||
# user.company_name = (
|
||||
# extra.get("organization", [""])[0] if extra.get("organization") else ""
|
||||
# )
|
||||
# user.name = f"{user.first_name} {user.last_name}".strip()
|
||||
# if user.name == "":
|
||||
# user.name = "N/A"
|
||||
# user.save()
|
||||
|
||||
# email_domain = user.email.split("@")[-1]
|
||||
# tenant = (
|
||||
# SAMLConfiguration.objects.using(MainRouter.admin_db)
|
||||
# .get(email_domain=email_domain)
|
||||
# .tenant
|
||||
# )
|
||||
# role_name = (
|
||||
# extra.get("userType", ["saml_default_role"])[0].strip()
|
||||
# if extra.get("userType")
|
||||
# else "saml_default_role"
|
||||
# )
|
||||
# try:
|
||||
# role = Role.objects.using(MainRouter.admin_db).get(
|
||||
# name=role_name, tenant=tenant
|
||||
# )
|
||||
# except Role.DoesNotExist:
|
||||
# role = Role.objects.using(MainRouter.admin_db).create(
|
||||
# name=role_name,
|
||||
# tenant=tenant,
|
||||
# manage_users=False,
|
||||
# manage_account=False,
|
||||
# manage_billing=False,
|
||||
# manage_providers=False,
|
||||
# manage_integrations=False,
|
||||
# manage_scans=False,
|
||||
# unlimited_visibility=False,
|
||||
# )
|
||||
# UserRoleRelationship.objects.using(MainRouter.admin_db).filter(
|
||||
# user=user,
|
||||
# tenant_id=tenant.id,
|
||||
# ).delete()
|
||||
# UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
# user=user,
|
||||
# role=role,
|
||||
# tenant_id=tenant.id,
|
||||
# )
|
||||
# membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create(
|
||||
# user=user,
|
||||
# tenant=tenant,
|
||||
# defaults={
|
||||
# "user": user,
|
||||
# "tenant": tenant,
|
||||
# "role": Membership.RoleChoices.MEMBER,
|
||||
# },
|
||||
# )
|
||||
|
||||
# serializer = TokenSocialLoginSerializer(data={"email": user.email})
|
||||
# serializer.is_valid(raise_exception=True)
|
||||
|
||||
# token_data = serializer.validated_data
|
||||
# saml_token = SAMLToken.objects.using(MainRouter.admin_db).create(
|
||||
# token=token_data, user=user
|
||||
# )
|
||||
# callback_url = env.str("SAML_SSO_CALLBACK_URL")
|
||||
# redirect_url = f"{callback_url}?id={saml_token.id}"
|
||||
|
||||
# return redirect(redirect_url)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
@@ -3038,9 +3109,9 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@extend_schema(tags=["Overview"])
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
tags=["Overview"],
|
||||
providers=extend_schema(
|
||||
summary="Get aggregated provider data",
|
||||
description=(
|
||||
"Retrieve an aggregated overview of findings and resources grouped by providers. "
|
||||
@@ -3287,6 +3358,7 @@ class OverviewViewSet(BaseRLSViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@extend_schema(tags=["Schedule"])
|
||||
@extend_schema_view(
|
||||
daily=extend_schema(
|
||||
summary="Create a daily schedule scan for a given provider",
|
||||
|
||||
@@ -25,9 +25,9 @@ SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
|
||||
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
|
||||
SOCIALACCOUNT_ADAPTER = "api.adapters.ProwlerSocialAccountAdapter"
|
||||
|
||||
# SAML keys
|
||||
SAML_PUBLIC_CERT = env("SAML_PUBLIC_CERT", default="")
|
||||
SAML_PRIVATE_KEY = env("SAML_PRIVATE_KEY", default="")
|
||||
# SAML keys (TODO: Validate certificates)
|
||||
# SAML_PUBLIC_CERT = env("SAML_PUBLIC_CERT", default="")
|
||||
# SAML_PRIVATE_KEY = env("SAML_PRIVATE_KEY", default="")
|
||||
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
"google": {
|
||||
@@ -60,12 +60,17 @@ SOCIALACCOUNT_PROVIDERS = {
|
||||
"entity_id": "urn:prowler.com:sp",
|
||||
},
|
||||
"advanced": {
|
||||
"x509cert": SAML_PUBLIC_CERT,
|
||||
"private_key": SAML_PRIVATE_KEY,
|
||||
# "x509cert": SAML_PUBLIC_CERT,
|
||||
# "private_key": SAML_PRIVATE_KEY,
|
||||
# "authn_request_signed": True,
|
||||
# "want_assertion_signed": True,
|
||||
# "want_message_signed": True,
|
||||
"name_id_format": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
||||
"authn_request_signed": True,
|
||||
"want_assertion_signed": True,
|
||||
"want_message_signed": True,
|
||||
"authn_request_signed": False,
|
||||
"logout_request_signed": False,
|
||||
"logout_response_signed": False,
|
||||
"want_assertion_encrypted": False,
|
||||
"want_name_id_encrypted": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from allauth.socialaccount.models import SocialLogin
|
||||
from django.conf import settings
|
||||
from django.db import connection as django_connection
|
||||
from django.db import connections as django_connections
|
||||
@@ -29,8 +28,6 @@ from api.models import (
|
||||
Resource,
|
||||
ResourceTag,
|
||||
Role,
|
||||
SAMLConfiguration,
|
||||
SAMLDomainIndex,
|
||||
Scan,
|
||||
ScanSummary,
|
||||
StateChoices,
|
||||
@@ -381,8 +378,14 @@ def providers_fixture(tenants_fixture):
|
||||
tenant_id=tenant.id,
|
||||
scanner_args={"key1": "value1", "key2": {"key21": "value21"}},
|
||||
)
|
||||
provider6 = Provider.objects.create(
|
||||
provider="m365",
|
||||
uid="m365.test.com",
|
||||
alias="m365_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
return provider1, provider2, provider3, provider4, provider5
|
||||
return provider1, provider2, provider3, provider4, provider5, provider6
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -1115,62 +1118,62 @@ def latest_scan_finding(authenticated_client, providers_fixture, resources_fixtu
|
||||
return finding
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def saml_setup(tenants_fixture):
|
||||
tenant_id = tenants_fixture[0].id
|
||||
domain = "example.com"
|
||||
# @pytest.fixture
|
||||
# def saml_setup(tenants_fixture):
|
||||
# tenant_id = tenants_fixture[0].id
|
||||
# domain = "example.com"
|
||||
|
||||
SAMLDomainIndex.objects.create(email_domain=domain, tenant_id=tenant_id)
|
||||
# SAMLDomainIndex.objects.create(email_domain=domain, tenant_id=tenant_id)
|
||||
|
||||
metadata_xml = """<?xml version='1.0' encoding='UTF-8'?>
|
||||
<md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
<md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
<md:KeyDescriptor use='signing'>
|
||||
<ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>TEST</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</md:KeyDescriptor>
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
|
||||
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>
|
||||
"""
|
||||
SAMLConfiguration.objects.create(
|
||||
tenant_id=str(tenant_id),
|
||||
email_domain=domain,
|
||||
metadata_xml=metadata_xml,
|
||||
)
|
||||
# metadata_xml = """<?xml version='1.0' encoding='UTF-8'?>
|
||||
# <md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
# <md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
# <md:KeyDescriptor use='signing'>
|
||||
# <ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
# <ds:X509Data>
|
||||
# <ds:X509Certificate>TEST</ds:X509Certificate>
|
||||
# </ds:X509Data>
|
||||
# </ds:KeyInfo>
|
||||
# </md:KeyDescriptor>
|
||||
# <md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
# <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
|
||||
# <md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
|
||||
# </md:IDPSSODescriptor>
|
||||
# </md:EntityDescriptor>
|
||||
# """
|
||||
# SAMLConfiguration.objects.create(
|
||||
# tenant_id=str(tenant_id),
|
||||
# email_domain=domain,
|
||||
# metadata_xml=metadata_xml,
|
||||
# )
|
||||
|
||||
return {
|
||||
"email": f"user@{domain}",
|
||||
"domain": domain,
|
||||
"tenant_id": tenant_id,
|
||||
}
|
||||
# return {
|
||||
# "email": f"user@{domain}",
|
||||
# "domain": domain,
|
||||
# "tenant_id": tenant_id,
|
||||
# }
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def saml_sociallogin(users_fixture):
|
||||
user = users_fixture[0]
|
||||
user.email = "samlsso@acme.com"
|
||||
extra_data = {
|
||||
"firstName": ["Test"],
|
||||
"lastName": ["User"],
|
||||
"organization": ["Prowler"],
|
||||
"userType": ["member"],
|
||||
}
|
||||
# @pytest.fixture
|
||||
# def saml_sociallogin(users_fixture):
|
||||
# user = users_fixture[0]
|
||||
# user.email = "samlsso@acme.com"
|
||||
# extra_data = {
|
||||
# "firstName": ["Test"],
|
||||
# "lastName": ["User"],
|
||||
# "organization": ["Prowler"],
|
||||
# "userType": ["member"],
|
||||
# }
|
||||
|
||||
account = MagicMock()
|
||||
account.provider = "saml"
|
||||
account.extra_data = extra_data
|
||||
# account = MagicMock()
|
||||
# account.provider = "saml"
|
||||
# account.extra_data = extra_data
|
||||
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = account
|
||||
sociallogin.user = user
|
||||
# sociallogin = MagicMock(spec=SocialLogin)
|
||||
# sociallogin.account = account
|
||||
# sociallogin.user = user
|
||||
|
||||
return sociallogin
|
||||
# return sociallogin
|
||||
|
||||
|
||||
def get_authorization_header(access_token: str) -> dict:
|
||||
|
||||
@@ -31,6 +31,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.kisa_ismsp.kisa_ismsp_aws import AWSKISAISMSP
|
||||
from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_aws import AWSMitreAttack
|
||||
from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import (
|
||||
@@ -90,6 +91,7 @@ COMPLIANCE_CLASS_MAP = {
|
||||
"m365": [
|
||||
(lambda name: name.startswith("cis_"), M365CIS),
|
||||
(lambda name: name == "prowler_threatscore_m365", ProwlerThreatScoreM365),
|
||||
(lambda name: name.startswith("iso27001_"), M365ISO27001),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from api.models import (
|
||||
StateChoices,
|
||||
)
|
||||
from api.models import StatusChoices as FindingStatus
|
||||
from api.utils import initialize_prowler_provider
|
||||
from api.utils import initialize_prowler_provider, return_prowler_provider
|
||||
from api.v1.serializers import ScanTaskSerializer
|
||||
from prowler.lib.outputs.finding import Finding as ProwlerFinding
|
||||
from prowler.lib.scan.scan import Scan as ProwlerScan
|
||||
@@ -149,7 +149,8 @@ def perform_prowler_scan(
|
||||
provider_instance.save()
|
||||
|
||||
# If the provider is not connected, raise an exception outside the transaction.
|
||||
# If raised within the transaction, the transaction will be rolled back and the provider will not be marked as not connected.
|
||||
# If raised within the transaction, the transaction will be rolled back and the provider will not be marked
|
||||
# as not connected.
|
||||
if exc:
|
||||
raise exc
|
||||
|
||||
@@ -526,7 +527,7 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
with rls_transaction(tenant_id):
|
||||
scan_instance = Scan.objects.get(pk=scan_id)
|
||||
provider_instance = scan_instance.provider
|
||||
prowler_provider = initialize_prowler_provider(provider_instance)
|
||||
prowler_provider = return_prowler_provider(provider_instance)
|
||||
|
||||
# Get check status data by region from findings
|
||||
check_status_by_region = {}
|
||||
|
||||
@@ -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
|
||||
@@ -399,9 +399,7 @@ class TestCreateComplianceRequirements:
|
||||
):
|
||||
with (
|
||||
patch("api.db_utils.rls_transaction"),
|
||||
patch(
|
||||
"tasks.jobs.scan.initialize_prowler_provider"
|
||||
) as mock_initialize_prowler_provider,
|
||||
patch("tasks.jobs.scan.return_prowler_provider") as mock_prowler_provider,
|
||||
patch(
|
||||
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
|
||||
) as mock_compliance_template,
|
||||
@@ -427,9 +425,7 @@ class TestCreateComplianceRequirements:
|
||||
"us-east-1",
|
||||
"us-west-2",
|
||||
]
|
||||
mock_initialize_prowler_provider.return_value = (
|
||||
mock_prowler_provider_instance
|
||||
)
|
||||
mock_prowler_provider.return_value = mock_prowler_provider_instance
|
||||
|
||||
mock_compliance_template.__getitem__.return_value = {
|
||||
"cis_1.4_aws": {
|
||||
@@ -512,9 +508,7 @@ class TestCreateComplianceRequirements:
|
||||
):
|
||||
with (
|
||||
patch("api.db_utils.rls_transaction"),
|
||||
patch(
|
||||
"tasks.jobs.scan.initialize_prowler_provider"
|
||||
) as mock_initialize_prowler_provider,
|
||||
patch("tasks.jobs.scan.return_prowler_provider") as mock_prowler_provider,
|
||||
patch(
|
||||
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
|
||||
) as mock_compliance_template,
|
||||
@@ -557,9 +551,7 @@ class TestCreateComplianceRequirements:
|
||||
"us-east-1",
|
||||
"us-west-2",
|
||||
]
|
||||
mock_initialize_prowler_provider.return_value = (
|
||||
mock_prowler_provider_instance
|
||||
)
|
||||
mock_prowler_provider.return_value = mock_prowler_provider_instance
|
||||
|
||||
mock_compliance_template.__getitem__.return_value = {
|
||||
"test_compliance": {
|
||||
@@ -607,9 +599,7 @@ class TestCreateComplianceRequirements:
|
||||
):
|
||||
with (
|
||||
patch("api.db_utils.rls_transaction"),
|
||||
patch(
|
||||
"tasks.jobs.scan.initialize_prowler_provider"
|
||||
) as mock_initialize_prowler_provider,
|
||||
patch("tasks.jobs.scan.return_prowler_provider") as mock_prowler_provider,
|
||||
patch(
|
||||
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
|
||||
) as mock_compliance_template,
|
||||
@@ -641,9 +631,7 @@ class TestCreateComplianceRequirements:
|
||||
mock_prowler_provider_instance.get_regions.side_effect = AttributeError(
|
||||
"No get_regions method"
|
||||
)
|
||||
mock_initialize_prowler_provider.return_value = (
|
||||
mock_prowler_provider_instance
|
||||
)
|
||||
mock_prowler_provider.return_value = mock_prowler_provider_instance
|
||||
|
||||
mock_compliance_template.__getitem__.return_value = {
|
||||
"kubernetes_cis": {
|
||||
@@ -676,9 +664,7 @@ class TestCreateComplianceRequirements:
|
||||
):
|
||||
with (
|
||||
patch("api.db_utils.rls_transaction"),
|
||||
patch(
|
||||
"tasks.jobs.scan.initialize_prowler_provider"
|
||||
) as mock_initialize_prowler_provider,
|
||||
patch("tasks.jobs.scan.return_prowler_provider") as mock_prowler_provider,
|
||||
patch(
|
||||
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
|
||||
) as mock_compliance_template,
|
||||
@@ -704,9 +690,7 @@ class TestCreateComplianceRequirements:
|
||||
|
||||
mock_prowler_provider_instance = MagicMock()
|
||||
mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"]
|
||||
mock_initialize_prowler_provider.return_value = (
|
||||
mock_prowler_provider_instance
|
||||
)
|
||||
mock_prowler_provider.return_value = mock_prowler_provider_instance
|
||||
|
||||
mock_compliance_template.__getitem__.return_value = {
|
||||
"cis_1.4_aws": {
|
||||
@@ -743,9 +727,7 @@ class TestCreateComplianceRequirements:
|
||||
):
|
||||
with (
|
||||
patch("api.db_utils.rls_transaction"),
|
||||
patch(
|
||||
"tasks.jobs.scan.initialize_prowler_provider"
|
||||
) as mock_initialize_prowler_provider,
|
||||
patch("tasks.jobs.scan.return_prowler_provider") as mock_prowler_provider,
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
@@ -759,7 +741,7 @@ class TestCreateComplianceRequirements:
|
||||
tenant_id = str(tenant.id)
|
||||
scan_id = str(scan.id)
|
||||
|
||||
mock_initialize_prowler_provider.side_effect = Exception(
|
||||
mock_prowler_provider.side_effect = Exception(
|
||||
"Provider initialization failed"
|
||||
)
|
||||
|
||||
@@ -774,9 +756,7 @@ class TestCreateComplianceRequirements:
|
||||
):
|
||||
with (
|
||||
patch("api.db_utils.rls_transaction"),
|
||||
patch(
|
||||
"tasks.jobs.scan.initialize_prowler_provider"
|
||||
) as mock_initialize_prowler_provider,
|
||||
patch("tasks.jobs.scan.return_prowler_provider") as mock_prowler_provider,
|
||||
patch(
|
||||
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
|
||||
) as mock_compliance_template,
|
||||
@@ -800,9 +780,7 @@ class TestCreateComplianceRequirements:
|
||||
|
||||
mock_prowler_provider_instance = MagicMock()
|
||||
mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"]
|
||||
mock_initialize_prowler_provider.return_value = (
|
||||
mock_prowler_provider_instance
|
||||
)
|
||||
mock_prowler_provider.return_value = mock_prowler_provider_instance
|
||||
|
||||
mock_compliance_template.__getitem__.return_value = {}
|
||||
|
||||
@@ -821,8 +799,8 @@ class TestCreateComplianceRequirements:
|
||||
with (
|
||||
patch("api.db_utils.rls_transaction"),
|
||||
patch(
|
||||
"tasks.jobs.scan.initialize_prowler_provider"
|
||||
) as mock_initialize_prowler_provider,
|
||||
"tasks.jobs.scan.return_prowler_provider"
|
||||
) as mock_return_prowler_provider,
|
||||
patch(
|
||||
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
|
||||
) as mock_compliance_template,
|
||||
@@ -862,9 +840,7 @@ class TestCreateComplianceRequirements:
|
||||
|
||||
mock_prowler_provider_instance = MagicMock()
|
||||
mock_prowler_provider_instance.get_regions.return_value = ["us-east-1"]
|
||||
mock_initialize_prowler_provider.return_value = (
|
||||
mock_prowler_provider_instance
|
||||
)
|
||||
mock_return_prowler_provider.return_value = mock_prowler_provider_instance
|
||||
|
||||
mock_compliance_template.__getitem__.return_value = {
|
||||
"cis_1.4_aws": {
|
||||
@@ -898,8 +874,8 @@ class TestCreateComplianceRequirements:
|
||||
with (
|
||||
patch("api.db_utils.rls_transaction"),
|
||||
patch(
|
||||
"tasks.jobs.scan.initialize_prowler_provider"
|
||||
) as mock_initialize_prowler_provider,
|
||||
"tasks.jobs.scan.return_prowler_provider"
|
||||
) as mock_return_prowler_provider,
|
||||
patch(
|
||||
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
|
||||
) as mock_compliance_template,
|
||||
@@ -911,7 +887,6 @@ class TestCreateComplianceRequirements:
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
providers_fixture[0]
|
||||
|
||||
mock_findings_filter.return_value = []
|
||||
|
||||
@@ -921,7 +896,7 @@ class TestCreateComplianceRequirements:
|
||||
"us-west-2",
|
||||
"eu-west-1",
|
||||
]
|
||||
mock_initialize_prowler_provider.return_value = mock_prowler_provider
|
||||
mock_return_prowler_provider.return_value = mock_prowler_provider
|
||||
|
||||
mock_compliance_template.__getitem__.return_value = {
|
||||
"test_compliance": {
|
||||
@@ -990,8 +965,8 @@ class TestCreateComplianceRequirements:
|
||||
with (
|
||||
patch("api.db_utils.rls_transaction"),
|
||||
patch(
|
||||
"tasks.jobs.scan.initialize_prowler_provider"
|
||||
) as mock_initialize_prowler_provider,
|
||||
"tasks.jobs.scan.return_prowler_provider"
|
||||
) as mock_return_prowler_provider,
|
||||
patch(
|
||||
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
|
||||
) as mock_compliance_template,
|
||||
@@ -1009,7 +984,7 @@ class TestCreateComplianceRequirements:
|
||||
|
||||
mock_prowler_provider = MagicMock()
|
||||
mock_prowler_provider.get_regions.return_value = ["us-east-1", "us-west-2"]
|
||||
mock_initialize_prowler_provider.return_value = mock_prowler_provider
|
||||
mock_return_prowler_provider.return_value = mock_prowler_provider
|
||||
|
||||
mock_compliance_template.__getitem__.return_value = {
|
||||
"test_compliance": {
|
||||
@@ -1077,8 +1052,8 @@ class TestCreateComplianceRequirements:
|
||||
with (
|
||||
patch("api.db_utils.rls_transaction"),
|
||||
patch(
|
||||
"tasks.jobs.scan.initialize_prowler_provider"
|
||||
) as mock_initialize_prowler_provider,
|
||||
"tasks.jobs.scan.return_prowler_provider"
|
||||
) as mock_return_prowler_provider,
|
||||
patch(
|
||||
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE"
|
||||
) as mock_compliance_template,
|
||||
@@ -1090,13 +1065,12 @@ class TestCreateComplianceRequirements:
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
providers_fixture[0]
|
||||
|
||||
mock_findings_filter.return_value = []
|
||||
|
||||
mock_prowler_provider = MagicMock()
|
||||
mock_prowler_provider.get_regions.return_value = ["us-east-1", "us-west-2"]
|
||||
mock_initialize_prowler_provider.return_value = mock_prowler_provider
|
||||
mock_return_prowler_provider.return_value = mock_prowler_provider
|
||||
|
||||
mock_compliance_template.__getitem__.return_value = {
|
||||
"test_compliance": {
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -212,34 +212,9 @@ Each check **must** populate the report with an unique identifier for the audite
|
||||
- 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`.
|
||||
|
||||
### Using the Audit Configuration
|
||||
### Configurable Checks in Prowler
|
||||
|
||||
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 = ec2_client.audit_config.get(
|
||||
"max_security_group_rules", 50
|
||||
)
|
||||
for security_group_arn, security_group in ec2_client.security_groups.items():
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
Always use the `dictionary.get(value, default)` syntax to ensure a default value is set when the configuration value is not present.
|
||||
See [Configurable Checks](./configurable-checks.md) for detailed information on making checks configurable using the `audit_config` object and configuration file.
|
||||
|
||||
## Metadata Structure for Prowler Checks
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Configurable Checks in Prowler
|
||||
|
||||
Prowler empowers users to extend and adapt cloud security coverage by making checks configurable through the use of the `audit_config` object. This approach enables customization of checks to meet specific requirements through a configuration file.
|
||||
|
||||
## Understanding the `audit_config` Object
|
||||
|
||||
The `audit_config` object is a dictionary attached to each provider's service client (for example, `<service_name>_client.audit_config`). This object loads configuration values from the main configuration file (`prowler/config/config.yaml`). Use `audit_config` to make checks flexible and user-configurable.
|
||||
|
||||
## Using `audit_config` to Configure Checks
|
||||
|
||||
Retrieve configuration values in a check by using the `.get()` method on the `audit_config` object. For example, to get the minimum number of Availability Zones for Lambda from the configuration file, use the following code. If the value is not set in the configuration, the check defaults to 2:
|
||||
|
||||
```python
|
||||
LAMBDA_MIN_AZS = awslambda_client.audit_config.get("lambda_min_azs", 2)
|
||||
```
|
||||
|
||||
Always provide a default value in `.get()` to ensure the check works even if the configuration is missing the variable.
|
||||
|
||||
### Example: Security Group Rule Limit
|
||||
|
||||
```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 = ec2_client.audit_config.get(
|
||||
"max_security_group_rules", 50
|
||||
)
|
||||
for security_group_arn, security_group in ec2_client.security_groups.items():
|
||||
# ... check logic ...
|
||||
```
|
||||
|
||||
## Required File Updates for Configurable Variables
|
||||
|
||||
When adding a new configurable check to Prowler, update the following files:
|
||||
|
||||
- **Configuration File:** Add the new variable under the relevant provider or service section in `prowler/config/config.yaml`.
|
||||
```yaml
|
||||
# aws.awslambda_function_vpc_multi_az
|
||||
lambda_min_azs: 2
|
||||
```
|
||||
- **Test Fixtures:** If tests depend on this configuration, add the variable to `tests/config/fixtures/config.yaml`.
|
||||
- **Documentation:** Document the new variable in the list of configurable checks in `docs/tutorials/configuration_file.md`.
|
||||
|
||||
For a complete list of checks that already support configuration, see the [Configuration File Tutorial](../tutorials/configuration_file.md).
|
||||
|
||||
This approach ensures that checks are easily configurable, making Prowler highly adaptable to different environments and requirements.
|
||||
@@ -164,3 +164,13 @@ If you enjoy swag, we’d love to thank you for your contribution with laptop st
|
||||
To request swag: Share your pull request details in our [Slack workspace](https://goto.prowler.com/slack).
|
||||
|
||||
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.
|
||||
@@ -139,7 +139,7 @@ Prowler for M365 currently supports the following authentication types:
|
||||
???+ warning
|
||||
For Prowler App only the Service Principal with User Credentials authentication method is supported.
|
||||
|
||||
### Service Principal authentication
|
||||
### Service Principal authentication (recommended)
|
||||
|
||||
Authentication flag: `--sp-env-auth`
|
||||
|
||||
@@ -154,9 +154,11 @@ export AZURE_TENANT_ID="XXXXXXXXX"
|
||||
If you try to execute Prowler with the `--sp-env-auth` flag and those variables are empty or not exported, the execution is going to fail.
|
||||
Follow the instructions in the [Create Prowler Service Principal](../tutorials/microsoft365/getting-started-m365.md#create-the-service-principal-app) section to create a service principal.
|
||||
|
||||
With this credentials you will only be able to run the checks that work through MS Graph, this means that you won't run all the provider. If you want to scan all the checks from M365 you will need to use the recommended authentication method.
|
||||
If you don't add the external API permissions described in the mentioned section above you will only be able to run the checks that work through MS Graph. This means that you won't run all the provider.
|
||||
|
||||
### Service Principal and User Credentials authentication (recommended)
|
||||
If you want to scan all the checks from M365 you will need to use the recommended authentication method or add the external API permissions.
|
||||
|
||||
### Service Principal and User Credentials authentication
|
||||
|
||||
Authentication flag: `--env-auth`
|
||||
|
||||
@@ -213,6 +215,8 @@ Prowler for M365 requires two types of permission scopes to be set (if you want
|
||||
- `Policy.Read.All`: Required for all services.
|
||||
- `SharePointTenantSettings.Read.All`: Required for SharePoint service.
|
||||
- `User.Read` (IMPORTANT: this must be set as **delegated**): Required for the sign-in.
|
||||
- `Exchange.ManageAsApp` from external API `Office 365 Exchange Online`: Required for Exchange PowerShell module app authentication. You also need to assign the `Exchange Administrator` role to the app.
|
||||
- `application_access` from external API `Skype and Teams Tenant Admin API`: Required for Teams PowerShell module app authentication.
|
||||
|
||||
???+ 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.
|
||||
@@ -221,7 +225,8 @@ Prowler for M365 requires two types of permission scopes to be set (if you want
|
||||
|
||||
|
||||
|
||||
- **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:
|
||||
|
||||
- **Powershell Modules Permissions** (if using user credentials): These are set at the `M365_USER` level, so the user used to run Prowler must have one of the following roles:
|
||||
- `Global Reader` (recommended): this allows you to read all roles needed.
|
||||
- `Exchange Administrator` and `Teams Administrator`: user needs both roles but with this [roles](https://learn.microsoft.com/en-us/exchange/permissions-exo/permissions-exo#microsoft-365-permissions-in-exchange-online) you can access to the same information as a Global Reader (since only read access is needed, Global Reader is recommended).
|
||||
|
||||
@@ -439,6 +444,7 @@ The required modules are:
|
||||
|
||||
- [ExchangeOnlineManagement](https://www.powershellgallery.com/packages/ExchangeOnlineManagement/3.6.0): Minimum version 3.6.0. Required for several checks across Exchange, Defender, and Purview.
|
||||
- [MicrosoftTeams](https://www.powershellgallery.com/packages/MicrosoftTeams/6.6.0): Minimum version 6.6.0. Required for all Teams checks.
|
||||
- [MSAL.PS](https://www.powershellgallery.com/packages/MSAL.PS/4.32.0): Required for Exchange module via application authentication.
|
||||
|
||||
## GitHub
|
||||
### Authentication
|
||||
@@ -455,3 +461,54 @@ The provided credentials must have the appropriate permissions to perform all th
|
||||
|
||||
???+ note
|
||||
GitHub App Credentials support less checks than other authentication methods.
|
||||
|
||||
## Infrastructure as Code (IaC)
|
||||
|
||||
Prowler's Infrastructure as Code (IaC) provider enables you to scan local infrastructure code for security and compliance issues using [Checkov](https://www.checkov.io/). This provider supports a wide range of IaC frameworks and requires no cloud authentication.
|
||||
|
||||
### Authentication
|
||||
|
||||
The IaC provider does not require any authentication or credentials since it scans local files directly. This makes it ideal for CI/CD pipelines and local development environments.
|
||||
|
||||
### Supported Frameworks
|
||||
|
||||
The IaC provider leverages Checkov to support multiple frameworks, including:
|
||||
|
||||
- Terraform
|
||||
- CloudFormation
|
||||
- Kubernetes
|
||||
- ARM (Azure Resource Manager)
|
||||
- Serverless
|
||||
- Dockerfile
|
||||
- YAML/JSON (generic IaC)
|
||||
- Bicep
|
||||
- Helm
|
||||
- GitHub Actions, GitLab CI, Bitbucket Pipelines, Azure Pipelines, CircleCI, Argo Workflows
|
||||
- Ansible
|
||||
- Kustomize
|
||||
- OpenAPI
|
||||
- SAST, SCA (Software Composition Analysis)
|
||||
|
||||
### Usage
|
||||
|
||||
To run Prowler with the IaC provider, use the `iac` flag. You can specify the directory to scan, frameworks to include, and paths to exclude.
|
||||
|
||||
#### Basic Example
|
||||
|
||||
```console
|
||||
prowler iac --scan-path ./my-iac-directory
|
||||
```
|
||||
|
||||
#### Specify Frameworks
|
||||
|
||||
Scan only Terraform and Kubernetes files:
|
||||
|
||||
```console
|
||||
prowler iac --scan-path ./my-iac-directory --frameworks terraform kubernetes
|
||||
```
|
||||
|
||||
#### Exclude Paths
|
||||
|
||||
```console
|
||||
prowler iac --scan-path ./my-iac-directory --exclude-path ./my-iac-directory/test,./my-iac-directory/examples
|
||||
```
|
||||
@@ -572,12 +572,12 @@ With M365 you need to specify which auth method is going to be used:
|
||||
|
||||
```console
|
||||
|
||||
# To use service principal authentication for MSGraph and PowerShell modules
|
||||
prowler m365 --sp-env-auth
|
||||
|
||||
# To use both service principal (for MSGraph) and user credentials (for PowerShell modules)
|
||||
prowler m365 --env-auth
|
||||
|
||||
# To use service principal authentication
|
||||
prowler m365 --sp-env-auth
|
||||
|
||||
# To use az cli authentication
|
||||
prowler m365 --az-cli-auth
|
||||
|
||||
@@ -612,5 +612,27 @@ prowler github --github-app-id app_id --github-app-key app_key
|
||||
2. `OAUTH_APP_TOKEN`
|
||||
3. `GITHUB_APP_ID` and `GITHUB_APP_KEY`
|
||||
|
||||
#### Infrastructure as Code (IaC)
|
||||
|
||||
Prowler's Infrastructure as Code (IaC) provider enables you to scan local infrastructure code for security and compliance issues using [Checkov](https://www.checkov.io/). This provider supports a wide range of IaC frameworks, allowing you to assess your code before deployment.
|
||||
|
||||
```console
|
||||
# Scan a directory for IaC files
|
||||
prowler iac --scan-path ./my-iac-directory
|
||||
|
||||
# Specify frameworks to scan (default: all)
|
||||
prowler iac --scan-path ./my-iac-directory --frameworks terraform kubernetes
|
||||
|
||||
# Exclude specific paths
|
||||
prowler iac --scan-path ./my-iac-directory --exclude-path ./my-iac-directory/test,./my-iac-directory/examples
|
||||
```
|
||||
|
||||
???+ note
|
||||
- The IaC provider does not require cloud authentication
|
||||
- It is ideal for CI/CD pipelines and local development environments
|
||||
- For more details on supported frameworks and rules, see the [Checkov documentation](https://www.checkov.io/1.Welcome/Quick%20Start.html)
|
||||
|
||||
See more details about IaC scanning in the [IaC Tutorial](tutorials/iac/getting-started-iac.md) section.
|
||||
|
||||
## Prowler v2 Documentation
|
||||
For **Prowler v2 Documentation**, please check it out [here](https://github.com/prowler-cloud/prowler/blob/8818f47333a0c1c1a457453c87af0ea5b89a385f/README.md).
|
||||
@@ -0,0 +1,67 @@
|
||||
# Getting Started with the IaC Provider
|
||||
|
||||
Prowler's Infrastructure as Code (IaC) provider enables you to scan local infrastructure code for security and compliance issues using [Checkov](https://www.checkov.io/). This provider supports a wide range of IaC frameworks, allowing you to assess your code before deployment.
|
||||
|
||||
## Supported Frameworks
|
||||
|
||||
The IaC provider leverages Checkov to support multiple frameworks, including:
|
||||
|
||||
- Terraform
|
||||
- CloudFormation
|
||||
- Kubernetes
|
||||
- ARM (Azure Resource Manager)
|
||||
- Serverless
|
||||
- Dockerfile
|
||||
- YAML/JSON (generic IaC)
|
||||
- Bicep
|
||||
- Helm
|
||||
- GitHub Actions, GitLab CI, Bitbucket Pipelines, Azure Pipelines, CircleCI, Argo Workflows
|
||||
- Ansible
|
||||
- Kustomize
|
||||
- OpenAPI
|
||||
- SAST, SCA (Software Composition Analysis)
|
||||
|
||||
## How It Works
|
||||
|
||||
- The IaC provider scans your local directory (or a specified path) for supported IaC files.
|
||||
- No cloud credentials or authentication are required.
|
||||
- Mutelist logic is handled by Checkov, not Prowler.
|
||||
- Results are output in the same formats as other Prowler providers (CSV, JSON, HTML, etc.).
|
||||
|
||||
## Usage
|
||||
|
||||
To run Prowler with the IaC provider, use the `iac` argument. You can specify the directory to scan, frameworks to include, and paths to exclude.
|
||||
|
||||
### Basic Example
|
||||
|
||||
```sh
|
||||
prowler iac --scan-path ./my-iac-directory
|
||||
```
|
||||
|
||||
### Specify Frameworks
|
||||
|
||||
Scan only Terraform and Kubernetes files:
|
||||
|
||||
```sh
|
||||
prowler iac --scan-path ./my-iac-directory --frameworks terraform kubernetes
|
||||
```
|
||||
|
||||
### Exclude Paths
|
||||
|
||||
```sh
|
||||
prowler iac --scan-path ./my-iac-directory --exclude-path ./my-iac-directory/test,./my-iac-directory/examples
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
You can use the standard Prowler output options, for example:
|
||||
|
||||
```sh
|
||||
prowler iac --scan-path ./iac --output-formats csv json html
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The IaC provider does not require cloud authentication.
|
||||
- It is ideal for CI/CD pipelines and local development environments.
|
||||
- For more details on supported frameworks and rules, see the [Checkov documentation](https://www.checkov.io/1.Welcome/Quick%20Start.html).
|
||||
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 332 KiB |
@@ -92,7 +92,7 @@ With this done you will have all the needed keys, summarized in the following ta
|
||||
|
||||
---
|
||||
|
||||
### Grant required API permissions
|
||||
### Grant required Graph API permissions
|
||||
|
||||
Assign the following Microsoft Graph permissions:
|
||||
|
||||
@@ -100,7 +100,7 @@ Assign the following Microsoft Graph permissions:
|
||||
- `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.
|
||||
- `User.Read` (IMPORTANT: this is set as **delegated**): Required for the sign-in only if using user authentication.
|
||||
|
||||
???+ note
|
||||
You can replace `Directory.Read.All` with `Domain.Read.All` is a more restrictive permission but you won't be able to run the Entra checks related with DirectoryRoles and GetUsers.
|
||||
@@ -128,18 +128,83 @@ Follow these steps to assign the permissions:
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
4. Click `+ Add a permission` > `Microsoft Graph` > `Delegated permissions`
|
||||
|
||||

|
||||
### Grant PowerShell modules permissions
|
||||
|
||||
5. Search and select:
|
||||
The permissions you need to grant depends on whether you are using user credentials or service principal to authenticate to the M365 modules.
|
||||
|
||||
???+ warning "Warning"
|
||||
Make sure you add the correct set of permissions for the authentication method you are using.
|
||||
|
||||
|
||||
#### If using application(service principal) authentication
|
||||
|
||||
???+ warning "Warning"
|
||||
Currently Prowler Cloud only supports user authentication.
|
||||
|
||||
To grant the permissions for the PowerShell modules via application authentication, you need to add the necessary APIs to your app registration.
|
||||
|
||||
???+ warning "Warning"
|
||||
You need to have a license that allows you to use the APIs.
|
||||
|
||||
1. Add Exchange API:
|
||||
|
||||
- Search and select`Office 365 Exchange Online` API in **APIs my organization uses**.
|
||||
|
||||

|
||||
|
||||
- Select `Exchange.ManageAsApp` permission and click on `Add permissions`.
|
||||
|
||||

|
||||
|
||||
You also need to assign the `Exchange Administrator` role to the app. For that go to `Roles and administrators` and in the `Administrative roles` section click `here` to go to the directory level assignment:
|
||||
|
||||

|
||||
|
||||
Once in the directory level assignment, search for `Exchange Administrator` and click on it to open the assginments page of that role.
|
||||
|
||||

|
||||
|
||||
Click on `Add assignments`, search for your app and click on `Assign`.
|
||||
|
||||
You have to select it as `Active` and click on `Assign` to assign the role to the app.
|
||||
|
||||

|
||||
|
||||
2. Add Teams API:
|
||||
|
||||
- Search and select `Skype and Teams Tenant Admin API` API in **APIs my organization uses**.
|
||||
|
||||

|
||||
|
||||
- Select `application_access` permission and click on `Add permissions`.
|
||||
|
||||

|
||||
|
||||
3. Click on `Grant admin consent for <your-tenant-name>` to grant admin consent.
|
||||
|
||||

|
||||
|
||||
The final result of permission assignment should be this:
|
||||
|
||||

|
||||
|
||||
???+ 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.
|
||||
|
||||
---
|
||||
|
||||
#### If using user authentication (Currently Prowler Cloud only supports this method)
|
||||
|
||||
1. Search and select:
|
||||
|
||||
- `User.Read`
|
||||
|
||||

|
||||
|
||||
6. After adding all the permissions, click on `Grant admin consent`
|
||||
2. Click `Add permissions`, then **grant admin consent**
|
||||
|
||||

|
||||
|
||||
@@ -147,37 +212,32 @@ Follow these steps to assign the permissions:
|
||||
|
||||

|
||||
|
||||
---
|
||||
3. Assign **required roles** to your **user**
|
||||
|
||||
### Assign required roles to your user
|
||||
Assign one of the following roles to your User:
|
||||
|
||||
Assign one of the following roles to your User:
|
||||
- `Global Reader` (recommended): this allows you to read all roles needed.
|
||||
- `Exchange Administrator` and `Teams Administrator`: user needs both roles but with this [roles](https://learn.microsoft.com/en-us/exchange/permissions-exo/permissions-exo#microsoft-365-permissions-in-exchange-online) you can access to the same information as a Global Reader (here you only read so that's why we recomend that role).
|
||||
|
||||
- `Global Reader` (recommended): this allows you to read all roles needed.
|
||||
- `Exchange Administrator` and `Teams Administrator`: user needs both roles but with this [roles](https://learn.microsoft.com/en-us/exchange/permissions-exo/permissions-exo#microsoft-365-permissions-in-exchange-online) you can access to the same information as a Global Reader (here you only read so that's why we recomend that role).
|
||||
Follow these steps to assign the role:
|
||||
|
||||
Follow these steps to assign the role:
|
||||
1. Go to Users > All Users > Click on the email for the user you will use
|
||||
|
||||
1. Go to Users > All Users > Click on the email for the user you will use
|
||||

|
||||
|
||||

|
||||
2. Click `Assigned Roles`
|
||||
|
||||
2. Click `Assigned Roles`
|
||||

|
||||
|
||||

|
||||
3. Click on `Add assignments`, then search and select:
|
||||
|
||||
3. Click on `Add assignments`, then search and select:
|
||||
- `Global Reader` This is the recommended, if you want to use the others just search for them
|
||||
|
||||
- `Global Reader` This is the recommended, if you want to use the others just search for them
|
||||

|
||||
|
||||

|
||||
4. Click on next, then assign the role as `Active`, and click on `Assign` to grant admin consent
|
||||
|
||||
4. Click on next, then assign the role as `Active`, and click on `Assign` to grant admin consent
|
||||
|
||||

|
||||
|
||||
???+ 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.
|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -24,7 +24,7 @@ DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,prowler-api,mycompany.prowler
|
||||
|
||||
To enable SAML support, you must provide a public certificate and private key to allow Prowler to sign SAML requests and validate responses.
|
||||
|
||||
### Why is this necessary?
|
||||
### Why is this necessary?
|
||||
|
||||
SAML relies on digital signatures to verify trust between the Identity Provider (IdP) and the Service Provider (SP). Prowler acts as the SP and must use a certificate to sign outbound authentication requests.
|
||||
|
||||
@@ -121,7 +121,7 @@ Start ngrok on port 8080:
|
||||
ngrok http 8080
|
||||
```
|
||||
|
||||
Then, copy the generated ngrok URL and include it in the ALLOWED_HOSTS setting. If you’re using the development environment, it usually defaults to *, but in some cases this may not work properly, like in my tests (investigate):
|
||||
Then, copy the generated ngrok URL and include it in the ALLOWED_HOSTS setting. If you're using the development environment, it usually defaults to *, but in some cases this may not work properly, like in my tests (investigate):
|
||||
|
||||
```
|
||||
ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"])
|
||||
@@ -129,7 +129,7 @@ ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["*"])
|
||||
|
||||
## 4. Configure the Identity Provider (IdP)
|
||||
|
||||
Start your environment and configure your IdP. You will need to download the IdP’s metadata XML file.
|
||||
Start your environment and configure your IdP. You will need to download the IdP's metadata XML file.
|
||||
|
||||
Your Assertion Consumer Service (ACS) URL must follow this format:
|
||||
|
||||
@@ -147,7 +147,7 @@ The following fields are expected from the IdP:
|
||||
|
||||
- userType (this is the name of the role the user should be assigned)
|
||||
|
||||
- companyName (this is filled automatically if the IdP includes an “organization” field)
|
||||
- companyName (this is filled automatically if the IdP includes an "organization" field)
|
||||
|
||||
These values are dynamic. If the values change in the IdP, they will be updated on the next login.
|
||||
|
||||
@@ -171,7 +171,37 @@ curl --location 'http://localhost:8080/api/v1/saml-config' \
|
||||
}'
|
||||
```
|
||||
|
||||
## 7. Start SAML Login Flow
|
||||
## 7. SAML SSO Callback Configuration
|
||||
|
||||
### Environment Variable Configuration
|
||||
|
||||
The SAML authentication flow requires proper callback URL configuration to handle post-authentication redirects. Configure the following environment variables:
|
||||
|
||||
#### `SAML_SSO_CALLBACK_URL`
|
||||
|
||||
Specifies the callback endpoint that will be invoked upon successful SAML authentication completion. This URL directs users back to the web application interface.
|
||||
|
||||
```env
|
||||
SAML_SSO_CALLBACK_URL="${AUTH_URL}/api/auth/callback/saml"
|
||||
```
|
||||
|
||||
#### `AUTH_URL`
|
||||
|
||||
Defines the base URL of the web user interface application that serves as the authentication callback destination.
|
||||
|
||||
```env
|
||||
AUTH_URL="<WEB_UI_URL>"
|
||||
```
|
||||
|
||||
### Configuration Notes
|
||||
|
||||
- The `SAML_SSO_CALLBACK_URL` dynamically references the `AUTH_URL` variable to construct the complete callback endpoint
|
||||
- Ensure the `AUTH_URL` points to the correct web UI deployment (development, staging, or production)
|
||||
- The callback endpoint `/api/auth/callback/saml` must be accessible and properly configured to handle SAML authentication responses
|
||||
- Both environment variables are required for proper SAML SSO functionality
|
||||
- Verify that the `NEXT_PUBLIC_API_BASE_URL` environment variable is properly configured to reference the correct API server base URL corresponding to your target deployment environment. This ensures proper routing of SAML callback requests to the appropriate backend services.
|
||||
|
||||
## 8. Start SAML Login Flow
|
||||
|
||||
Once everything is configured, start the SAML login process by visiting the following URL:
|
||||
|
||||
@@ -181,6 +211,6 @@ https://<PROXY_IP>/api/v1/accounts/saml/<CONFIGURED_DOMAIN>/login/?email=<USER_E
|
||||
|
||||
At the end you will get a valid access and refresh token
|
||||
|
||||
## 8. Notes on the initiate Endpoint
|
||||
## 9. Notes on the initiate Endpoint
|
||||
|
||||
The initiate endpoint is not strictly required. It was created to allow extra checks or behavior modifications (like enumeration mitigation). It also simplifies UI integration with SAML, but again, it’s optional.
|
||||
The initiate endpoint is not strictly required. It was created to allow extra checks or behavior modifications (like enumeration mitigation). It also simplifies UI integration with SAML, but again, it's optional.
|
||||
@@ -101,7 +101,26 @@ For Azure, Prowler App uses a service principal application to authenticate, for
|
||||
|
||||
---
|
||||
### **Step 4.3: GCP Credentials**
|
||||
To connect your GCP Project, you need to use the Application Default Credentials (ADC) returned by the `gcloud` CLI. Here’s how to set up:
|
||||
|
||||
For Google Cloud, first enter your `GCP Project ID` and then select the authentication method you want to use:
|
||||
|
||||
- **Service Account Authentication** (**Recommended**)
|
||||
- **Application Default Credentials**
|
||||
|
||||
**Service Account Authentication** is the recommended authentication method for automated systems and machine-to-machine interactions, like Prowler. For detailed information about this, refer to the [Google Cloud documentation](https://cloud.google.com/iam/docs/service-account-overview).
|
||||
|
||||
<img src="../img/gcp-auth-methods.png" alt="GCP Authentication Methods" width="700"/>
|
||||
|
||||
#### **Step 4.3.1: Service Account Authentication**
|
||||
|
||||
First of all, in the same project that you selected in the previous step, you need to create a service account and then generate a key in JSON format for it. For more information about this, you can follow the next Google Cloud documentation tutorials:
|
||||
|
||||
- [Create a service account](https://cloud.google.com/iam/docs/creating-managing-service-accounts)
|
||||
- [Generate a key for a service account](https://cloud.google.com/iam/docs/creating-managing-service-account-keys)
|
||||
|
||||
<img src="../img/gcp-service-account-creds.png" alt="GCP Service Account Credentials" width="700"/>
|
||||
|
||||
#### **Step 4.3.2: Application Default Credentials**
|
||||
|
||||
1. Run the following command in your terminal to authenticate with GCP:
|
||||
```bash
|
||||
|
||||
@@ -106,6 +106,8 @@ nav:
|
||||
- Getting Started: tutorials/microsoft365/getting-started-m365.md
|
||||
- Authentication: tutorials/microsoft365/authentication.md
|
||||
- Use of PowerShell: tutorials/microsoft365/use-of-powershell.md
|
||||
- IaC:
|
||||
- Getting Started: tutorials/iac/getting-started-iac.md
|
||||
- Developer Guide:
|
||||
- General Concepts:
|
||||
- Introduction: developer-guide/introduction.md
|
||||
@@ -128,6 +130,7 @@ nav:
|
||||
- Unit Tests: developer-guide/unit-testing.md
|
||||
- Integration Tests: developer-guide/integration-testing.md
|
||||
- Debugging: developer-guide/debugging.md
|
||||
- Configurable Checks: developer-guide/configurable-checks.md
|
||||
- Security: security.md
|
||||
- Contact Us: contact.md
|
||||
- Troubleshooting: troubleshooting.md
|
||||
|
||||
@@ -2,18 +2,29 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [v5.8.0] (Prowler UNRELEASED)
|
||||
## [v5.9.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)
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
---
|
||||
|
||||
## [v5.8.0] (Prowler v5.8.0)
|
||||
|
||||
### Added
|
||||
|
||||
- `storage_geo_redundant_enabled` check for Azure provider [(#7980)](https://github.com/prowler-cloud/prowler/pull/7980)
|
||||
- `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)
|
||||
- New check `entra_users_mfa_capable` for M365 provider [(#7734)](https://github.com/prowler-cloud/prowler/pull/7734)
|
||||
- New check `admincenter_organization_customer_lockbox_enabled` for M365 provider [(#7732)](https://github.com/prowler-cloud/prowler/pull/7732)
|
||||
- New check `admincenter_external_calendar_sharing_disabled` for M365 provider [(#7733)](https://github.com/prowler-cloud/prowler/pull/7733)
|
||||
- a level for Prowler ThreatScore in the accordion in Dashboard [(#7739)](https://github.com/prowler-cloud/prowler/pull/7739)
|
||||
- `entra_users_mfa_capable` check for M365 provider [(#7734)](https://github.com/prowler-cloud/prowler/pull/7734)
|
||||
- `admincenter_organization_customer_lockbox_enabled` check for M365 provider [(#7732)](https://github.com/prowler-cloud/prowler/pull/7732)
|
||||
- `admincenter_external_calendar_sharing_disabled` check for M365 provider [(#7733)](https://github.com/prowler-cloud/prowler/pull/7733)
|
||||
- Level for Prowler ThreatScore in the accordion in Dashboard [(#7739)](https://github.com/prowler-cloud/prowler/pull/7739)
|
||||
- CIS 4.0 compliance framework for GCP [(7785)](https://github.com/prowler-cloud/prowler/pull/7785)
|
||||
- `repository_has_codeowners_file` check for GitHub provider [(#7752)](https://github.com/prowler-cloud/prowler/pull/7752)
|
||||
- `repository_default_branch_requires_signed_commits` check for GitHub provider [(#7777)](https://github.com/prowler-cloud/prowler/pull/7777)
|
||||
@@ -31,24 +42,43 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- 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)
|
||||
- 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)
|
||||
- `databricks_workspace_cmk_encryption_enabled` check for Azure provider [(#8017)](https://github.com/prowler-cloud/prowler/pull/8017)
|
||||
- Appication auth for PowerShell in M365 provider [(#7992)](https://github.com/prowler-cloud/prowler/pull/7992)
|
||||
- `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)
|
||||
- `monitor_alert_service_health_exists` check 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)
|
||||
- New check `codebuild_project_not_publicly_accessible` for AWS provider [(#8127)](https://github.com/prowler-cloud/prowler/pull/8127)
|
||||
|
||||
### Fixed
|
||||
- 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)
|
||||
- Migrate Azure VM service and managed disk logic to Pydantic models for better serialization and type safety, and update all related tests to use the new models and fix UUID handling [(#https://github.com/prowler-cloud/prowler/pull/8151)](https://github.com/prowler-cloud/prowler/pull/https://github.com/prowler-cloud/prowler/pull/8151)
|
||||
- `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)
|
||||
|
||||
### 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 UNRELEASED)
|
||||
## [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)
|
||||
- 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)
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
- "*"
|
||||
|
||||
@@ -7,6 +7,7 @@ from dataclasses import asdict, dataclass, is_dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional, Set
|
||||
|
||||
from checkov.common.output.record import Record
|
||||
from pydantic.v1 import BaseModel, ValidationError, validator
|
||||
|
||||
from prowler.config.config import Provider
|
||||
@@ -441,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"
|
||||
@@ -519,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
|
||||
@@ -622,7 +629,7 @@ class CheckReportIAC(Check_Report):
|
||||
resource_path: str
|
||||
resource_line_range: str
|
||||
|
||||
def __init__(self, metadata: dict = {}, finding: dict = {}) -> None:
|
||||
def __init__(self, metadata: dict = {}, resource: Record = None) -> None:
|
||||
"""
|
||||
Initialize the IAC Check's finding information from a Checkov failed_check dict.
|
||||
|
||||
@@ -630,11 +637,10 @@ class CheckReportIAC(Check_Report):
|
||||
metadata (Dict): Optional check metadata (can be None).
|
||||
failed_check (dict): A single failed_check result from Checkov's JSON output.
|
||||
"""
|
||||
super().__init__(metadata, finding)
|
||||
|
||||
self.resource_name = getattr(finding, "resource", "")
|
||||
self.resource_path = getattr(finding, "file_path", "")
|
||||
self.resource_line_range = getattr(finding, "file_line_range", "")
|
||||
super().__init__(metadata, resource)
|
||||
self.resource_name = resource.resource
|
||||
self.resource_path = resource.file_path
|
||||
self.resource_line_range = resource.file_line_range
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -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 {}
|
||||
@@ -286,8 +286,8 @@ class Finding(BaseModel):
|
||||
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["resource"]
|
||||
output_data["resource_uid"] = check_output.resource["resource"]
|
||||
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"] = (
|
||||
|
||||
@@ -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",
|
||||
@@ -948,6 +951,7 @@
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-east-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
@@ -1333,7 +1337,9 @@
|
||||
"aws": [
|
||||
"us-east-1"
|
||||
],
|
||||
"aws-cn": [],
|
||||
"aws-cn": [
|
||||
"cn-northwest-1"
|
||||
],
|
||||
"aws-us-gov": []
|
||||
}
|
||||
},
|
||||
@@ -2782,6 +2788,9 @@
|
||||
"regions": {
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
@@ -3397,6 +3406,7 @@
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"il-central-1",
|
||||
"me-central-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
@@ -3457,6 +3467,7 @@
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-east-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
@@ -3502,6 +3513,7 @@
|
||||
"regions": {
|
||||
"aws": [
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
@@ -4983,6 +4995,8 @@
|
||||
"ap-south-1",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-5",
|
||||
"ap-southeast-7",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-north-1",
|
||||
@@ -6231,6 +6245,7 @@
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-east-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
@@ -7825,13 +7840,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",
|
||||
@@ -8103,6 +8123,8 @@
|
||||
"regions": {
|
||||
"aws": [
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-3",
|
||||
"ap-south-1",
|
||||
"ap-southeast-1",
|
||||
"eu-central-1",
|
||||
"eu-west-1",
|
||||
@@ -8205,7 +8227,10 @@
|
||||
"us-west-2"
|
||||
],
|
||||
"aws-cn": [],
|
||||
"aws-us-gov": []
|
||||
"aws-us-gov": [
|
||||
"us-gov-east-1",
|
||||
"us-gov-west-1"
|
||||
]
|
||||
}
|
||||
},
|
||||
"personalize": {
|
||||
@@ -8843,6 +8868,7 @@
|
||||
"aws": [
|
||||
"af-south-1",
|
||||
"ap-east-1",
|
||||
"ap-east-2",
|
||||
"ap-northeast-1",
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
@@ -9885,7 +9911,10 @@
|
||||
"cn-north-1",
|
||||
"cn-northwest-1"
|
||||
],
|
||||
"aws-us-gov": []
|
||||
"aws-us-gov": [
|
||||
"us-gov-east-1",
|
||||
"us-gov-west-1"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
@@ -10303,17 +10332,20 @@
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
"ap-south-1",
|
||||
"ap-south-2",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-3",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-central-2",
|
||||
"eu-north-1",
|
||||
"eu-south-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"il-central-1",
|
||||
"me-central-1",
|
||||
"me-south-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
@@ -10336,17 +10368,20 @@
|
||||
"ap-northeast-2",
|
||||
"ap-northeast-3",
|
||||
"ap-south-1",
|
||||
"ap-south-2",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-3",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-central-2",
|
||||
"eu-north-1",
|
||||
"eu-south-1",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
"il-central-1",
|
||||
"me-central-1",
|
||||
"me-south-1",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "codebuild_project_not_publicly_accessible",
|
||||
"CheckTitle": "Ensure AWS CodeBuild projects are not public",
|
||||
"CheckType": [],
|
||||
"ServiceName": "codebuild",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:aws:codebuild:region:account-id:project:project-name",
|
||||
"Severity": "high",
|
||||
"ResourceType": "AwsCodeBuildProject",
|
||||
"Description": "Check for CodeBuild projects ensuring that the project visibility is appropriate",
|
||||
"Risk": "Public CodeBuild Project ensures all build logs and artifacts are available to the public. Environment variables, source code, and other sensitive information may have been output to the build logs and artifacts. You must be careful about what information is output to the build logs.",
|
||||
"RelatedUrl": "",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"NativeIaC": "",
|
||||
"Terraform": "",
|
||||
"CLI": "aws codebuild update-project --name <project-name> --project-visibility PRIVATE",
|
||||
"Other": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Ensure that all CodeBuild projects are private to avoid fact gathering about builds from an Attacker.",
|
||||
"Url": "https://docs.aws.amazon.com/codebuild/latest/userguide/public-builds.html"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.codebuild.codebuild_client import codebuild_client
|
||||
|
||||
|
||||
class codebuild_project_not_publicly_accessible(Check):
|
||||
def execute(self) -> List[Check_Report_AWS]:
|
||||
findings = []
|
||||
|
||||
projects = codebuild_client.projects
|
||||
for arn, project in projects.items():
|
||||
report = Check_Report_AWS(self.metadata(), resource=project)
|
||||
report.resource_id = project.name
|
||||
report.resource_arn = arn
|
||||
report.region = project.region
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"CodeBuild project {project.name} is public."
|
||||
|
||||
if project.project_visibility == "PRIVATE":
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"CodeBuild project {project.name} is private."
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -121,6 +121,7 @@ class Codebuild(AWSService):
|
||||
)
|
||||
project.tags = project_info.get("tags", [])
|
||||
project.service_role_arn = project_info.get("serviceRole", "")
|
||||
project.project_visibility = project_info.get("projectVisibility", "")
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
@@ -222,6 +223,7 @@ class Project(BaseModel):
|
||||
s3_logs: Optional[s3Logs]
|
||||
cloudwatch_logs: Optional[CloudWatchLogs]
|
||||
tags: Optional[list]
|
||||
project_visibility: Optional[str] = None
|
||||
|
||||
|
||||
class ExportConfig(BaseModel):
|
||||
|
||||
@@ -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
|
||||
@@ -175,6 +175,7 @@ class ECS(AWSService):
|
||||
clusters=[cluster.arn],
|
||||
include=[
|
||||
"TAGS",
|
||||
"SETTINGS",
|
||||
],
|
||||
)
|
||||
cluster.settings = response["clusters"][0].get("settings", [])
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]}}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <resource_group> --name <vault_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": ""
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 <subscription-id> --resource-group <resource-group> --name <alert-rule> --condition category=ServiceHealth and properties.incidentType=Incident --scope /subscriptions/<subscription-id> --action-group <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."
|
||||
}
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -147,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}"
|
||||
@@ -230,6 +221,13 @@ class ReplicationSettings(Enum):
|
||||
STANDARD_RAGZRS = "Standard_RAGZRS"
|
||||
|
||||
|
||||
class FileServiceProperties(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
share_delete_retention_policy: DeleteRetentionPolicy
|
||||
|
||||
|
||||
@dataclass
|
||||
class Account:
|
||||
id: str
|
||||
@@ -249,12 +247,4 @@ class Account:
|
||||
allow_shared_key_access: bool = True
|
||||
blob_properties: Optional[BlobProperties] = None
|
||||
default_to_entra_authorization: bool = False
|
||||
file_shares: list = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileShare:
|
||||
id: str
|
||||
name: str
|
||||
soft_delete_enabled: bool
|
||||
retention_days: int
|
||||
file_service_properties: Optional[FileServiceProperties] = None
|
||||
@@ -15,17 +15,17 @@ class vm_ensure_using_managed_disks(Check):
|
||||
|
||||
using_managed_disks = (
|
||||
True
|
||||
if vm.storage_profile
|
||||
and getattr(vm.storage_profile, "os_disk", False)
|
||||
and getattr(vm.storage_profile.os_disk, "managed_disk", False)
|
||||
if getattr(
|
||||
getattr(getattr(vm, "storage_profile", None), "os_disk", None),
|
||||
"managed_disk",
|
||||
None,
|
||||
)
|
||||
else False
|
||||
)
|
||||
|
||||
if using_managed_disks and getattr(
|
||||
vm.storage_profile, "data_disks", False
|
||||
):
|
||||
if using_managed_disks and getattr(vm, "storage_profile", None):
|
||||
for data_disk in vm.storage_profile.data_disks:
|
||||
if not getattr(data_disk, "managed_disk", False):
|
||||
if not getattr(data_disk, "managed_disk", None):
|
||||
using_managed_disks = False
|
||||
break
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
from azure.mgmt.compute import ComputeManagementClient
|
||||
from pydantic import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.azure.azure_provider import AzureProvider
|
||||
@@ -30,24 +32,33 @@ class VirtualMachines(AzureService):
|
||||
if storage_profile
|
||||
else None
|
||||
)
|
||||
|
||||
data_disks = []
|
||||
|
||||
if storage_profile and getattr(storage_profile, "data_disks", []):
|
||||
data_disks = [
|
||||
DataDisk(
|
||||
lun=data_disk.lun,
|
||||
name=data_disk.name,
|
||||
managed_disk=data_disk.managed_disk,
|
||||
managed_disk=ManagedDiskParameters(
|
||||
id=(
|
||||
getattr(
|
||||
getattr(data_disk, "managed_disk", None),
|
||||
"id",
|
||||
None,
|
||||
)
|
||||
if data_disk.managed_disk
|
||||
else None
|
||||
)
|
||||
),
|
||||
)
|
||||
for data_disk in getattr(storage_profile, "data_disks", [])
|
||||
if data_disk
|
||||
]
|
||||
|
||||
extensions = []
|
||||
if getattr(vm, "resources", []):
|
||||
extensions = [
|
||||
VirtualMachineExtension(id=extension.id)
|
||||
for extension in getattr(vm, "resources", [])
|
||||
for extension in vm.resources
|
||||
if extension
|
||||
]
|
||||
|
||||
@@ -60,8 +71,17 @@ class VirtualMachines(AzureService):
|
||||
StorageProfile(
|
||||
os_disk=OSDisk(
|
||||
name=getattr(os_disk, "name", None),
|
||||
managed_disk=getattr(
|
||||
os_disk, "managed_disk", None
|
||||
operating_system_type=getattr(
|
||||
os_disk, "os_type", None
|
||||
),
|
||||
managed_disk=ManagedDiskParameters(
|
||||
id=getattr(
|
||||
getattr(
|
||||
os_disk, "managed_disk", None
|
||||
),
|
||||
"id",
|
||||
None,
|
||||
)
|
||||
),
|
||||
),
|
||||
data_disks=data_disks,
|
||||
@@ -130,32 +150,37 @@ class SecurityProfile:
|
||||
uefi_settings: Optional[UefiSettings]
|
||||
|
||||
|
||||
@dataclass
|
||||
class OSDisk:
|
||||
name: Optional[str]
|
||||
managed_disk: Optional[bool]
|
||||
class OperatingSystemType(Enum):
|
||||
WINDOWS = "Windows"
|
||||
LINUX = "Linux"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataDisk:
|
||||
class ManagedDiskParameters(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class OSDisk(BaseModel):
|
||||
name: str
|
||||
operating_system_type: OperatingSystemType
|
||||
managed_disk: Optional[ManagedDiskParameters]
|
||||
|
||||
|
||||
class DataDisk(BaseModel):
|
||||
lun: int
|
||||
name: str
|
||||
managed_disk: bool
|
||||
managed_disk: Optional[ManagedDiskParameters]
|
||||
|
||||
|
||||
@dataclass
|
||||
class StorageProfile:
|
||||
class StorageProfile(BaseModel):
|
||||
os_disk: Optional[OSDisk]
|
||||
data_disks: List[DataDisk]
|
||||
|
||||
|
||||
@dataclass
|
||||
class VirtualMachineExtension:
|
||||
class VirtualMachineExtension(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class VirtualMachine:
|
||||
class VirtualMachine(BaseModel):
|
||||
resource_id: str
|
||||
resource_name: str
|
||||
location: str
|
||||
@@ -164,8 +189,7 @@ class VirtualMachine:
|
||||
storage_profile: Optional[StorageProfile] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Disk:
|
||||
class Disk(BaseModel):
|
||||
resource_id: str
|
||||
resource_name: str
|
||||
vms_attached: list[str]
|
||||
|
||||
@@ -246,6 +246,8 @@ class Provider(ABC):
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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)."
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
import json
|
||||
import subprocess
|
||||
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 (
|
||||
@@ -23,6 +54,8 @@ class IacProvider(Provider):
|
||||
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 = {},
|
||||
@@ -30,6 +63,8 @@ class IacProvider(Provider):
|
||||
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
|
||||
@@ -86,7 +121,9 @@ class IacProvider(Provider):
|
||||
"""IAC provider doesn't need a session since it uses Checkov directly"""
|
||||
return None
|
||||
|
||||
def _process_check(self, finding: dict, check: dict, status: str) -> CheckReportIAC:
|
||||
def _process_check(
|
||||
self, finding: Report, check: Record, status: str
|
||||
) -> CheckReportIAC:
|
||||
"""
|
||||
Process a single check (failed or passed) and create a CheckReportIAC object.
|
||||
|
||||
@@ -98,109 +135,110 @@ class IacProvider(Provider):
|
||||
Returns:
|
||||
CheckReportIAC: The processed check report
|
||||
"""
|
||||
metadata_dict = {
|
||||
"Provider": "iac",
|
||||
"CheckID": check.get("check_id", ""),
|
||||
"CheckTitle": check.get("check_name", ""),
|
||||
"CheckType": ["Infrastructure as Code"],
|
||||
"ServiceName": finding["check_type"],
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": (
|
||||
check.get("severity", "low").lower() if check.get("severity") else "low"
|
||||
),
|
||||
"ResourceType": "iac",
|
||||
"Description": check.get("check_name", ""),
|
||||
"Risk": "",
|
||||
"RelatedUrl": (
|
||||
check.get("guideline", "") if check.get("guideline") else ""
|
||||
),
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"NativeIaC": "",
|
||||
"Terraform": "",
|
||||
"CLI": "",
|
||||
"Other": "",
|
||||
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 ""),
|
||||
},
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "",
|
||||
"Url": (
|
||||
check.get("guideline", "") if check.get("guideline") else ""
|
||||
),
|
||||
},
|
||||
},
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "",
|
||||
}
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "",
|
||||
}
|
||||
|
||||
# Convert metadata dict to JSON string
|
||||
metadata = json.dumps(metadata_dict)
|
||||
# Convert metadata dict to JSON string
|
||||
metadata = json.dumps(metadata_dict)
|
||||
|
||||
report = CheckReportIAC(metadata=metadata, finding=check)
|
||||
report.status = status
|
||||
report.resource_tags = check.get("entity_tags", {})
|
||||
report.status_extended = check.get("check_name", "")
|
||||
if status == "MUTED":
|
||||
report.muted = True
|
||||
return report
|
||||
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)
|
||||
return self.run_scan(self.scan_path, self.frameworks, self.exclude_path)
|
||||
|
||||
def run_scan(self, directory: str) -> List[CheckReportIAC]:
|
||||
def run_scan(
|
||||
self, directory: str, frameworks: list[str], exclude_path: list[str]
|
||||
) -> List[CheckReportIAC]:
|
||||
try:
|
||||
logger.info(f"Running IaC scan on {directory}...")
|
||||
|
||||
# Run Checkov with JSON output
|
||||
process = subprocess.run(
|
||||
["checkov", "-d", directory, "-o", "json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
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
|
||||
)
|
||||
# Log Checkov's error output if any
|
||||
if process.stderr:
|
||||
logger.error(process.stderr)
|
||||
|
||||
try:
|
||||
output = json.loads(process.stdout)
|
||||
if not output:
|
||||
logger.warning("No findings returned from Checkov scan")
|
||||
return []
|
||||
except Exception as error:
|
||||
logger.critical(
|
||||
f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}"
|
||||
)
|
||||
sys.exit(1)
|
||||
registry = RunnerRegistry("", runner_filter, *runners)
|
||||
checkov_reports = registry.run(root_folder=directory)
|
||||
|
||||
reports = []
|
||||
reports: List[CheckReportIAC] = []
|
||||
for report in checkov_reports:
|
||||
|
||||
# If only one framework has findings, the output is a dict, otherwise it's a list of dicts
|
||||
if isinstance(output, dict):
|
||||
output = [output]
|
||||
for failed in report.failed_checks:
|
||||
reports.append(self._process_check(report, failed, "FAIL"))
|
||||
|
||||
# Process all frameworks findings
|
||||
for finding in output:
|
||||
results = finding.get("results", {})
|
||||
for passed in report.passed_checks:
|
||||
reports.append(self._process_check(report, passed, "PASS"))
|
||||
|
||||
# Process failed checks
|
||||
failed_checks = results.get("failed_checks", [])
|
||||
for failed_check in failed_checks:
|
||||
report = self._process_check(finding, failed_check, "FAIL")
|
||||
reports.append(report)
|
||||
|
||||
# Process passed checks
|
||||
passed_checks = results.get("passed_checks", [])
|
||||
for passed_check in passed_checks:
|
||||
report = self._process_check(finding, passed_check, "PASS")
|
||||
reports.append(report)
|
||||
|
||||
# Process skipped checks (muted)
|
||||
skipped_checks = results.get("skipped_checks", [])
|
||||
for skipped_check in skipped_checks:
|
||||
report = self._process_check(finding, skipped_check, "MUTED")
|
||||
reports.append(report)
|
||||
for skipped in report.skipped_checks:
|
||||
reports.append(self._process_check(report, skipped, "MUTED"))
|
||||
|
||||
return reports
|
||||
|
||||
@@ -214,5 +252,12 @@ class IacProvider(Provider):
|
||||
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)
|
||||
@@ -1,3 +1,36 @@
|
||||
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(
|
||||
@@ -13,3 +46,20 @@ def init_parser(self):
|
||||
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",
|
||||
)
|
||||
@@ -114,6 +114,18 @@ class M365BaseException(ProwlerException):
|
||||
"message": "The provided User does not belong to the specified tenant.",
|
||||
"remediation": "Check the User email domain and ensure it belongs to the specified tenant.",
|
||||
},
|
||||
(6027, "M365GraphConnectionError"): {
|
||||
"message": "Failed to establish connection to Microsoft Graph API.",
|
||||
"remediation": "Check your Microsoft Application credentials and ensure the app has proper permissions.",
|
||||
},
|
||||
(6028, "M365TeamsConnectionError"): {
|
||||
"message": "Failed to establish connection to Microsoft Teams API.",
|
||||
"remediation": "Ensure the application has proper permission granted to access Microsoft Teams.",
|
||||
},
|
||||
(6029, "M365ExchangeConnectionError"): {
|
||||
"message": "Failed to establish connection to Exchange Online API.",
|
||||
"remediation": "Ensure the application has proper permission granted to access Exchange Online.",
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, code, file=None, original_exception=None, message=None):
|
||||
@@ -324,3 +336,24 @@ class M365UserNotBelongingToTenantError(M365CredentialsError):
|
||||
super().__init__(
|
||||
6026, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class M365GraphConnectionError(M365CredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
6027, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class M365TeamsConnectionError(M365CredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
6028, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class M365ExchangeConnectionError(M365CredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
6029, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
@@ -6,6 +6,9 @@ import msal
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.powershell.powershell import PowerShellSession
|
||||
from prowler.providers.m365.exceptions.exceptions import (
|
||||
M365ExchangeConnectionError,
|
||||
M365GraphConnectionError,
|
||||
M365TeamsConnectionError,
|
||||
M365UserNotBelongingToTenantError,
|
||||
)
|
||||
from prowler.providers.m365.models import M365Credentials, M365IdentityInfo
|
||||
@@ -65,21 +68,33 @@ class M365PowerShell(PowerShellSession):
|
||||
The credentials are sanitized to prevent command injection and
|
||||
stored securely in the PowerShell session.
|
||||
"""
|
||||
# User Auth (Will be deprecated in September 2025)
|
||||
if credentials.user and credentials.passwd:
|
||||
credentials.encrypted_passwd = self.encrypt_password(credentials.passwd)
|
||||
|
||||
credentials.encrypted_passwd = self.encrypt_password(credentials.passwd)
|
||||
# Sanitize user and password
|
||||
sanitized_user = self.sanitize(credentials.user)
|
||||
sanitized_encrypted_passwd = self.sanitize(credentials.encrypted_passwd)
|
||||
|
||||
# Sanitize user and password
|
||||
sanitized_user = self.sanitize(credentials.user)
|
||||
sanitized_encrypted_passwd = self.sanitize(credentials.encrypted_passwd)
|
||||
|
||||
# Securely convert encrypted password to SecureString
|
||||
self.execute(f'$user = "{sanitized_user}"')
|
||||
self.execute(
|
||||
f'$secureString = "{sanitized_encrypted_passwd}" | ConvertTo-SecureString'
|
||||
)
|
||||
self.execute(
|
||||
"$credential = New-Object System.Management.Automation.PSCredential ($user, $secureString)"
|
||||
)
|
||||
# Securely convert encrypted password to SecureString
|
||||
self.execute(f'$user = "{sanitized_user}"')
|
||||
self.execute(
|
||||
f'$secureString = "{sanitized_encrypted_passwd}" | ConvertTo-SecureString'
|
||||
)
|
||||
self.execute(
|
||||
"$credential = New-Object System.Management.Automation.PSCredential ($user, $secureString)"
|
||||
)
|
||||
else:
|
||||
# Application Auth
|
||||
self.execute(f'$clientID = "{credentials.client_id}"')
|
||||
self.execute(f'$clientSecret = "{credentials.client_secret}"')
|
||||
self.execute(f'$tenantID = "{credentials.tenant_id}"')
|
||||
self.execute(
|
||||
'$graphtokenBody = @{ Grant_Type = "client_credentials"; Scope = "https://graph.microsoft.com/.default"; Client_Id = $clientID; Client_Secret = $clientSecret }'
|
||||
)
|
||||
self.execute(
|
||||
'$graphToken = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token" -Method POST -Body $graphtokenBody | Select-Object -ExpandProperty Access_Token'
|
||||
)
|
||||
|
||||
def encrypt_password(self, password: str) -> str:
|
||||
"""
|
||||
@@ -129,46 +144,151 @@ class M365PowerShell(PowerShellSession):
|
||||
Returns:
|
||||
bool: True if credentials are valid and authentication succeeds, False otherwise.
|
||||
"""
|
||||
self.execute(
|
||||
f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' # encrypted password already sanitized
|
||||
)
|
||||
self.execute(
|
||||
f'$credential = New-Object System.Management.Automation.PSCredential("{self.sanitize(credentials.user)}", $securePassword)'
|
||||
)
|
||||
if credentials.user and credentials.passwd:
|
||||
self.execute(
|
||||
f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' # encrypted password already sanitized
|
||||
)
|
||||
self.execute(
|
||||
f'$credential = New-Object System.Management.Automation.PSCredential("{self.sanitize(credentials.user)}", $securePassword)'
|
||||
)
|
||||
|
||||
# Validate user belongs to tenant
|
||||
user_domain = credentials.user.split("@")[1]
|
||||
if not any(
|
||||
user_domain.endswith(domain)
|
||||
for domain in self.tenant_identity.tenant_domains
|
||||
):
|
||||
raise M365UserNotBelongingToTenantError(
|
||||
user_domain = credentials.user.split("@")[1]
|
||||
if not any(
|
||||
user_domain.endswith(domain)
|
||||
for domain in self.tenant_identity.tenant_domains
|
||||
):
|
||||
raise M365UserNotBelongingToTenantError(
|
||||
file=os.path.basename(__file__),
|
||||
message=f"The user domain {user_domain} does not match any of the tenant domains: {', '.join(self.tenant_identity.tenant_domains)}",
|
||||
)
|
||||
|
||||
app = msal.ConfidentialClientApplication(
|
||||
client_id=credentials.client_id,
|
||||
client_credential=credentials.client_secret,
|
||||
authority=f"https://login.microsoftonline.com/{credentials.tenant_id}",
|
||||
)
|
||||
|
||||
# Validate credentials
|
||||
result = app.acquire_token_by_username_password(
|
||||
username=credentials.user,
|
||||
password=credentials.passwd,
|
||||
scopes=["https://graph.microsoft.com/.default"],
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise Exception(
|
||||
"Unexpected error: Acquiring token in behalf of user did not return a result."
|
||||
)
|
||||
|
||||
if "access_token" not in result:
|
||||
raise Exception(f"MsGraph Error {result.get('error_description')}")
|
||||
|
||||
return True
|
||||
|
||||
else:
|
||||
# Test Microsoft Graph connection
|
||||
try:
|
||||
logger.info("Testing Microsoft Graph connection...")
|
||||
self.test_graph_connection()
|
||||
logger.info("Microsoft Graph connection successful")
|
||||
except Exception as e:
|
||||
logger.error(f"Microsoft Graph connection failed: {e}")
|
||||
raise M365GraphConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=e,
|
||||
message="Check your Microsoft Application credentials and ensure the app has proper permissions",
|
||||
)
|
||||
|
||||
# Test Microsoft Teams connection
|
||||
try:
|
||||
logger.info("Testing Microsoft Teams connection...")
|
||||
self.test_teams_connection()
|
||||
logger.info("Microsoft Teams connection successful")
|
||||
except Exception as e:
|
||||
logger.error(f"Microsoft Teams connection failed: {e}")
|
||||
raise M365TeamsConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=e,
|
||||
message="Ensure the application has proper permission granted to access Microsoft Teams.",
|
||||
)
|
||||
|
||||
# Test Exchange Online connection
|
||||
try:
|
||||
logger.info("Testing Exchange Online connection...")
|
||||
self.test_exchange_connection()
|
||||
logger.info("Exchange Online connection successful")
|
||||
except Exception as e:
|
||||
logger.error(f"Exchange Online connection failed: {e}")
|
||||
raise M365ExchangeConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=e,
|
||||
message="Ensure the application has proper permission granted to access Exchange Online.",
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def test_graph_connection(self) -> bool:
|
||||
"""Test Microsoft Graph API connection and raise exception if it fails."""
|
||||
try:
|
||||
if self.execute("Write-Output $graphToken") == "":
|
||||
raise M365GraphConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
message="Microsoft Graph token is empty or invalid.",
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Microsoft Graph connection failed: {e}")
|
||||
raise M365GraphConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
message=f"The user domain {user_domain} does not match any of the tenant domains: {', '.join(self.tenant_identity.tenant_domains)}",
|
||||
original_exception=e,
|
||||
message=f"Failed to connect to Microsoft Graph API: {str(e)}",
|
||||
)
|
||||
|
||||
app = msal.ConfidentialClientApplication(
|
||||
client_id=credentials.client_id,
|
||||
client_credential=credentials.client_secret,
|
||||
authority=f"https://login.microsoftonline.com/{credentials.tenant_id}",
|
||||
)
|
||||
|
||||
# Validate credentials
|
||||
result = app.acquire_token_by_username_password(
|
||||
username=credentials.user,
|
||||
password=credentials.passwd,
|
||||
scopes=["https://graph.microsoft.com/.default"],
|
||||
)
|
||||
|
||||
if result is None:
|
||||
raise Exception(
|
||||
"Unexpected error: Acquiring token in behalf of user did not return a result."
|
||||
def test_teams_connection(self) -> bool:
|
||||
"""Test Microsoft Teams API connection and raise exception if it fails."""
|
||||
try:
|
||||
self.execute(
|
||||
'$teamstokenBody = @{ Grant_Type = "client_credentials"; Scope = "48ac35b8-9aa8-4d74-927d-1f4a14a0b239/.default"; Client_Id = $clientID; Client_Secret = $clientSecret }'
|
||||
)
|
||||
self.execute(
|
||||
'$teamsToken = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token" -Method POST -Body $teamstokenBody | Select-Object -ExpandProperty Access_Token'
|
||||
)
|
||||
if self.execute("Write-Output $teamsToken") == "":
|
||||
raise M365TeamsConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
message="Microsoft Teams token is empty or invalid.",
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Microsoft Teams connection failed: {e}")
|
||||
raise M365TeamsConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=e,
|
||||
message=f"Failed to connect to Microsoft Teams API: {str(e)}",
|
||||
)
|
||||
|
||||
if "access_token" not in result:
|
||||
raise Exception(f"MsGraph Error {result.get('error_description')}")
|
||||
|
||||
return True
|
||||
def test_exchange_connection(self) -> bool:
|
||||
"""Test Exchange Online API connection and raise exception if it fails."""
|
||||
try:
|
||||
self.execute(
|
||||
'$SecureSecret = ConvertTo-SecureString "$clientSecret" -AsPlainText -Force'
|
||||
)
|
||||
self.execute(
|
||||
'$exchangeToken = Get-MsalToken -clientID "$clientID" -tenantID "$tenantID" -clientSecret $SecureSecret -Scopes "https://outlook.office365.com/.default"'
|
||||
)
|
||||
if self.execute("Write-Output $exchangeToken") == "":
|
||||
raise M365ExchangeConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
message="Exchange Online token is empty or invalid.",
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Exchange Online connection failed: {e}")
|
||||
raise M365ExchangeConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=e,
|
||||
message=f"Failed to connect to Exchange Online API: {str(e)}",
|
||||
)
|
||||
|
||||
def connect_microsoft_teams(self) -> dict:
|
||||
"""
|
||||
@@ -182,7 +302,18 @@ class M365PowerShell(PowerShellSession):
|
||||
Note:
|
||||
This method requires the Microsoft Teams PowerShell module to be installed.
|
||||
"""
|
||||
return self.execute("Connect-MicrosoftTeams -Credential $credential")
|
||||
if self.execute("Write-Output $credential") != "": # User Auth
|
||||
return self.execute("Connect-MicrosoftTeams -Credential $credential")
|
||||
else: # Application Auth
|
||||
self.execute(
|
||||
'$teamstokenBody = @{ Grant_Type = "client_credentials"; Scope = "48ac35b8-9aa8-4d74-927d-1f4a14a0b239/.default"; Client_Id = $clientID; Client_Secret = $clientSecret }'
|
||||
)
|
||||
self.execute(
|
||||
'$teamsToken = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token" -Method POST -Body $teamstokenBody | Select-Object -ExpandProperty Access_Token'
|
||||
)
|
||||
return self.execute(
|
||||
'Connect-MicrosoftTeams -AccessTokens @("$graphToken","$teamsToken")'
|
||||
)
|
||||
|
||||
def get_teams_settings(self) -> dict:
|
||||
"""
|
||||
@@ -276,7 +407,18 @@ class M365PowerShell(PowerShellSession):
|
||||
Note:
|
||||
This method requires the Exchange Online PowerShell module to be installed.
|
||||
"""
|
||||
return self.execute("Connect-ExchangeOnline -Credential $credential")
|
||||
if self.execute("Write-Output $credential") != "": # User Auth
|
||||
return self.execute("Connect-ExchangeOnline -Credential $credential")
|
||||
else: # Application Auth
|
||||
self.execute(
|
||||
'$SecureSecret = ConvertTo-SecureString "$clientSecret" -AsPlainText -Force'
|
||||
)
|
||||
self.execute(
|
||||
'$exchangeToken = Get-MsalToken -clientID "$clientID" -tenantID "$tenantID" -clientSecret $SecureSecret -Scopes "https://outlook.office365.com/.default"'
|
||||
)
|
||||
return self.execute(
|
||||
'Connect-ExchangeOnline -AccessToken $exchangeToken.AccessToken -Organization "$tenantID"'
|
||||
)
|
||||
|
||||
def get_audit_log_config(self) -> dict:
|
||||
"""
|
||||
@@ -758,25 +900,20 @@ def initialize_m365_powershell_modules():
|
||||
bool: True if all modules were successfully initialized, False otherwise
|
||||
"""
|
||||
|
||||
REQUIRED_MODULES = [
|
||||
"ExchangeOnlineManagement",
|
||||
"MicrosoftTeams",
|
||||
]
|
||||
REQUIRED_MODULES = ["ExchangeOnlineManagement", "MicrosoftTeams", "MSAL.PS"]
|
||||
|
||||
pwsh = PowerShellSession()
|
||||
try:
|
||||
for module in REQUIRED_MODULES:
|
||||
try:
|
||||
# Check if module is already installed
|
||||
result = pwsh.execute(
|
||||
f"Get-Module -ListAvailable -Name {module}", timeout=5
|
||||
)
|
||||
result = pwsh.execute(f"Get-Module -ListAvailable {module}", timeout=5)
|
||||
|
||||
# Install module if not installed
|
||||
if not result:
|
||||
install_result = pwsh.execute(
|
||||
f'Install-Module -Name "{module}" -Force -AllowClobber -Scope CurrentUser',
|
||||
timeout=30,
|
||||
f'Install-Module "{module}" -Force -AllowClobber -Scope CurrentUser',
|
||||
timeout=60,
|
||||
)
|
||||
if install_result:
|
||||
logger.warning(
|
||||
@@ -786,7 +923,7 @@ def initialize_m365_powershell_modules():
|
||||
logger.info(f"Successfully installed module {module}")
|
||||
|
||||
# Import module
|
||||
pwsh.execute(f'Import-Module -Name "{module}" -Force', timeout=1)
|
||||
pwsh.execute(f'Import-Module "{module}" -Force', timeout=1)
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Failed to initialize module {module}: {str(error)}")
|
||||
|
||||
@@ -43,9 +43,7 @@ from prowler.providers.m365.exceptions.exceptions import (
|
||||
M365NotTenantIdButClientIdAndClientSecretError,
|
||||
M365NotValidClientIdError,
|
||||
M365NotValidClientSecretError,
|
||||
M365NotValidPasswordError,
|
||||
M365NotValidTenantIdError,
|
||||
M365NotValidUserError,
|
||||
M365SetUpRegionConfigError,
|
||||
M365SetUpSessionError,
|
||||
M365TenantIdAndClientIdNotBelongingToClientSecretError,
|
||||
@@ -172,7 +170,7 @@ class M365Provider(Provider):
|
||||
|
||||
# Get the dict from the static credentials
|
||||
m365_credentials = None
|
||||
if tenant_id and client_id and client_secret and user and password:
|
||||
if tenant_id and client_id and client_secret:
|
||||
m365_credentials = self.validate_static_credentials(
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
@@ -204,6 +202,7 @@ class M365Provider(Provider):
|
||||
# Set up PowerShell session credentials
|
||||
self._credentials = self.setup_powershell(
|
||||
env_auth=env_auth,
|
||||
sp_env_auth=sp_env_auth,
|
||||
m365_credentials=m365_credentials,
|
||||
identity=self.identity,
|
||||
init_modules=init_modules,
|
||||
@@ -378,6 +377,7 @@ class M365Provider(Provider):
|
||||
@staticmethod
|
||||
def setup_powershell(
|
||||
env_auth: bool = False,
|
||||
sp_env_auth: bool = False,
|
||||
m365_credentials: dict = {},
|
||||
identity: M365IdentityInfo = None,
|
||||
init_modules: bool = False,
|
||||
@@ -392,12 +392,13 @@ class M365Provider(Provider):
|
||||
If env_auth is True, retrieves from environment variables.
|
||||
If False, returns empty credentials.
|
||||
"""
|
||||
logger.info("M365 provider: Setting up PowerShell session...")
|
||||
credentials = None
|
||||
|
||||
if m365_credentials:
|
||||
credentials = M365Credentials(
|
||||
user=m365_credentials.get("user", ""),
|
||||
passwd=m365_credentials.get("password", ""),
|
||||
user=m365_credentials.get("user", None),
|
||||
passwd=m365_credentials.get("password", None),
|
||||
client_id=m365_credentials.get("client_id", ""),
|
||||
client_secret=m365_credentials.get("client_secret", ""),
|
||||
tenant_id=m365_credentials.get("tenant_id", ""),
|
||||
@@ -418,6 +419,7 @@ class M365Provider(Provider):
|
||||
file=os.path.basename(__file__),
|
||||
message="Missing M365_USER or M365_PASSWORD environment variables required for credentials authentication.",
|
||||
)
|
||||
|
||||
credentials = M365Credentials(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
@@ -427,14 +429,25 @@ class M365Provider(Provider):
|
||||
passwd=m365_password,
|
||||
)
|
||||
|
||||
elif sp_env_auth:
|
||||
client_id = getenv("AZURE_CLIENT_ID")
|
||||
client_secret = getenv("AZURE_CLIENT_SECRET")
|
||||
tenant_id = getenv("AZURE_TENANT_ID")
|
||||
credentials = M365Credentials(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
tenant_id=tenant_id,
|
||||
tenant_domains=identity.tenant_domains,
|
||||
)
|
||||
|
||||
if credentials:
|
||||
if identity:
|
||||
if identity and credentials.user:
|
||||
identity.user = credentials.user
|
||||
test_session = M365PowerShell(credentials, identity)
|
||||
try:
|
||||
if init_modules:
|
||||
initialize_m365_powershell_modules()
|
||||
if test_session.test_credentials(credentials):
|
||||
if init_modules:
|
||||
initialize_m365_powershell_modules()
|
||||
return credentials
|
||||
raise M365UserCredentialsError(
|
||||
file=os.path.basename(__file__),
|
||||
@@ -523,6 +536,8 @@ class M365Provider(Provider):
|
||||
tenant_id=m365_credentials["tenant_id"],
|
||||
client_id=m365_credentials["client_id"],
|
||||
client_secret=m365_credentials["client_secret"],
|
||||
user=m365_credentials["user"],
|
||||
password=m365_credentials["password"],
|
||||
)
|
||||
return credentials
|
||||
except ClientAuthenticationError as error:
|
||||
@@ -688,8 +703,8 @@ class M365Provider(Provider):
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
user="user",
|
||||
password="password",
|
||||
user=None,
|
||||
password=None,
|
||||
)
|
||||
else:
|
||||
m365_credentials = M365Provider.validate_static_credentials(
|
||||
@@ -739,17 +754,13 @@ class M365Provider(Provider):
|
||||
logger.info("M365 provider: Identity retrieved successfully")
|
||||
|
||||
# Set up PowerShell credentials
|
||||
if user and password:
|
||||
M365Provider.setup_powershell(
|
||||
env_auth,
|
||||
m365_credentials,
|
||||
identity,
|
||||
)
|
||||
logger.info("M365 provider: Connection to PowerShell successful")
|
||||
else:
|
||||
logger.info(
|
||||
"M365 provider: Connection to PowerShell has not been requested"
|
||||
)
|
||||
M365Provider.setup_powershell(
|
||||
env_auth,
|
||||
sp_env_auth,
|
||||
m365_credentials,
|
||||
identity,
|
||||
)
|
||||
logger.info("M365 provider: Connection to PowerShell successful")
|
||||
|
||||
return Connection(is_connected=True)
|
||||
|
||||
@@ -1030,20 +1041,6 @@ class M365Provider(Provider):
|
||||
message="The provided Client Secret is not valid.",
|
||||
)
|
||||
|
||||
# Validate the User
|
||||
if not user:
|
||||
raise M365NotValidUserError(
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided User is not valid.",
|
||||
)
|
||||
|
||||
# Validate the Password
|
||||
if not password:
|
||||
raise M365NotValidPasswordError(
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided Password is not valid.",
|
||||
)
|
||||
|
||||
try:
|
||||
M365Provider.verify_client(tenant_id, client_id, client_secret)
|
||||
return {
|
||||
|
||||
@@ -24,9 +24,9 @@ class M365RegionConfig(BaseModel):
|
||||
|
||||
|
||||
class M365Credentials(BaseModel):
|
||||
user: str = ""
|
||||
passwd: str = ""
|
||||
encrypted_passwd: str = ""
|
||||
user: Optional[str] = None
|
||||
passwd: Optional[str] = None
|
||||
encrypted_passwd: Optional[str] = None
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
tenant_id: str = ""
|
||||
|
||||
@@ -260,7 +260,7 @@ class ExternalMailConfig(BaseModel):
|
||||
class TransportRule(BaseModel):
|
||||
name: str
|
||||
scl: Optional[int]
|
||||
sender_domain_is: list[str]
|
||||
sender_domain_is: Optional[list[str]]
|
||||
redirect_message_to: Optional[list[str]]
|
||||
|
||||
|
||||
|
||||