Compare commits

...
Author SHA1 Message Date
Rubén De la Torre Vico 332490dfc6 fix(dashboard): count rows in the CSV more efficiently (#8257)
(cherry picked from commit 20c7c9f8de)
2025-07-15 13:41:34 +00:00
Prowler BotandHugo Pereira Brito e7f5ad1bf2 fix(docs): GitHub provider mkdocs and -h (#8252)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2025-07-11 17:11:08 +08:00
Prowler Botandprowler-bot e3d7fe4436 chore(release): Bump version to v5.8.2 (#8239)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2025-07-10 19:14:55 +08:00
Prowler BotandVíctor Fernández Poyatos 3bd5f4f650 feat(exceptions): add custom error for provider connection during scans (#8237)
Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com>
2025-07-10 11:14:14 +02:00
Prowler BotandHugo Pereira Brito 30dd1df2ae fix: changelog entries with new specification (#8238)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2025-07-10 14:43:28 +05:45
d338123c62 fix(azure/storage): use BaseModel for all Storage models (#8227)
Co-authored-by: Rubén De la Torre Vico <ruben@prowler.com>
Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
2025-07-09 17:23:08 +08:00
Prowler BotandHugo Pereira Brito ff4a38a985 fix(firehose): list all streams and fix firehose_stream_encrypted_at_rest logic (#8226)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2025-07-09 16:31:17 +08:00
Prowler BotandPepe Fagoaga db23a1be5f fix(overview): use findings latest to get new (#8220)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2025-07-08 19:37:41 +05:45
0f62ec3e07 fix(api): make invitation email comparison case-insensitive (#8217)
Co-authored-by: Pablo Lara <larabjj@gmail.com>
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2025-07-08 15:01:37 +02:00
609137521b fix: Remove type validation while updating provider credentials (#8218)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com>
2025-07-08 18:08:40 +05:45
Prowler BotandVíctor Fernández Poyatos f141b8e87f feat(tasks): create overview queue for summaries and overviews (#8216)
Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com>
2025-07-08 14:11:04 +02:00
Prowler BotandVíctor Fernández Poyatos ad3aa2a2cc fix(findings): avoid backfill on empty scans (#8188)
Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com>
2025-07-07 16:57:14 +02:00
Prowler BotandPedro Martín 1e4ac36de9 fix(ec2): allow empty values for http_endpoint in templates (#8187)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2025-07-04 19:05:33 +08:00
2fa8d7c351 fix(iam): detect wildcarded ARNs in sts:AssumeRole policy resources (#8185)
Co-authored-by: Kay Agahd <kagahd@users.noreply.github.com>
Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
2025-07-04 16:48:28 +08:00
Prowler Botandprowler-bot 39306e47f2 chore(release): Bump version to v5.8.1 (#8179)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2025-07-03 23:10:21 +08:00
Pablo Lara 084b33c5dd fix: disable dynamic filters for now (#8177) 2025-07-03 14:24:21 +02:00
Alejandro Bailo 06ce25d6a2 fix: remove duplicated calls during promise all resolving (#8176) 2025-07-03 14:24:13 +02:00
Pablo Lara 061cad9bda fix: bug when updating credentials for m365 (#8173) 2025-07-03 11:35:12 +02:00
César Arroba 7d4541a1be chore(api): prowler version 2025-07-03 09:48:00 +02:00
Adrián Jesús Peña RodríguezandVíctor Fernández Poyatos d6f26df2e8 refactor(migrations): remove saml migrations (#8167)
Co-authored-by: Víctor Fernández Poyatos <victor@prowler.com>
2025-07-02 17:23:08 +02:00
Sergio Garcia ece74e15fd chore(sdk): update changelog (#8166) 2025-07-02 16:11:48 +02:00
sumit-tftandPablo Lara eea6d07259 chore(ui): update capitalization of Sign In and Sign Up to match UI s… (#8136)
Co-authored-by: Pablo Lara <larabjj@gmail.com>
2025-07-02 16:01:29 +02:00
Víctor Fernández Poyatos 4a6d7a5be2 chore: bump API changelog to v5.8.0 (#8165) 2025-07-02 16:00:43 +02:00
Alejandro Bailo 883c5d4e56 feat: client side validation (#8161) 2025-07-02 15:43:20 +02:00
Adrián Jesús Peña Rodríguez f1f998c2fa chore: update spec (#8162) 2025-07-02 13:19:57 +02:00
Adrián Jesús Peña Rodríguez 5276e38f1d chore: disable SAML endpoints (#8160) 2025-07-02 12:51:57 +02:00
Pablo Lara ad98a4747f chore: Hide all SAML config for v5.8 (#8159) 2025-07-02 12:46:04 +02:00
Alejandro Bailo 5798321dc6 feat: saml e2e improvements (#8158) 2025-07-02 11:57:56 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> bf58728d29 chore(deps-dev): bump brace-expansion from 1.1.11 to 1.1.12 in /ui (#8003)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 11:25:17 +02:00
Sergio GarciaandRubén De la Torre Vico fcea3b6570 docs(iac): add documentation for IaC (#8150)
Co-authored-by: Rubén De la Torre Vico <rubendltv22@gmail.com>
2025-07-02 17:20:34 +08:00
Neil MillardandMrCloudSec 965111245a feat(aws): add new check for Codebuild projects visibility (#8127)
Co-authored-by: MrCloudSec <hello@mistercloudsec.com>
2025-07-02 17:20:15 +08:00
Rubén De la Torre Vico f78a29206c fix(azure): use Pydantic models in VM service and fix managed disk logic (#8151) 2025-07-02 16:23:51 +08:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> c719d705e0 chore(deps): bump trufflesecurity/trufflehog from 3.88.35 to 3.89.2 (#8156)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 15:36:10 +08:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 8948ee6868 chore(deps): bump docker/setup-buildx-action from 3.10.0 to 3.11.1 (#8153)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 15:29:21 +08:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 24fb31e98f chore(deps): bump github/codeql-action from 3.28.18 to 3.29.2 (#8155)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 14:24:12 +08:00
Adrián Jesús Peña Rodríguez c8b193e658 fix(saml): add user to SAML tenant (#8152) 2025-07-01 18:41:16 +02:00
Alejandro Bailo 6d27738c4d fix: HotFIX related with ACS SAML url (#8148) 2025-07-01 13:10:46 +02:00
Adrián Jesús Peña Rodríguez 17b7becfdf fix(saml): limit attributes length to satisfy the socialapp restriction (#8145) 2025-07-01 12:03:20 +02:00
Alejandro Bailo cfa7f271d2 fix: Minor changes detected while SAML E2E (#8146) 2025-07-01 11:50:47 +02:00
Pedro Martín e61a97cb65 fix(api): handle ISO27001 - M365 in exports (#8143) 2025-07-01 10:19:56 +02:00
Pablo LaraandPepe Fagoaga cd4a1ad8a7 chore: clarify M365 context due to credential changes (#8144)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2025-07-01 09:01:17 +02:00
Alejandro Bailo e650d19a30 feat: enhance getScans API to support fields and include parameters; … (#8140) 2025-07-01 08:13:48 +02:00
Pedro Martín f930739a3d fix(ui): remove typo from compliance detailed view (#8142) 2025-06-30 18:03:45 +02:00
Sergio Garcia 89fc698a0e fix(m365): handle none attribute in exchange transport rule (#8141) 2025-06-30 23:13:18 +08:00
Pablo Lara 6acb6bbf8e docs: update changelog (#8139) 2025-06-30 16:34:03 +02:00
971424f822 fix: ACS dynamic url and password input visible in sign up (#8131)
Co-authored-by: Pablo Lara <larabjj@gmail.com>
Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
2025-06-30 16:17:34 +02:00
Adrián Jesús Peña Rodríguez 9ba1ae1ced restore: change api redirect (#8138) 2025-06-30 16:15:25 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 062db4cc70 chore(deps): bump protobuf from 6.30.2 to 6.31.1 in /api (#8053)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 21:05:10 +08:00
Pepe Fagoaga dc4db10c41 fix(version): only for master branch (#7850) 2025-06-30 16:50:32 +05:45
Rubén De la Torre Vico 68a542ef64 chore(CHANGELOG): put all checks entries in same format (#8134) 2025-06-30 16:50:12 +05:45
32f3787e18 feat(m365powershell): add pwsh authentication via service principal (#7992)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
Co-authored-by: MrCloudSec <hello@mistercloudsec.com>
2025-06-30 18:42:18 +08:00
Víctor Fernández Poyatos 6792bea319 fix(compliance): Avoid initializing Prowler provider (#8133) 2025-06-30 12:14:03 +02:00
Prowler Botandprowler-bot ae4b43c137 chore(regions_update): Changes in regions for AWS services (#8132)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2025-06-30 17:53:21 +08:00
Rubén De la Torre Vico d576c4f1c4 docs(developer-guide): add configurable checks documentation (#8122) 2025-06-30 16:47:27 +08:00
Pablo Lara ddc0596aa2 chore: tweaks for SAML config in profile page (#8130) 2025-06-30 09:40:02 +02:00
Rubén De la Torre Vico 636bdb6d0a docs(prowler-app): add new auth method for GCP (#8129) 2025-06-30 15:21:03 +08:00
Alejandro BailoandAdrián Jesús Peña Rodríguez 4a839b0146 feat: update SAML login URL handling and redirect logic (#8095)
Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com>
2025-06-27 14:44:04 +02:00
Pablo Lara 73e244dce5 docs: update changelog (#8125) 2025-06-27 13:51:56 +02:00
Adrián Jesús Peña Rodríguez d8ed70236b refactor(s3): adapt test_connection to match AwsProvider (#8088) 2025-06-27 13:23:59 +02:00
Sergio GarciaandPedro Martín bcc96ab4f2 fix(gcp): handle case sensitivity in block-project-ssh-keys (#8115)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2025-06-27 19:03:51 +08:00
fd53a8c9d0 feat: Playright setup (#8107)
Co-authored-by: Pablo Lara <larabjj@gmail.com>
Co-authored-by: César Arroba <19954079+cesararroba@users.noreply.github.com>
2025-06-27 11:47:21 +02:00
Daniel Barranquero 7b58d1dd56 fix: checks with no resource name (#8120) 2025-06-27 17:40:43 +08:00
Víctor Fernández Poyatos 7858c147f7 fix(spec): API specification (#8119) 2025-06-27 10:49:36 +02:00
Alejandro Bailo 8e635b3bd4 feat: saml sso ui integration (#8094) 2025-06-27 10:45:21 +02:00
Pedro Martín 2e97e37316 feat(dashboard): improve overview page (#8118) 2025-06-27 15:41:48 +08:00
Pedro Martín cd804836a1 docs(dev): add info about installing prowler for a branch (#8116) 2025-06-26 23:00:31 +08:00
Víctor Fernández Poyatos d102ee2fd5 chore: ignore Flask Safety alert in API (#8114) 2025-06-26 16:02:39 +02:00
Pedro Martín 325e5739a2 fix(compliance): handle latest assessment date for each account (#8108) 2025-06-26 17:48:35 +08:00
179 changed files with 9582 additions and 3563 deletions
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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}}"
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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 }}
@@ -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'
+2 -2
View File
@@ -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 }}
+2 -2
View File
@@ -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}}"
+51 -2
View File
@@ -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:
+2 -5
View File
@@ -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
+23 -2
View File
@@ -2,16 +2,37 @@
All notable changes to the **Prowler API** are documented in this file.
## [v1.9.0] (Prowler UNRELEASED)
## [v1.10.0] (Prowler UNRELEASED)
---
## [v1.9.1] (Prowler v5.8.1)
### Added
- Custom exception for provider connection errors during scans [(#8234)](https://github.com/prowler-cloud/prowler/pull/8234)
### Changed
- Summary and overview tasks now use a dedicated queue and no longer propagate errors to compliance tasks [(#8214)](https://github.com/prowler-cloud/prowler/pull/8214)
### Fixed
- Scan with no resources will not trigger legacy code for findings metadata [(#8183)](https://github.com/prowler-cloud/prowler/pull/8183)
- Invitation email comparison case-insensitive [(#8206)](https://github.com/prowler-cloud/prowler/pull/8206)
### Removed
- Validation of the provider's secret type during updates [(#8197)](https://github.com/prowler-cloud/prowler/pull/8197)
---
## [v1.9.0] (Prowler v5.8.0)
### 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 -1
View File
@@ -32,7 +32,7 @@ start_prod_server() {
start_worker() {
echo "Starting the worker..."
poetry run python -m celery -A config.celery worker -l "${DJANGO_LOGGING_LEVEL:-info}" -Q celery,scans,scan-reports,deletion,backfill -E --max-tasks-per-child 1
poetry run python -m celery -A config.celery worker -l "${DJANGO_LOGGING_LEVEL:-info}" -Q celery,scans,scan-reports,deletion,backfill,overview -E --max-tasks-per-child 1
}
start_worker_beat() {
+1376 -119
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -23,7 +23,7 @@ dependencies = [
"drf-spectacular==0.27.2",
"drf-spectacular-jsonapi==0.5.1",
"gunicorn==23.0.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.8",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (>=1.0.1,<2.0.0)",
"sentry-sdk[django] (>=2.20.0,<3.0.0)",
@@ -36,7 +36,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.9.0"
version = "1.9.1"
[project.scripts]
celery = "src.backend.config.settings.celery"
+90 -99
View File
@@ -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,100 +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] 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"
# 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()
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,
)
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
+5
View File
@@ -57,6 +57,11 @@ class TaskInProgressException(TaskManagementError):
super().__init__()
# Provider connection errors
class ProviderConnectionError(Exception):
"""Base exception for provider connection errors."""
def custom_exception_handler(exc, context):
if isinstance(exc, django_validation_error):
if hasattr(exc, "error_dict"):
@@ -11,7 +11,7 @@ import api.rls
class Migration(migrations.Migration):
dependencies = [
("api", "0030_samlconfigurations"),
("api", "0029_findings_check_index_parent"),
]
operations = [
@@ -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"),
]
),
),
]
@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0031_lighthouseconfiguration"),
("api", "0030_lighthouseconfiguration"),
("django_celery_beat", "0019_alter_periodictasks_options"),
]
+209 -187
View File
@@ -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,
@@ -942,6 +936,11 @@ class Invitation(RowLevelSecurityProtectedModel):
null=True,
)
def save(self, *args, **kwargs):
if self.email:
self.email = self.email.strip().lower()
super().save(*args, **kwargs)
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "invitations"
@@ -1370,219 +1369,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):
+543 -342
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.9.0
version: 1.9.1
description: |-
Prowler API specification.
@@ -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:
@@ -9329,7 +9661,6 @@ components:
* `static` - Key-value pairs
* `role` - Role assumption
* `service_account` - GCP Service Account Key
readOnly: true
secret:
oneOf:
- type: object
@@ -9710,52 +10041,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:
@@ -11087,7 +11372,6 @@ components:
* `static` - Key-value pairs
* `role` - Role assumption
* `service_account` - GCP Service Account Key
readOnly: true
secret:
oneOf:
- type: object
@@ -11869,97 +12153,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 +12842,10 @@ components:
type: string
enum:
- provider-groups
Type4bfEnum:
type: string
enum:
- lighthouse-configurations
Type6bbEnum:
type: string
enum:
@@ -12970,3 +13167,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.
+45 -47
View File
@@ -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,37 +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"]
saml_sociallogin.account.extra_data = {
"firstName": [],
"lastName": [],
"organization": [],
"userType": [],
}
# 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.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()
)
# 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()
# )
+121 -124
View File
@@ -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]
+26 -4
View File
@@ -254,7 +254,7 @@ class TestValidateInvitation:
assert result == invitation
mock_db.get.assert_called_once_with(
token="VALID_TOKEN", email="user@example.com"
token="VALID_TOKEN", email__iexact="user@example.com"
)
def test_invitation_not_found_raises_validation_error(self):
@@ -269,7 +269,7 @@ class TestValidateInvitation:
"invitation_token": "Invalid invitation code."
}
mock_db.get.assert_called_once_with(
token="INVALID_TOKEN", email="user@example.com"
token="INVALID_TOKEN", email__iexact="user@example.com"
)
def test_invitation_not_found_raises_not_found(self):
@@ -284,7 +284,7 @@ class TestValidateInvitation:
assert exc_info.value.detail == "Invitation is not valid."
mock_db.get.assert_called_once_with(
token="INVALID_TOKEN", email="user@example.com"
token="INVALID_TOKEN", email__iexact="user@example.com"
)
def test_invitation_expired(self, invitation):
@@ -332,5 +332,27 @@ class TestValidateInvitation:
"invitation_token": "Invalid invitation code."
}
mock_db.get.assert_called_once_with(
token="VALID_TOKEN", email="different@example.com"
token="VALID_TOKEN", email__iexact="different@example.com"
)
def test_valid_invitation_uppercase_email(self):
"""Test that validate_invitation works with case-insensitive email lookup."""
uppercase_email = "USER@example.com"
invitation = MagicMock(spec=Invitation)
invitation.token = "VALID_TOKEN"
invitation.email = uppercase_email
invitation.expires_at = datetime.now(timezone.utc) + timedelta(days=1)
invitation.state = Invitation.State.PENDING
invitation.tenant = MagicMock()
with patch("api.utils.Invitation.objects.using") as mock_using:
mock_db = mock_using.return_value
mock_db.get.return_value = invitation
result = validate_invitation("VALID_TOKEN", "user@example.com")
assert result == invitation
mock_db.get.assert_called_once_with(
token="VALID_TOKEN", email__iexact="user@example.com"
)
+498 -234
View File
@@ -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": {
@@ -1987,6 +2029,104 @@ class TestProviderSecretViewSet:
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_provider_secrets_partial_update_with_secret_type(
self, authenticated_client, provider_secret_fixture
):
provider_secret, *_ = provider_secret_fixture
data = {
"data": {
"type": "provider-secrets",
"id": str(provider_secret.id),
"attributes": {
"name": "new_name",
"secret": {
"service_account_key": {},
},
"secret_type": "service_account",
},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": str(provider_secret.provider.id),
}
}
},
}
}
response = authenticated_client.patch(
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_200_OK
provider_secret.refresh_from_db()
assert provider_secret.name == "new_name"
assert provider_secret.secret == {"service_account_key": {}}
def test_provider_secrets_partial_update_with_invalid_secret_type(
self, authenticated_client, provider_secret_fixture
):
provider_secret, *_ = provider_secret_fixture
data = {
"data": {
"type": "provider-secrets",
"id": str(provider_secret.id),
"attributes": {
"name": "new_name",
"secret": {
"service_account_key": {},
},
"secret_type": "static",
},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": str(provider_secret.provider.id),
}
}
},
}
}
response = authenticated_client.patch(
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_provider_secrets_partial_update_without_secret_type_but_different(
self, authenticated_client, provider_secret_fixture
):
provider_secret, *_ = provider_secret_fixture
data = {
"data": {
"type": "provider-secrets",
"id": str(provider_secret.id),
"attributes": {
"name": "new_name",
"secret": {
"service_account_key": {},
},
},
"relationships": {
"provider": {
"data": {
"type": "providers",
"id": str(provider_secret.provider.id),
}
}
},
}
}
response = authenticated_client.patch(
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
data=json.dumps(data),
content_type="application/vnd.api+json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@pytest.mark.django_db
class TestScanViewSet:
@@ -3329,6 +3469,61 @@ class TestFindingViewSet:
]
}
def test_findings_metadata_backfill(
self, authenticated_client, scans_fixture, findings_fixture
):
scan = scans_fixture[0]
scan.unique_resource_count = 1
scan.save()
with patch(
"api.v1.views.backfill_scan_resource_summaries_task.apply_async"
) as mock_backfill_task:
response = authenticated_client.get(
reverse("finding-metadata"),
{"filter[scan]": str(scan.id)},
)
assert response.status_code == status.HTTP_200_OK
mock_backfill_task.assert_called()
def test_findings_metadata_backfill_no_resources(
self, authenticated_client, scans_fixture
):
scan_id = str(scans_fixture[0].id)
with patch(
"api.v1.views.backfill_scan_resource_summaries_task.apply_async"
) as mock_backfill_task:
response = authenticated_client.get(
reverse("finding-metadata"),
{"filter[scan]": scan_id},
)
assert response.status_code == status.HTTP_200_OK
mock_backfill_task.assert_not_called()
def test_findings_metadata_latest_backfill(
self, authenticated_client, scans_fixture, findings_fixture
):
scan = scans_fixture[0]
scan.unique_resource_count = 1
scan.save()
with patch(
"api.v1.views.backfill_scan_resource_summaries_task.apply_async"
) as mock_backfill_task:
response = authenticated_client.get(reverse("finding-metadata_latest"))
assert response.status_code == status.HTTP_200_OK
mock_backfill_task.assert_called()
def test_findings_metadata_latest_backfill_no_resources(
self, authenticated_client, scans_fixture
):
with patch(
"api.v1.views.backfill_scan_resource_summaries_task.apply_async"
) as mock_backfill_task:
response = authenticated_client.get(reverse("finding-metadata_latest"))
assert response.status_code == status.HTTP_200_OK
mock_backfill_task.assert_not_called()
def test_findings_latest(self, authenticated_client, latest_scan_finding):
response = authenticated_client.get(
reverse("finding-latest"),
@@ -5561,265 +5756,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
+1 -1
View File
@@ -187,7 +187,7 @@ def validate_invitation(
# Admin DB connector is used to bypass RLS protection since the invitation belongs to a tenant the user
# is not a member of yet
invitation = Invitation.objects.using(MainRouter.admin_db).get(
token=invitation_token, email=email
token=invitation_token, email__iexact=email
)
except Invitation.DoesNotExist:
if raise_not_found:
+17 -17
View File
@@ -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"
@@ -1309,12 +1308,13 @@ class ProviderSecretUpdateSerializer(BaseWriteProviderSecretSerializer):
"inserted_at": {"read_only": True},
"updated_at": {"read_only": True},
"provider": {"read_only": True},
"secret_type": {"read_only": True},
"secret_type": {"required": False},
}
def validate(self, attrs):
provider = self.instance.provider
secret_type = self.instance.secret_type
# To allow updating a secret with the same type without making the `secret_type` mandatory
secret_type = attrs.get("secret_type") or self.instance.secret_type
secret = attrs.get("secret")
validated_attrs = super().validate(attrs)
@@ -2068,23 +2068,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):
+19 -15
View File
@@ -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)),
+233 -164
View File
@@ -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,
@@ -279,7 +271,7 @@ class SchemaView(SpectacularAPIView):
def get(self, request, *args, **kwargs):
spectacular_settings.TITLE = "Prowler API"
spectacular_settings.VERSION = "1.9.0"
spectacular_settings.VERSION = "1.9.1"
spectacular_settings.DESCRIPTION = (
"Prowler API specification.\n\nThis file is auto-generated."
)
@@ -401,171 +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] 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()
# # 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()
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,
)
# # 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(
@@ -2066,9 +2127,12 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
# ToRemove: Temporary fallback mechanism
if not queryset.exists():
scan_ids = Scan.objects.filter(
raw_scans_ids = Scan.objects.filter(
tenant_id=tenant_id, **scan_based_filters
).values_list("id", flat=True)
).values_list("id", "unique_resource_count")
scan_ids = [
scan_id for scan_id, count in raw_scans_ids if count and count > 0
]
for scan_id in scan_ids:
backfill_scan_resource_summaries_task.apply_async(
kwargs={"tenant_id": tenant_id, "scan_id": scan_id}
@@ -2154,7 +2218,12 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
.order_by("provider_id", "-inserted_at")
.distinct("provider_id")
)
latest_scans_ids = list(latest_scans_queryset.values_list("id", flat=True))
raw_latest_scans_ids = list(
latest_scans_queryset.values_list("id", "unique_resource_count")
)
latest_scans_ids = [
scan_id for scan_id, count in raw_latest_scans_ids if count and count > 0
]
queryset = ResourceScanSummary.objects.filter(
tenant_id=tenant_id,
@@ -3050,7 +3119,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
@extend_schema(tags=["Overview"])
@extend_schema_view(
list=extend_schema(
providers=extend_schema(
summary="Get aggregated provider data",
description=(
"Retrieve an aggregated overview of findings and resources grouped by providers. "
+6 -3
View File
@@ -4,6 +4,7 @@ from config.env import env
IGNORED_EXCEPTIONS = [
# Provider is not connected due to credentials errors
"is not connected",
"ProviderConnectionError",
# Authentication Errors from AWS
"InvalidToken",
"AccessDeniedException",
@@ -16,7 +17,7 @@ IGNORED_EXCEPTIONS = [
"InternalServerErrorException",
"AccessDenied",
"No Shodan API Key", # Shodan Check
"RequestLimitExceeded", # For now we don't want to log the RequestLimitExceeded errors
"RequestLimitExceeded", # For now, we don't want to log the RequestLimitExceeded errors
"ThrottlingException",
"Rate exceeded",
"SubscriptionRequiredException",
@@ -42,7 +43,9 @@ IGNORED_EXCEPTIONS = [
"AWSAccessKeyIDInvalidError",
"AWSSessionTokenExpiredError",
"EndpointConnectionError", # AWS Service is not available in a region
"Pool is closed", # The following comes from urllib3: eu-west-1 -- HTTPClientError[126]: An HTTP Client raised an unhandled exception: AWSHTTPSConnectionPool(host='hostname.s3.eu-west-1.amazonaws.com', port=443): Pool is closed.
# The following comes from urllib3: eu-west-1 -- HTTPClientError[126]: An HTTP Client raised an
# unhandled exception: AWSHTTPSConnectionPool(host='hostname.s3.eu-west-1.amazonaws.com', port=443): Pool is closed.
"Pool is closed",
# Authentication Errors from GCP
"ClientAuthenticationError",
"AuthorizationFailed",
@@ -71,7 +74,7 @@ IGNORED_EXCEPTIONS = [
def before_send(event, hint):
"""
before_send handles the Sentry events in order to sent them or not
before_send handles the Sentry events in order to send them or not
"""
# Ignore logs with the ignored_exceptions
# https://docs.python.org/3/library/logging.html#logrecord-objects
@@ -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,
},
},
}
+56 -53
View File
@@ -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:
+2
View File
@@ -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),
],
}
+6 -4
View File
@@ -14,6 +14,7 @@ from api.compliance import (
generate_scan_compliance,
)
from api.db_utils import create_objects_in_batches, rls_transaction
from api.exceptions import ProviderConnectionError
from api.models import (
ComplianceRequirementOverview,
Finding,
@@ -26,7 +27,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
@@ -139,7 +140,7 @@ def perform_prowler_scan(
provider_instance.connected = True
except Exception as e:
provider_instance.connected = False
exc = ValueError(
exc = ProviderConnectionError(
f"Provider {provider_instance.provider} is not connected: {e}"
)
finally:
@@ -149,7 +150,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 +528,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 = {}
+25 -19
View File
@@ -37,6 +37,26 @@ from prowler.lib.outputs.finding import Finding as FindingOutput
logger = get_task_logger(__name__)
def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str):
"""
Helper function to perform tasks after a scan is completed.
Args:
tenant_id (str): The tenant ID under which the scan was performed.
scan_id (str): The ID of the scan that was performed.
provider_id (str): The primary key of the Provider instance that was scanned.
"""
create_compliance_requirements_task.apply_async(
kwargs={"tenant_id": tenant_id, "scan_id": scan_id}
)
chain(
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
generate_outputs_task.si(
scan_id=scan_id, provider_id=provider_id, tenant_id=tenant_id
),
).apply_async()
@shared_task(base=RLSTask, name="provider-connection-check")
@set_tenant
def check_provider_connection_task(provider_id: str):
@@ -103,13 +123,7 @@ def perform_scan_task(
checks_to_execute=checks_to_execute,
)
chain(
perform_scan_summary_task.si(tenant_id, scan_id),
create_compliance_requirements_task.si(tenant_id=tenant_id, scan_id=scan_id),
generate_outputs.si(
scan_id=scan_id, provider_id=provider_id, tenant_id=tenant_id
),
).apply_async()
_perform_scan_complete_tasks(tenant_id, scan_id, provider_id)
return result
@@ -214,20 +228,12 @@ def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
scheduler_task_id=periodic_task_instance.id,
)
chain(
perform_scan_summary_task.si(tenant_id, scan_instance.id),
create_compliance_requirements_task.si(
tenant_id=tenant_id, scan_id=str(scan_instance.id)
),
generate_outputs.si(
scan_id=str(scan_instance.id), provider_id=provider_id, tenant_id=tenant_id
),
).apply_async()
_perform_scan_complete_tasks(tenant_id, str(scan_instance.id), provider_id)
return result
@shared_task(name="scan-summary")
@shared_task(name="scan-summary", queue="overview")
def perform_scan_summary_task(tenant_id: str, scan_id: str):
return aggregate_findings(tenant_id=tenant_id, scan_id=scan_id)
@@ -243,7 +249,7 @@ def delete_tenant_task(tenant_id: str):
queue="scan-reports",
)
@set_tenant(keep_tenant=True)
def generate_outputs(scan_id: str, provider_id: str, tenant_id: str):
def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
"""
Process findings in batches and generate output files in multiple formats.
@@ -381,7 +387,7 @@ def backfill_scan_resource_summaries_task(tenant_id: str, scan_id: str):
return backfill_resource_scan_summaries(tenant_id=tenant_id, scan_id=scan_id)
@shared_task(base=RLSTask, name="scan-compliance-overviews")
@shared_task(base=RLSTask, name="scan-compliance-overviews", queue="overview")
def create_compliance_requirements_task(tenant_id: str, scan_id: str):
"""
Creates detailed compliance requirement records for a scan.
+26 -51
View File
@@ -12,6 +12,7 @@ from tasks.jobs.scan import (
)
from tasks.utils import CustomEncoder
from api.exceptions import ProviderConnectionError
from api.models import (
ComplianceRequirementOverview,
Finding,
@@ -203,7 +204,7 @@ class TestPerformScan:
provider_id = str(provider.id)
checks_to_execute = ["check1", "check2"]
with pytest.raises(ValueError):
with pytest.raises(ProviderConnectionError):
perform_prowler_scan(tenant_id, scan_id, provider_id, checks_to_execute)
scan.refresh_from_db()
@@ -399,9 +400,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 +426,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 +509,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 +552,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 +600,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 +632,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 +665,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 +691,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 +728,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 +742,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 +757,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 +781,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 +800,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 +841,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 +875,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 +888,6 @@ class TestCreateComplianceRequirements:
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
providers_fixture[0]
mock_findings_filter.return_value = []
@@ -921,7 +897,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 +966,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 +985,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 +1053,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 +1066,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": {
+31 -8
View File
@@ -3,9 +3,10 @@ from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from tasks.tasks import generate_outputs
from tasks.tasks import _perform_scan_complete_tasks, generate_outputs_task
# TODO Move this to outputs/reports jobs
@pytest.mark.django_db
class TestGenerateOutputs:
def setup_method(self):
@@ -17,7 +18,7 @@ class TestGenerateOutputs:
with patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter:
mock_filter.return_value.exists.return_value = False
result = generate_outputs(
result = generate_outputs_task(
scan_id=self.scan_id,
provider_id=self.provider_id,
tenant_id=self.tenant_id,
@@ -99,7 +100,7 @@ class TestGenerateOutputs:
mock_compress.return_value = "/tmp/zipped.zip"
mock_upload.return_value = "s3://bucket/zipped.zip"
result = generate_outputs(
result = generate_outputs_task(
scan_id=self.scan_id,
provider_id=self.provider_id,
tenant_id=self.tenant_id,
@@ -150,7 +151,7 @@ class TestGenerateOutputs:
True,
]
result = generate_outputs(
result = generate_outputs_task(
scan_id="scan",
provider_id="provider",
tenant_id=self.tenant_id,
@@ -208,7 +209,7 @@ class TestGenerateOutputs:
{"aws": [(lambda x: True, MagicMock())]},
),
):
generate_outputs(
generate_outputs_task(
scan_id=self.scan_id,
provider_id=self.provider_id,
tenant_id=self.tenant_id,
@@ -276,7 +277,7 @@ class TestGenerateOutputs:
}
},
):
result = generate_outputs(
result = generate_outputs_task(
scan_id=self.scan_id,
provider_id=self.provider_id,
tenant_id=self.tenant_id,
@@ -346,7 +347,7 @@ class TestGenerateOutputs:
):
mock_summary.return_value.exists.return_value = True
result = generate_outputs(
result = generate_outputs_task(
scan_id=self.scan_id,
provider_id=self.provider_id,
tenant_id=self.tenant_id,
@@ -407,9 +408,31 @@ class TestGenerateOutputs:
),
):
with caplog.at_level("ERROR"):
generate_outputs(
generate_outputs_task(
scan_id=self.scan_id,
provider_id=self.provider_id,
tenant_id=self.tenant_id,
)
assert "Error deleting output files" in caplog.text
class TestScanCompleteTasks:
@patch("tasks.tasks.create_compliance_requirements_task.apply_async")
@patch("tasks.tasks.perform_scan_summary_task.si")
@patch("tasks.tasks.generate_outputs_task.si")
def test_scan_complete_tasks(
self, mock_outputs_task, mock_scan_summary_task, mock_compliance_tasks
):
_perform_scan_complete_tasks("tenant-id", "scan-id", "provider-id")
mock_compliance_tasks.assert_called_once_with(
kwargs={"tenant_id": "tenant-id", "scan_id": "scan-id"},
)
mock_scan_summary_task.assert_called_once_with(
scan_id="scan-id",
tenant_id="tenant-id",
)
mock_outputs_task.assert_called_once_with(
scan_id="scan-id",
provider_id="provider-id",
tenant_id="tenant-id",
)
+4 -1
View File
@@ -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.
+25
View File
@@ -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.
+5 -2
View File
@@ -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",
+13 -20
View File
@@ -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()
+81 -38
View File
@@ -1,5 +1,4 @@
# Standard library imports
import csv
import glob
import json
import os
@@ -20,7 +19,6 @@ from dash.dependencies import Input, Output
# Config import
from dashboard.config import (
critical_color,
encoding_format,
fail_color,
folder_path_overview,
high_color,
@@ -38,6 +36,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,
@@ -45,6 +44,7 @@ from dashboard.lib.dropdowns import (
create_table_row_dropdown,
)
from dashboard.lib.layouts import create_layout_overview
from prowler.lib.logger import logger
# Suppress warnings
warnings.filterwarnings("ignore")
@@ -54,11 +54,13 @@ warnings.filterwarnings("ignore")
csv_files = []
for file in glob.glob(os.path.join(folder_path_overview, "*.csv")):
with open(file, "r", newline="", encoding=encoding_format) as csvfile:
reader = csv.reader(csvfile)
num_rows = sum(1 for row in reader)
try:
df = pd.read_csv(file, sep=";")
num_rows = len(df)
if num_rows > 1:
csv_files.append(file)
except Exception:
logger.error(f"Error reading file {file}")
# Import logos providers
@@ -298,6 +300,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 +488,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 +519,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 +539,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 +563,7 @@ def filter_data(
n_clicks_xlsx,
severity_values,
service_values,
provider_values,
table_row_values,
status_values,
search_value,
@@ -874,6 +889,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 +1128,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 +1373,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 +1486,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,
+2 -27
View File
@@ -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.
+10
View File
@@ -164,3 +164,13 @@ If you enjoy swag, wed 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.
+61 -4
View File
@@ -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
```
+25 -3
View File
@@ -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).
+1 -1
View File
@@ -37,7 +37,7 @@ prowler github --github-app-id app_id --github-app-key app_key
If no login method is explicitly provided, Prowler will automatically attempt to authenticate using environment variables in the following order of precedence:
1. `GITHUB_PERSONAL_ACCESS_TOKEN`
2. `OAUTH_APP_TOKEN`
2. `GITHUB_OAUTH_APP_TOKEN`
3. `GITHUB_APP_ID` and `GITHUB_APP_KEY`
???+ note
+67
View File
@@ -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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

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:
![Application Permissions](./img/app-permissions.png)
---
4. Click `+ Add a permission` > `Microsoft Graph` > `Delegated permissions`
![Add API Permission](./img/add-delegated-api-permission.png)
### 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**.
![Office 365 Exchange Online API](./img/search-exchange-api.png)
- Select `Exchange.ManageAsApp` permission and click on `Add permissions`.
![Exchange.ManageAsApp Permission](./img/exchange-permission.png)
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:
![Roles and administrators](./img/here.png)
Once in the directory level assignment, search for `Exchange Administrator` and click on it to open the assginments page of that role.
![Exchange Administrator Role](./img/exchange-administrator-role.png)
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.
![Assign Exchange Administrator Role](./img/assign-exchange-administrator-role.png)
2. Add Teams API:
- Search and select `Skype and Teams Tenant Admin API` API in **APIs my organization uses**.
![Skype and Teams Tenant Admin API](./img/search-skype-teams-tenant-admin-api.png)
- Select `application_access` permission and click on `Add permissions`.
![application_access Permission](./img/teams-permission.png)
3. Click on `Grant admin consent for <your-tenant-name>` to grant admin consent.
![Grant Admin Consent](./img/grant-external-api-permissions.png)
The final result of permission assignment should be this:
![Final Permission Assignment](./img/final-permissions.png)
???+ warning
Remember that if the user is newly created, you need to sign in with that account first, as Microsoft will prompt you to change the password. If you dont 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`
![Permission Screenshots](./img/directory-permission-delegated.png)
6. After adding all the permissions, click on `Grant admin consent`
2. Click `Add permissions`, then **grant admin consent**
![Grant Admin Consent](./img/grant-admin-consent.png)
@@ -147,37 +212,32 @@ Follow these steps to assign the permissions:
![Final Permission Assignment](./img/final-permissions-m365.png)
---
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
![User Overview](./img/user-info-page.png)
![User Overview](./img/user-info-page.png)
2. Click `Assigned Roles`
2. Click `Assigned Roles`
![User Roles](./img/user-role-page.png)
![User Roles](./img/user-role-page.png)
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
![Global Reader Screenshots](./img/global-reader.png)
![Global Reader Screenshots](./img/global-reader.png)
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
![Grant Admin Consent for Role](./img/grant-admin-consent-for-role.png)
???+ warning
Remember that if the user is newly created, you need to sign in with that account first, as Microsoft will prompt you to change the password. If you dont complete this step, user authentication will fail because Microsoft marks the initial password as expired.
![Grant Admin Consent for Role](./img/grant-admin-consent-for-role.png)
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+37 -7
View File
@@ -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 youre 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 IdPs 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, its 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.
+20 -1
View File
@@ -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. Heres 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
+5
View File
@@ -106,6 +106,10 @@ nav:
- Getting Started: tutorials/microsoft365/getting-started-m365.md
- Authentication: tutorials/microsoft365/authentication.md
- Use of PowerShell: tutorials/microsoft365/use-of-powershell.md
- GitHub:
- Authentication: tutorials/github/authentication.md
- IaC:
- Getting Started: tutorials/iac/getting-started-iac.md
- Developer Guide:
- General Concepts:
- Introduction: developer-guide/introduction.md
@@ -128,6 +132,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
Generated
+896 -868
View File
File diff suppressed because it is too large Load Diff
+55 -22
View File
@@ -2,18 +2,47 @@
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
- Add GitHub provider to lateral panel in documentation and change -h environment variable output [(#8246)](https://github.com/prowler-cloud/prowler/pull/8246)
---
## [v5.8.2] (Prowler UNRELEASED)
### Fixed
- Fix error in Dashboard Overview page when reading CSV files [(#8257)](https://github.com/prowler-cloud/prowler/pull/8257)
---
## [v5.8.1] (Prowler 5.8.1)
### Fixed
- Detect wildcarded ARNs in sts:AssumeRole policy resources [(#8164)](https://github.com/prowler-cloud/prowler/pull/8164)
- List all streams and `firehose_stream_encrypted_at_rest` logic [(#8213)](https://github.com/prowler-cloud/prowler/pull/8213)
- Allow empty values for http_endpoint in templates [(#8184)](https://github.com/prowler-cloud/prowler/pull/8184)
- Convert all Azure Storage models to Pydantic models to avoid serialization issues [(#8222)](https://github.com/prowler-cloud/prowler/pull/8222)
---
## [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,31 +60,35 @@ 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)
- `keyvault_ensure_public_network_access_disabled` check for Azure provider. [(#8072)](https://github.com/prowler-cloud/prowler/pull/8072)
- New check `monitor_alert_service_health_exists` for Azure provider [(#8067)](https://github.com/prowler-cloud/prowler/pull/8067)
- `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)
### Removed
- OCSF version number references to point always to the latest [(#8064)](https://github.com/prowler-cloud/prowler/pull/8064)
---
## [v5.7.6] (Prowler UNRELEASED)
### Fixed
- 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)
---
@@ -63,7 +96,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 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)
+1 -1
View File
@@ -12,7 +12,7 @@ from prowler.lib.logger import logger
timestamp = datetime.today()
timestamp_utc = datetime.now(timezone.utc).replace(tzinfo=timezone.utc)
prowler_version = "5.8.0"
prowler_version = "5.8.2"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://prowler.com/wp-content/uploads/logo-html.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
+5 -1
View File
@@ -522,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
@@ -951,6 +951,7 @@
"aws": [
"af-south-1",
"ap-east-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
@@ -1336,7 +1337,9 @@
"aws": [
"us-east-1"
],
"aws-cn": [],
"aws-cn": [
"cn-northwest-1"
],
"aws-us-gov": []
}
},
@@ -2785,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",
@@ -3461,6 +3467,7 @@
"aws": [
"af-south-1",
"ap-east-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
@@ -3506,6 +3513,7 @@
"regions": {
"aws": [
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
"eu-west-1",
"eu-west-2",
@@ -4987,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",
@@ -9901,7 +9911,10 @@
"cn-north-1",
"cn-northwest-1"
],
"aws-us-gov": []
"aws-us-gov": [
"us-gov-east-1",
"us-gov-west-1"
]
}
},
"schemas": {
@@ -10319,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",
@@ -10352,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",
+198 -7
View File
@@ -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):
@@ -18,7 +18,10 @@ class ec2_launch_template_imdsv2_required(Check):
and version.template_data.http_tokens == "required"
):
versions_with_imdsv2_required.append(str(version.version_number))
elif version.template_data.http_endpoint == "disabled":
elif (
version.template_data.http_endpoint == "disabled"
or not version.template_data.http_endpoint
):
versions_with_metadata_disabled.append(str(version.version_number))
else:
versions_with_no_imdsv2.append(str(version.version_number))
@@ -25,18 +25,47 @@ class Firehose(AWSService):
def _list_delivery_streams(self, regional_client):
logger.info("Firehose - Listing delivery streams...")
try:
for stream_name in regional_client.list_delivery_streams()[
"DeliveryStreamNames"
]:
stream_arn = f"arn:{self.audited_partition}:firehose:{regional_client.region}:{self.audited_account}:deliverystream/{stream_name}"
if not self.audit_resources or (
is_resource_filtered(stream_arn, self.audit_resources)
):
self.delivery_streams[stream_arn] = DeliveryStream(
arn=stream_arn,
name=stream_name,
region=regional_client.region,
# Manual pagination using ExclusiveStartDeliveryStreamName
# This ensures we get all streams alphabetically without duplicates
exclusive_start_delivery_stream_name = None
processed_streams = set()
while True:
kwargs = {}
if exclusive_start_delivery_stream_name:
kwargs["ExclusiveStartDeliveryStreamName"] = (
exclusive_start_delivery_stream_name
)
response = regional_client.list_delivery_streams(**kwargs)
stream_names = response.get("DeliveryStreamNames", [])
for stream_name in stream_names:
if stream_name in processed_streams:
continue
processed_streams.add(stream_name)
stream_arn = f"arn:{self.audited_partition}:firehose:{regional_client.region}:{self.audited_account}:deliverystream/{stream_name}"
if not self.audit_resources or (
is_resource_filtered(stream_arn, self.audit_resources)
):
self.delivery_streams[stream_arn] = DeliveryStream(
arn=stream_arn,
name=stream_name,
region=regional_client.region,
)
if not response.get("HasMoreDeliveryStreams", False):
break
# Set the starting point for the next page (last stream name from current batch)
# ExclusiveStartDeliveryStreamName will start after this stream alphabetically
if stream_names:
exclusive_start_delivery_stream_name = stream_names[-1]
else:
break
except ClientError as error:
logger.error(
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -61,13 +90,45 @@ class Firehose(AWSService):
describe_stream = self.regional_clients[
stream.region
].describe_delivery_stream(DeliveryStreamName=stream.name)
encryption_config = describe_stream.get(
"DeliveryStreamDescription", {}
).get("DeliveryStreamEncryptionConfiguration", {})
stream.kms_encryption = EncryptionStatus(
encryption_config.get("Status", "DISABLED")
)
stream.kms_key_arn = encryption_config.get("KeyARN", "")
stream.delivery_stream_type = describe_stream.get(
"DeliveryStreamDescription", {}
).get("DeliveryStreamType", "")
source_config = describe_stream.get("DeliveryStreamDescription", {}).get(
"Source", {}
)
stream.source = Source(
direct_put=DirectPutSourceDescription(
troughput_hint_in_mb_per_sec=source_config.get(
"DirectPutSourceDescription", {}
).get("TroughputHintInMBPerSec", 0)
),
kinesis_stream=KinesisStreamSourceDescription(
kinesis_stream_arn=source_config.get(
"KinesisStreamSourceDescription", {}
).get("KinesisStreamARN", "")
),
msk=MSKSourceDescription(
msk_cluster_arn=source_config.get("MSKSourceDescription", {}).get(
"MSKClusterARN", ""
)
),
database=DatabaseSourceDescription(
endpoint=source_config.get("DatabaseSourceDescription", {}).get(
"Endpoint", ""
)
),
)
except ClientError as error:
logger.error(
f"{stream.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
@@ -85,6 +146,39 @@ class EncryptionStatus(Enum):
DISABLING_FAILED = "DISABLING_FAILED"
class DirectPutSourceDescription(BaseModel):
"""Model for the DirectPut source of a Firehose stream"""
troughput_hint_in_mb_per_sec: int = Field(default_factory=int)
class KinesisStreamSourceDescription(BaseModel):
"""Model for the KinesisStream source of a Firehose stream"""
kinesis_stream_arn: str = Field(default_factory=str)
class MSKSourceDescription(BaseModel):
"""Model for the MSK source of a Firehose stream"""
msk_cluster_arn: str = Field(default_factory=str)
class DatabaseSourceDescription(BaseModel):
"""Model for the Database source of a Firehose stream"""
endpoint: str = Field(default_factory=str)
class Source(BaseModel):
"""Model for the source of a Firehose stream"""
direct_put: Optional[DirectPutSourceDescription]
kinesis_stream: Optional[KinesisStreamSourceDescription]
msk: Optional[MSKSourceDescription]
database: Optional[DatabaseSourceDescription]
class DeliveryStream(BaseModel):
"""Model for a Firehose Delivery Stream"""
@@ -94,3 +188,5 @@ class DeliveryStream(BaseModel):
kms_key_arn: Optional[str] = Field(default_factory=str)
kms_encryption: Optional[str] = Field(default_factory=str)
tags: Optional[List[Dict[str, str]]] = Field(default_factory=list)
delivery_stream_type: Optional[str] = Field(default_factory=str)
source: Source = Field(default_factory=Source)
@@ -3,6 +3,8 @@ from typing import List
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.firehose.firehose_client import firehose_client
from prowler.providers.aws.services.firehose.firehose_service import EncryptionStatus
from prowler.providers.aws.services.kinesis.kinesis_client import kinesis_client
from prowler.providers.aws.services.kinesis.kinesis_service import EncryptionType
class firehose_stream_encrypted_at_rest(Check):
@@ -22,14 +24,22 @@ class firehose_stream_encrypted_at_rest(Check):
findings = []
for stream in firehose_client.delivery_streams.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=stream)
report.status = "PASS"
report.status_extended = (
f"Firehose Stream {stream.name} does have at rest encryption enabled."
)
report.status = "FAIL"
report.status_extended = f"Firehose Stream {stream.name} does not have at rest encryption enabled or the source stream is not encrypted."
if stream.kms_encryption != EncryptionStatus.ENABLED:
report.status = "FAIL"
report.status_extended = f"Firehose Stream {stream.name} does not have at rest encryption enabled."
# Encrypted Kinesis Stream source
if stream.delivery_stream_type == "KinesisStreamAsSource":
source_stream = kinesis_client.streams.get(
stream.source.kinesis_stream.kinesis_stream_arn
)
if source_stream.encrypted_at_rest != EncryptionType.NONE:
report.status = "PASS"
report.status_extended = f"Firehose Stream {stream.name} does not have at rest encryption enabled but the source stream {source_stream.name} has at rest encryption enabled."
# Check if the stream has encryption enabled directly
elif stream.kms_encryption == EncryptionStatus.ENABLED:
report.status = "PASS"
report.status_extended = f"Firehose Stream {stream.name} does have at rest encryption enabled."
findings.append(report)
@@ -5,6 +5,14 @@ from prowler.providers.aws.services.iam.iam_client import iam_client
class iam_no_custom_policy_permissive_role_assumption(Check):
def execute(self) -> Check_Report_AWS:
findings = []
def resource_has_wildcard(resource):
if isinstance(resource, str):
return "*" in resource
if isinstance(resource, list):
return any("*" in r for r in resource)
return False
for policy in iam_client.policies:
# Check only custom policies
if policy.type == "Custom":
@@ -12,6 +20,7 @@ class iam_no_custom_policy_permissive_role_assumption(Check):
report.region = iam_client.region
report.status = "PASS"
report.status_extended = f"Custom Policy {policy.name} does not allow permissive STS Role assumption."
if policy.document:
if not isinstance(policy.document["Statement"], list):
policy_statements = [policy.document["Statement"]]
@@ -19,30 +28,23 @@ class iam_no_custom_policy_permissive_role_assumption(Check):
policy_statements = policy.document["Statement"]
for statement in policy_statements:
if (
statement["Effect"] == "Allow"
statement.get("Effect") == "Allow"
and "Action" in statement
and "Resource" in statement
and "*" in statement["Resource"]
and resource_has_wildcard(statement["Resource"])
):
if isinstance(statement["Action"], list):
for action in statement["Action"]:
if (
action == "sts:AssumeRole"
or action == "sts:*"
or action == "*"
):
report.status = "FAIL"
report.status_extended = f"Custom Policy {policy.name} allows permissive STS Role assumption."
break
else:
if (
statement["Action"] == "sts:AssumeRole"
or statement["Action"] == "sts:*"
or statement["Action"] == "*"
):
actions = (
statement["Action"]
if isinstance(statement["Action"], list)
else [statement["Action"]]
)
for action in actions:
if action in ["sts:AssumeRole", "sts:*", "*"]:
report.status = "FAIL"
report.status_extended = f"Custom Policy {policy.name} allows permissive STS Role assumption."
break
break
if report.status == "FAIL":
break
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,
@@ -1,6 +1,5 @@
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional
from typing import Optional
from azure.mgmt.storage import StorageManagementClient
from pydantic import BaseModel
@@ -33,7 +32,7 @@ class Storage(AzureService):
resouce_group_name = None
key_expiration_period_in_days = None
if storage_account.key_policy:
key_expiration_period_in_days = (
key_expiration_period_in_days = int(
storage_account.key_policy.key_expiration_period_in_days
)
replication_settings = ReplicationSettings(storage_account.sku.name)
@@ -181,30 +180,26 @@ class Storage(AzureService):
)
@dataclass
class DeleteRetentionPolicy:
class DeleteRetentionPolicy(BaseModel):
enabled: bool
days: int
@dataclass
class BlobProperties:
class BlobProperties(BaseModel):
id: str
name: str
type: str
default_service_version: str
container_delete_retention_policy: DeleteRetentionPolicy
versioning_enabled: bool = False
default_service_version: Optional[str] = None
versioning_enabled: Optional[bool] = None
@dataclass
class NetworkRuleSet:
class NetworkRuleSet(BaseModel):
bypass: str
default_action: str
@dataclass
class PrivateEndpointConnection:
class PrivateEndpointConnection(BaseModel):
id: str
name: str
type: str
@@ -228,20 +223,19 @@ class FileServiceProperties(BaseModel):
share_delete_retention_policy: DeleteRetentionPolicy
@dataclass
class Account:
class Account(BaseModel):
id: str
name: str
location: str
resouce_group_name: str
enable_https_traffic_only: bool
infrastructure_encryption: bool
infrastructure_encryption: Optional[bool] = None
allow_blob_public_access: bool
network_rule_set: NetworkRuleSet
encryption_type: str
minimum_tls_version: str
private_endpoint_connections: List[PrivateEndpointConnection]
key_expiration_period_in_days: str
location: str
private_endpoint_connections: list[PrivateEndpointConnection]
key_expiration_period_in_days: Optional[int] = None
replication_settings: ReplicationSettings = ReplicationSettings.STANDARD_LRS
allow_cross_tenant_replication: bool = True
allow_shared_key_access: bool = True
@@ -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]
@@ -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)."
@@ -10,6 +10,7 @@ def init_parser(self):
nargs="?",
help="Personal Access Token to log in against GitHub",
default=None,
metavar="GITHUB_PERSONAL_ACCESS_TOKEN",
)
github_auth_subparser.add_argument(
@@ -17,6 +18,7 @@ def init_parser(self):
nargs="?",
help="OAuth App Token to log in against GitHub",
default=None,
metavar="GITHUB_OAUTH_APP_TOKEN",
)
# GitHub App Authentication
@@ -25,10 +27,12 @@ def init_parser(self):
nargs="?",
help="GitHub App ID to log in against GitHub",
default=None,
metavar="GITHUB_APP_ID",
)
github_auth_subparser.add_argument(
"--github-app-key",
nargs="?",
help="GitHub App Key Path to log in against GitHub",
default=None,
metavar="GITHUB_APP_KEY",
)
@@ -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)}")
+32 -35
View File
@@ -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 {
+3 -3
View File
@@ -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]]
+1 -1
View File
@@ -68,7 +68,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
name = "prowler"
readme = "README.md"
requires-python = ">3.9.1,<3.13"
version = "5.8.0"
version = "5.8.2"
[project.scripts]
prowler = "prowler.__main__:prowler"
+44 -16
View File
@@ -11,6 +11,7 @@ from prowler.lib.outputs.html.html import HTML
from prowler.lib.outputs.ocsf.ocsf import OCSF
from prowler.providers.aws.lib.s3.exceptions.exceptions import S3InvalidBucketNameError
from prowler.providers.aws.lib.s3.s3 import S3
from prowler.providers.common.models import Connection
from tests.lib.outputs.compliance.fixtures import ISO27001_2013_AWS
from tests.lib.outputs.fixtures.fixtures import generate_finding_output
from tests.providers.aws.utils import AWS_REGION_US_EAST_1
@@ -318,32 +319,59 @@ class TestS3:
@mock_aws
def test_test_connection_S3(self):
current_session = boto3.session.Session(region_name=AWS_REGION_US_EAST_1)
# Create a mock IAM user
iam_client = boto3.client("iam", region_name=AWS_REGION_US_EAST_1)
iam_user = iam_client.create_user(UserName="test-user")["User"]
# Create a mock IAM access keys
access_key = iam_client.create_access_key(UserName=iam_user["UserName"])[
"AccessKey"
]
# Create bucket
current_session = boto3.session.Session(
aws_access_key_id=access_key["AccessKeyId"],
aws_secret_access_key=access_key["SecretAccessKey"],
region_name=AWS_REGION_US_EAST_1,
)
s3_client = current_session.client("s3")
s3_client.create_bucket(Bucket=S3_BUCKET_NAME)
s3 = S3.test_connection(
session=current_session,
connection = S3.test_connection(
aws_region=AWS_REGION_US_EAST_1,
bucket_name=S3_BUCKET_NAME,
aws_access_key_id=access_key["AccessKeyId"],
aws_secret_access_key=access_key["SecretAccessKey"],
)
assert s3 is not None
assert s3.is_connected is True
assert s3.error is None
assert isinstance(connection, Connection)
assert connection.is_connected is True
assert connection.error is None
@mock_aws
def test_test_connection_S3_bucket_invalid_name(self):
current_session = boto3.session.Session(region_name=AWS_REGION_US_EAST_1)
# Create a mock IAM user
iam_client = boto3.client("iam", region_name=AWS_REGION_US_EAST_1)
iam_user = iam_client.create_user(UserName="test-user")["User"]
# Create a mock IAM access keys
access_key = iam_client.create_access_key(UserName=iam_user["UserName"])[
"AccessKey"
]
# Create bucket (with valid name)
current_session = boto3.session.Session(
aws_access_key_id=access_key["AccessKeyId"],
aws_secret_access_key=access_key["SecretAccessKey"],
region_name=AWS_REGION_US_EAST_1,
)
s3_client = current_session.client("s3")
s3_client.create_bucket(Bucket=S3_BUCKET_NAME)
with pytest.raises(S3InvalidBucketNameError):
s3 = S3.test_connection(
session=current_session,
bucket_name="invalid_bucket",
)
assert s3 is not None
assert s3.is_connected is False
assert s3.error is not None
with pytest.raises(S3InvalidBucketNameError):
S3.test_connection(
aws_region=AWS_REGION_US_EAST_1,
bucket_name="invalid_bucket",
aws_access_key_id=access_key["AccessKeyId"],
aws_secret_access_key=access_key["SecretAccessKey"],
)
@mock_aws
def test_init_without_session(self):
@@ -0,0 +1,176 @@
from unittest import mock
from prowler.providers.aws.services.codebuild.codebuild_service import Project
AWS_REGION = "eu-west-1"
AWS_ACCOUNT_NUMBER = "123456789012"
class Test_codebuild_project_not_publicly_accessible:
def test_project_public(self):
codebuild_client = mock.MagicMock
project_name = "test-project"
project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}"
codebuild_client.projects = {
project_arn: Project(
name=project_name,
arn=project_arn,
region="eu-west-1",
project_visibility="PUBLIC",
tags=[],
)
}
with (
mock.patch(
"prowler.providers.aws.services.codebuild.codebuild_service.Codebuild",
codebuild_client,
),
mock.patch(
"prowler.providers.aws.services.codebuild.codebuild_project_not_publicly_accessible.codebuild_project_not_publicly_accessible.codebuild_client",
codebuild_client,
),
):
from prowler.providers.aws.services.codebuild.codebuild_project_not_publicly_accessible.codebuild_project_not_publicly_accessible import (
codebuild_project_not_publicly_accessible,
)
check = codebuild_project_not_publicly_accessible()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"CodeBuild project {project_name} is public."
)
assert result[0].resource_id == project_name
assert result[0].resource_arn == project_arn
assert result[0].resource_tags == []
assert result[0].region == AWS_REGION
def test_project_private(self):
codebuild_client = mock.MagicMock
project_name = "test-project"
project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}"
codebuild_client.projects = {
project_arn: Project(
name=project_name,
arn=project_arn,
region="eu-west-1",
project_visibility="PRIVATE",
tags=[],
)
}
with (
mock.patch(
"prowler.providers.aws.services.codebuild.codebuild_service.Codebuild",
codebuild_client,
),
mock.patch(
"prowler.providers.aws.services.codebuild.codebuild_project_not_publicly_accessible.codebuild_project_not_publicly_accessible.codebuild_client",
codebuild_client,
),
):
from prowler.providers.aws.services.codebuild.codebuild_project_not_publicly_accessible.codebuild_project_not_publicly_accessible import (
codebuild_project_not_publicly_accessible,
)
check = codebuild_project_not_publicly_accessible()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"CodeBuild project {project_name} is private."
)
assert result[0].resource_id == project_name
assert result[0].resource_arn == project_arn
assert result[0].resource_tags == []
assert result[0].region == AWS_REGION
def test_project_no_visibility_set(self):
codebuild_client = mock.MagicMock
project_name = "test-project"
project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}"
codebuild_client.projects = {
project_arn: Project(
name=project_name,
arn=project_arn,
region="eu-west-1",
project_visibility=None,
tags=[],
)
}
with (
mock.patch(
"prowler.providers.aws.services.codebuild.codebuild_service.Codebuild",
codebuild_client,
),
mock.patch(
"prowler.providers.aws.services.codebuild.codebuild_project_not_publicly_accessible.codebuild_project_not_publicly_accessible.codebuild_client",
codebuild_client,
),
):
from prowler.providers.aws.services.codebuild.codebuild_project_not_publicly_accessible.codebuild_project_not_publicly_accessible import (
codebuild_project_not_publicly_accessible,
)
check = codebuild_project_not_publicly_accessible()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"CodeBuild project {project_name} is public."
)
assert result[0].resource_id == project_name
assert result[0].resource_arn == project_arn
assert result[0].resource_tags == []
assert result[0].region == AWS_REGION
def test_project_empty_visibility(self):
codebuild_client = mock.MagicMock
project_name = "test-project"
project_arn = f"arn:aws:codebuild:{AWS_REGION}:{AWS_ACCOUNT_NUMBER}:project/{project_name}"
codebuild_client.projects = {
project_arn: Project(
name=project_name,
arn=project_arn,
region="eu-west-1",
project_visibility="",
tags=[],
)
}
with (
mock.patch(
"prowler.providers.aws.services.codebuild.codebuild_service.Codebuild",
codebuild_client,
),
mock.patch(
"prowler.providers.aws.services.codebuild.codebuild_project_not_publicly_accessible.codebuild_project_not_publicly_accessible.codebuild_client",
codebuild_client,
),
):
from prowler.providers.aws.services.codebuild.codebuild_project_not_publicly_accessible.codebuild_project_not_publicly_accessible import (
codebuild_project_not_publicly_accessible,
)
check = codebuild_project_not_publicly_accessible()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"CodeBuild project {project_name} is public."
)
assert result[0].resource_id == project_name
assert result[0].resource_arn == project_arn
assert result[0].resource_tags == []
assert result[0].region == AWS_REGION
@@ -28,6 +28,7 @@ build_id = "test:93f838a7-cd20-48ae-90e5-c10fbbc78ca6"
last_invoked_time = datetime.now() - timedelta(days=2)
bitbucket_url = "https://bitbucket.org/example/repo.git"
secondary_bitbucket_url = "https://bitbucket.org/example/secondary-repo.git"
project_visibility = "PRIVATE"
report_group_arn = f"arn:{AWS_COMMERCIAL_PARTITION}:codebuild:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:report-group/{project_name}"
@@ -71,6 +72,7 @@ def mock_make_api_call(self, operation_name, kwarg):
},
},
"tags": [{"key": "Name", "value": project_name}],
"projectVisibility": project_visibility,
}
]
}
@@ -152,6 +154,7 @@ class Test_Codebuild_Service:
)
assert codebuild.projects[project_arn].tags[0]["key"] == "Name"
assert codebuild.projects[project_arn].tags[0]["value"] == project_name
assert codebuild.projects[project_arn].project_visibility == project_visibility
# Asserttions related with report groups
assert len(codebuild.report_groups) == 1
assert isinstance(codebuild.report_groups, dict)
@@ -27,6 +27,24 @@ def mock_make_api_call(self, operation_name, kwarg):
return make_api_call(self, operation_name, kwarg)
def mock_make_api_call_empty(self, operation_name, kwarg):
if operation_name == "DescribeLaunchTemplateVersions":
return {
"LaunchTemplateVersions": [
{
"VersionNumber": 1,
"LaunchTemplateData": {
"MetadataOptions": {
"HttpEndpoint": "",
"HttpTokens": "required",
}
},
}
]
}
return make_api_call(self, operation_name, kwarg)
def mock_make_api_call_not_required(self, operation_name, kwarg):
if operation_name == "DescribeLaunchTemplateVersions":
return {
@@ -134,6 +152,66 @@ class Test_ec2_launch_template_imdsv2_required:
)
assert result[0].resource_tags == []
@mock_aws
def test_launch_template_imdsv2_required_empty(self):
with mock.patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_empty,
):
ec2_client = client("ec2", region_name=AWS_REGION_US_EAST_1)
launch_template_name = "test-imdsv2-required-empty"
ec2_client.create_launch_template(
LaunchTemplateName=launch_template_name,
VersionDescription="Launch Template with IMDSv2 required",
LaunchTemplateData={
"InstanceType": "t1.micro",
"MetadataOptions": {
"HttpEndpoint": "",
"HttpTokens": "required",
},
},
)
launch_template_id = ec2_client.describe_launch_templates(
LaunchTemplateNames=[launch_template_name]
)["LaunchTemplates"][0]["LaunchTemplateId"]
from prowler.providers.aws.services.ec2.ec2_service import EC2
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.ec2.ec2_launch_template_imdsv2_required.ec2_launch_template_imdsv2_required.ec2_client",
new=EC2(aws_provider),
),
):
# Test Check
from prowler.providers.aws.services.ec2.ec2_launch_template_imdsv2_required.ec2_launch_template_imdsv2_required import (
ec2_launch_template_imdsv2_required,
)
check = ec2_launch_template_imdsv2_required()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"EC2 Launch Template {launch_template_name} has metadata service disabled in the following versions: 1."
)
assert result[0].resource_id == launch_template_id
assert result[0].region == AWS_REGION_US_EAST_1
assert (
result[0].resource_arn
== f"arn:aws:ec2:{AWS_REGION_US_EAST_1}:123456789012:launch-template/{launch_template_id}"
)
assert result[0].resource_tags == []
@mock_aws
def test_launch_template_imdsv2_not_required(self):
with mock.patch(
@@ -2,8 +2,13 @@ from boto3 import client
from moto import mock_aws
from prowler.providers.aws.services.firehose.firehose_service import (
DatabaseSourceDescription,
DirectPutSourceDescription,
EncryptionStatus,
Firehose,
KinesisStreamSourceDescription,
MSKSourceDescription,
Source,
)
from tests.providers.aws.utils import (
AWS_ACCOUNT_NUMBER,
@@ -152,3 +157,102 @@ class Test_Firehose_Service:
firehose.delivery_streams[arn].kms_key_arn
== f"arn:aws:kms:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:key/test-kms-key-id"
)
@mock_aws
def test_describe_delivery_stream_source_direct_put(self):
# Generate S3 client
s3_client = client("s3", region_name=AWS_REGION_EU_WEST_1)
s3_client.create_bucket(
Bucket="test-bucket",
CreateBucketConfiguration={"LocationConstraint": AWS_REGION_EU_WEST_1},
)
# Generate Firehose client
firehose_client = client("firehose", region_name=AWS_REGION_EU_WEST_1)
delivery_stream = firehose_client.create_delivery_stream(
DeliveryStreamName="test-delivery-stream",
DeliveryStreamType="DirectPut",
S3DestinationConfiguration={
"RoleARN": "arn:aws:iam::012345678901:role/firehose-role",
"BucketARN": "arn:aws:s3:::test-bucket",
"Prefix": "",
"BufferingHints": {"IntervalInSeconds": 300, "SizeInMBs": 5},
"CompressionFormat": "UNCOMPRESSED",
},
Tags=[{"Key": "key", "Value": "value"}],
)
arn = delivery_stream["DeliveryStreamARN"]
# Firehose Client for this test class
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
firehose = Firehose(aws_provider)
assert len(firehose.delivery_streams) == 1
assert firehose.delivery_streams[arn].delivery_stream_type == "DirectPut"
# Test Source structure
assert isinstance(firehose.delivery_streams[arn].source, Source)
assert isinstance(
firehose.delivery_streams[arn].source.direct_put, DirectPutSourceDescription
)
assert isinstance(
firehose.delivery_streams[arn].source.kinesis_stream,
KinesisStreamSourceDescription,
)
assert isinstance(
firehose.delivery_streams[arn].source.msk, MSKSourceDescription
)
assert isinstance(
firehose.delivery_streams[arn].source.database, DatabaseSourceDescription
)
@mock_aws
def test_describe_delivery_stream_source_kinesis_stream(self):
# Generate Kinesis client
kinesis_client = client("kinesis", region_name=AWS_REGION_EU_WEST_1)
kinesis_client.create_stream(
StreamName="test-kinesis-stream",
ShardCount=1,
)
kinesis_stream_arn = f"arn:aws:kinesis:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:stream/test-kinesis-stream"
# Generate Firehose client
firehose_client = client("firehose", region_name=AWS_REGION_EU_WEST_1)
delivery_stream = firehose_client.create_delivery_stream(
DeliveryStreamName="test-delivery-stream",
DeliveryStreamType="KinesisStreamAsSource",
KinesisStreamSourceConfiguration={
"KinesisStreamARN": kinesis_stream_arn,
"RoleARN": "arn:aws:iam::012345678901:role/firehose-role",
},
S3DestinationConfiguration={
"RoleARN": "arn:aws:iam::012345678901:role/firehose-role",
"BucketARN": "arn:aws:s3:::test-bucket",
"Prefix": "",
"BufferingHints": {"IntervalInSeconds": 300, "SizeInMBs": 5},
"CompressionFormat": "UNCOMPRESSED",
},
Tags=[{"Key": "key", "Value": "value"}],
)
arn = delivery_stream["DeliveryStreamARN"]
# Firehose Client for this test class
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
firehose = Firehose(aws_provider)
assert len(firehose.delivery_streams) == 1
assert (
firehose.delivery_streams[arn].delivery_stream_type
== "KinesisStreamAsSource"
)
# Test Source structure
assert isinstance(firehose.delivery_streams[arn].source, Source)
assert isinstance(
firehose.delivery_streams[arn].source.kinesis_stream,
KinesisStreamSourceDescription,
)
assert (
firehose.delivery_streams[arn].source.kinesis_stream.kinesis_stream_arn
== kinesis_stream_arn
)
@@ -198,7 +198,7 @@ class Test_firehose_stream_encrypted_at_rest:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Firehose Stream {stream_name} does not have at rest encryption enabled."
== f"Firehose Stream {stream_name} does not have at rest encryption enabled or the source stream is not encrypted."
)
@mock_aws
@@ -253,5 +253,74 @@ class Test_firehose_stream_encrypted_at_rest:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Firehose Stream {stream_name} does not have at rest encryption enabled."
== f"Firehose Stream {stream_name} does not have at rest encryption enabled or the source stream is not encrypted."
)
@mock_aws
def test_stream_kinesis_source_encrypted(self):
# Generate Kinesis client
kinesis_client = client("kinesis", region_name=AWS_REGION_EU_WEST_1)
kinesis_client.create_stream(
StreamName="test-kinesis-stream",
ShardCount=1,
)
kinesis_stream_arn = f"arn:aws:kinesis:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:stream/test-kinesis-stream"
# Enable encryption on the Kinesis stream
kinesis_client.start_stream_encryption(
StreamName="test-kinesis-stream",
EncryptionType="KMS",
KeyId=f"arn:aws:kms:{AWS_REGION_EU_WEST_1}:{AWS_ACCOUNT_NUMBER}:key/test-kms-key-id",
)
# Generate Firehose client
firehose_client = client("firehose", region_name=AWS_REGION_EU_WEST_1)
delivery_stream = firehose_client.create_delivery_stream(
DeliveryStreamName="test-delivery-stream",
DeliveryStreamType="KinesisStreamAsSource",
KinesisStreamSourceConfiguration={
"KinesisStreamARN": kinesis_stream_arn,
"RoleARN": "arn:aws:iam::012345678901:role/firehose-role",
},
S3DestinationConfiguration={
"RoleARN": "arn:aws:iam::012345678901:role/firehose-role",
"BucketARN": "arn:aws:s3:::test-bucket",
"Prefix": "",
"BufferingHints": {"IntervalInSeconds": 300, "SizeInMBs": 5},
"CompressionFormat": "UNCOMPRESSED",
},
Tags=[{"Key": "key", "Value": "value"}],
)
arn = delivery_stream["DeliveryStreamARN"]
stream_name = arn.split("/")[-1]
from prowler.providers.aws.services.firehose.firehose_service import Firehose
from prowler.providers.aws.services.kinesis.kinesis_service import Kinesis
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
with mock.patch(
"prowler.providers.aws.services.firehose.firehose_stream_encrypted_at_rest.firehose_stream_encrypted_at_rest.firehose_client",
new=Firehose(aws_provider),
):
with mock.patch(
"prowler.providers.aws.services.firehose.firehose_stream_encrypted_at_rest.firehose_stream_encrypted_at_rest.kinesis_client",
new=Kinesis(aws_provider),
):
# Test Check
from prowler.providers.aws.services.firehose.firehose_stream_encrypted_at_rest.firehose_stream_encrypted_at_rest import (
firehose_stream_encrypted_at_rest,
)
check = firehose_stream_encrypted_at_rest()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Firehose Stream {stream_name} does not have at rest encryption enabled but the source stream test-kinesis-stream has at rest encryption enabled."
)
@@ -232,3 +232,179 @@ class Test_iam_no_custom_policy_permissive_role_assumption:
result[1].status_extended,
)
assert result[1].resource_id == policy_name_permissive
@mock_aws
def test_policy_resource_with_embedded_wildcard_in_arn(self):
iam_client = client("iam")
policy_name = "policy_with_wildcard_in_arn"
policy_document = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::*:role/eks-terraform-*",
}
],
}
arn = iam_client.create_policy(
PolicyName=policy_name, PolicyDocument=dumps(policy_document)
)["Policy"]["Arn"]
from prowler.providers.aws.services.iam.iam_service import IAM
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
with mock.patch(
"prowler.providers.aws.services.iam.iam_no_custom_policy_permissive_role_assumption.iam_no_custom_policy_permissive_role_assumption.iam_client",
new=IAM(aws_provider),
):
from prowler.providers.aws.services.iam.iam_no_custom_policy_permissive_role_assumption.iam_no_custom_policy_permissive_role_assumption import (
iam_no_custom_policy_permissive_role_assumption,
)
check = iam_no_custom_policy_permissive_role_assumption()
result = check.execute()
assert result[0].status == "FAIL"
assert result[0].resource_arn == arn
assert search(
"allows permissive STS Role assumption", result[0].status_extended
)
assert result[0].resource_id == policy_name
@mock_aws
def test_policy_resource_list_containing_wildcard(self):
iam_client = client("iam")
policy_name = "policy_with_resource_list"
policy_document = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": ["arn:aws:iam::123456789012:role/SomeRole", "*"],
}
],
}
arn = iam_client.create_policy(
PolicyName=policy_name, PolicyDocument=dumps(policy_document)
)["Policy"]["Arn"]
from prowler.providers.aws.services.iam.iam_service import IAM
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
with mock.patch(
"prowler.providers.aws.services.iam.iam_no_custom_policy_permissive_role_assumption.iam_no_custom_policy_permissive_role_assumption.iam_client",
new=IAM(aws_provider),
):
from prowler.providers.aws.services.iam.iam_no_custom_policy_permissive_role_assumption.iam_no_custom_policy_permissive_role_assumption import (
iam_no_custom_policy_permissive_role_assumption,
)
check = iam_no_custom_policy_permissive_role_assumption()
result = check.execute()
assert result[0].status == "FAIL"
assert result[0].resource_arn == arn
assert search(
"allows permissive STS Role assumption", result[0].status_extended
)
assert result[0].resource_id == policy_name
@mock_aws
def test_policy_resource_list_with_arn_wildcards_should_fail(self):
iam_client = client("iam")
policy_name = "policy_list_wildcard_arn"
policy_document = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": [
"arn:aws:iam::123456789012:role/eks-admin",
"arn:aws:iam::*:role/eks-*",
],
}
],
}
arn = iam_client.create_policy(
PolicyName=policy_name, PolicyDocument=dumps(policy_document)
)["Policy"]["Arn"]
from prowler.providers.aws.services.iam.iam_service import IAM
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
with mock.patch(
"prowler.providers.aws.services.iam.iam_no_custom_policy_permissive_role_assumption.iam_no_custom_policy_permissive_role_assumption.iam_client",
new=IAM(aws_provider),
):
from prowler.providers.aws.services.iam.iam_no_custom_policy_permissive_role_assumption.iam_no_custom_policy_permissive_role_assumption import (
iam_no_custom_policy_permissive_role_assumption,
)
check = iam_no_custom_policy_permissive_role_assumption()
result = check.execute()
assert result[0].status == "FAIL"
assert result[0].resource_arn == arn
assert search(
"allows permissive STS Role assumption", result[0].status_extended
)
@mock_aws
def test_policy_resource_list_with_only_wildcarded_arns_should_fail(self):
iam_client = client("iam")
policy_name = "policy_list_scoped_wildcards_only"
policy_document = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": [
"arn:aws:iam::*:role/team-*",
"arn:aws:iam::*:role/dev-*",
],
}
],
}
arn = iam_client.create_policy(
PolicyName=policy_name, PolicyDocument=dumps(policy_document)
)["Policy"]["Arn"]
from prowler.providers.aws.services.iam.iam_service import IAM
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
with mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
):
with mock.patch(
"prowler.providers.aws.services.iam.iam_no_custom_policy_permissive_role_assumption.iam_no_custom_policy_permissive_role_assumption.iam_client",
new=IAM(aws_provider),
):
from prowler.providers.aws.services.iam.iam_no_custom_policy_permissive_role_assumption.iam_no_custom_policy_permissive_role_assumption import (
iam_no_custom_policy_permissive_role_assumption,
)
check = iam_no_custom_policy_permissive_role_assumption()
result = check.execute()
assert result[0].status == "FAIL"
assert result[0].resource_arn == arn
assert search(
"allows permissive STS Role assumption", result[0].status_extended
)

Some files were not shown because too many files have changed in this diff Show More