mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-13 05:59:47 +00:00
Compare commits
137 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7f1ba8e21 | |||
| db1edf5ca7 | |||
| 82d3ccec18 | |||
| ff46281f64 | |||
| 94e234cefb | |||
| 8267fc4813 | |||
| 8bfeee238b | |||
| cc197ea901 | |||
| 2b5d015e09 | |||
| 73e0ac6892 | |||
| 700b51ddad | |||
| 417be55604 | |||
| f75ce7b4dd | |||
| 269d9dfe41 | |||
| 7b0ce7842b | |||
| 0a11ca4a68 | |||
| c953fa7e67 | |||
| 73907db856 | |||
| 041f95b3df | |||
| 716c130140 | |||
| c651f60e3a | |||
| dd00d71a07 | |||
| 834d1bca49 | |||
| 2cf45c72b6 | |||
| 213e18724d | |||
| 571141f57c | |||
| 45f0909c3e | |||
| b01fcc6cb2 | |||
| 2ddd5b3091 | |||
| 6100932c60 | |||
| 1c2b146e6e | |||
| 833f3779ef | |||
| c752811666 | |||
| 4d1f7626f9 | |||
| 9bf2a13177 | |||
| d15e67e2e5 | |||
| 20cf5562b8 | |||
| 36279f694c | |||
| c991a1d0e8 | |||
| aa3641718b | |||
| bb80797392 | |||
| 435624fcd4 | |||
| 9e67f31913 | |||
| 0984cfd75b | |||
| c1044ef491 | |||
| 19c4c9251c | |||
| 55ed7a0663 | |||
| 0599040d4e | |||
| 737d20d2c1 | |||
| 844efbd046 | |||
| d60b4f0f52 | |||
| 49ba25ba07 | |||
| 41629137ef | |||
| 114e86c0dc | |||
| 1015f1379f | |||
| c62ac6c71b | |||
| 14356e3187 | |||
| 591f5a8603 | |||
| 93b8a7c74c | |||
| 7df73a9d4f | |||
| 1eda94140d | |||
| ad6368a446 | |||
| 3361393b7d | |||
| 0b7a21a70c | |||
| 872e6e239c | |||
| 2fe92cfce3 | |||
| cece2cb87e | |||
| ab266080d0 | |||
| 4638b39ed4 | |||
| 997f9bf64a | |||
| aecc234f78 | |||
| 8317eff67b | |||
| 5c4ee0bc48 | |||
| 0f2fdcfb3f | |||
| 11a8873155 | |||
| 5a3475bed3 | |||
| bc43eed736 | |||
| 8c1e69b542 | |||
| 75c4f11475 | |||
| 1da10611e7 | |||
| e8aaf5266a | |||
| f5f1f1ab2d | |||
| 65e745d779 | |||
| 907664093f | |||
| 8c2e2332d7 | |||
| cb03573599 | |||
| b7571abaeb | |||
| 4f93a89d1b | |||
| 88ce188103 | |||
| df680ef277 | |||
| 451071d694 | |||
| 887a20f06e | |||
| 712da2cf98 | |||
| 6a4278ed4d | |||
| febd2c8fdb | |||
| 787a339cd9 | |||
| 1cf6eaa0b7 | |||
| b311456160 | |||
| ad02801c74 | |||
| 361f8548bf | |||
| 2b7b2623c5 | |||
| e9860f7002 | |||
| b509fdf562 | |||
| e197ad6fb0 | |||
| c9284f8003 | |||
| 4cd3b09818 | |||
| 22f79edec5 | |||
| 0790619020 | |||
| 9df06095eb | |||
| 3672d19c6a | |||
| ebc792e578 | |||
| 534ad3d04f | |||
| 37d59b118f | |||
| 06e32e69c0 | |||
| 6e9f54d1ba | |||
| b29cd7f6c7 | |||
| 41a7b19c7d | |||
| c972f19059 | |||
| 27d074abe4 | |||
| 28060064de | |||
| fd695b6992 | |||
| 2fff8cb416 | |||
| f55e87d659 | |||
| 29b835360a | |||
| 16e15a3a71 | |||
| a6d47bdb2b | |||
| 712af7b9c9 | |||
| b8c6f3ba67 | |||
| 80a814afce | |||
| 52facad35c | |||
| 63e10c9661 | |||
| 97a91bfaaa | |||
| ba92a592ab | |||
| 5346222be2 | |||
| 4dc3765670 | |||
| e0d61ba5d1 | |||
| fc2fef755a |
@@ -78,6 +78,9 @@ TASK_RETRY_ATTEMPTS=5
|
||||
|
||||
# Valkey settings
|
||||
# If running Valkey and celery on host, use localhost, else use 'valkey'
|
||||
VALKEY_SCHEME=redis
|
||||
VALKEY_USERNAME=
|
||||
VALKEY_PASSWORD=
|
||||
VALKEY_HOST=valkey
|
||||
VALKEY_PORT=6379
|
||||
VALKEY_DB=0
|
||||
|
||||
@@ -26,10 +26,18 @@ runs:
|
||||
if: github.event_name == 'pull_request' && github.base_ref == 'master' && github.repository == 'prowler-cloud/prowler'
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
env:
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
run: |
|
||||
BRANCH_NAME="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
|
||||
echo "Using branch: $BRANCH_NAME"
|
||||
sed -i "s|\(git+https://github.com/prowler-cloud/prowler[^@]*\)@master|\1@$BRANCH_NAME|g" pyproject.toml
|
||||
UPSTREAM="prowler-cloud/prowler"
|
||||
if [ "$HEAD_REPO" != "$UPSTREAM" ]; then
|
||||
echo "Fork PR detected (${HEAD_REPO}), rewriting VCS URL to fork"
|
||||
sed -i "s|git+https://github.com/prowler-cloud/prowler\([^@]*\)@master|git+https://github.com/${HEAD_REPO}\1@$BRANCH_NAME|g" pyproject.toml
|
||||
else
|
||||
echo "Same-repo PR, using branch: $BRANCH_NAME"
|
||||
sed -i "s|\(git+https://github.com/prowler-cloud/prowler[^@]*\)@master|\1@$BRANCH_NAME|g" pyproject.toml
|
||||
fi
|
||||
|
||||
- name: Install poetry
|
||||
shell: bash
|
||||
|
||||
@@ -117,7 +117,10 @@ runs:
|
||||
INPUTS_IMAGE_TAG: ${{ inputs.image-tag }}
|
||||
|
||||
- name: Comment scan results on PR
|
||||
if: inputs.create-pr-comment == 'true' && github.event_name == 'pull_request'
|
||||
if: >-
|
||||
inputs.create-pr-comment == 'true'
|
||||
&& github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
IMAGE_NAME: ${{ inputs.image-name }}
|
||||
|
||||
@@ -27,8 +27,13 @@ jobs:
|
||||
patch_version: ${{ steps.detect.outputs.patch_version }}
|
||||
current_api_version: ${{ steps.get_api_version.outputs.current_api_version }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -79,8 +84,13 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -118,7 +128,7 @@ jobs:
|
||||
git --no-pager diff
|
||||
|
||||
- name: Create PR for next API minor version to master
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
@@ -137,7 +147,7 @@ jobs:
|
||||
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
|
||||
|
||||
- name: Checkout version branch
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }}
|
||||
persist-credentials: false
|
||||
@@ -177,7 +187,7 @@ jobs:
|
||||
git --no-pager diff
|
||||
|
||||
- name: Create PR for first API patch version to version branch
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
@@ -204,8 +214,13 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -255,7 +270,7 @@ jobs:
|
||||
git --no-pager diff
|
||||
|
||||
- name: Create PR for next API patch version to version branch
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
|
||||
@@ -32,15 +32,25 @@ jobs:
|
||||
working-directory: ./api
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
api.github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check for API changes
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
api/**
|
||||
|
||||
@@ -41,18 +41,30 @@ jobs:
|
||||
- 'python'
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.github.com:443
|
||||
github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
uploads.github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
objects.githubusercontent.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ./.github/codeql/api-codeql-config.yml
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
||||
@@ -18,9 +18,6 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -43,7 +40,14 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
|
||||
- name: Calculate short SHA
|
||||
id: set-short-sha
|
||||
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
@@ -55,9 +59,16 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
message-ts: ${{ steps.slack-notification.outputs.ts }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -94,13 +105,39 @@ jobs:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
_http._tcp.deb.debian.org:443
|
||||
aka.ms:443
|
||||
auth.docker.io:443
|
||||
cdn.powershellgallery.com:443
|
||||
dc.services.visualstudio.com:443
|
||||
debian.map.fastlydns.net:80
|
||||
files.pythonhosted.org:443
|
||||
github.com:443
|
||||
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
|
||||
production.cloudflare.docker.com:443
|
||||
pypi.org:443
|
||||
registry-1.docker.io:443
|
||||
release-assets.githubusercontent.com:443
|
||||
www.powershellgallery.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Pin prowler SDK to latest master commit
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
LATEST_SHA=$(git ls-remote https://github.com/prowler-cloud/prowler.git refs/heads/master | cut -f1)
|
||||
sed -i "s|prowler-cloud/prowler.git@master|prowler-cloud/prowler.git@${LATEST_SHA}|" api/pyproject.toml
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -111,7 +148,7 @@ jobs:
|
||||
- name: Build and push API container for ${{ matrix.arch }}
|
||||
id: container-push
|
||||
if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: ${{ env.WORKING_DIRECTORY }}
|
||||
push: true
|
||||
@@ -126,10 +163,22 @@ jobs:
|
||||
needs: [setup, container-build-push]
|
||||
if: always() && needs.setup.result == 'success' && needs.container-build-push.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -161,7 +210,7 @@ jobs:
|
||||
|
||||
- name: Install regctl
|
||||
if: always()
|
||||
uses: regclient/actions/regctl-installer@f61d18f46c86af724a9c804cb9ff2a6fec741c7c # main
|
||||
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
|
||||
|
||||
- name: Cleanup intermediate architecture tags
|
||||
if: always()
|
||||
@@ -178,9 +227,16 @@ jobs:
|
||||
needs: [setup, notify-release-started, container-build-push, create-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -221,6 +277,13 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.github.com:443
|
||||
|
||||
- name: Trigger API deployment
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
with:
|
||||
|
||||
@@ -27,15 +27,22 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check if Dockerfile changed
|
||||
id: dockerfile-changed
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: api/Dockerfile
|
||||
|
||||
@@ -65,15 +72,39 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.github.com:443
|
||||
mirror.gcr.io:443
|
||||
check.trivy.dev:443
|
||||
github.com:443
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
debian.map.fastlydns.net:80
|
||||
release-assets.githubusercontent.com:443
|
||||
objects.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
www.powershellgallery.com:443
|
||||
aka.ms:443
|
||||
cdn.powershellgallery.com:443
|
||||
_http._tcp.deb.debian.org:443
|
||||
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
|
||||
get.trivy.dev:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check for API changes
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: api/**
|
||||
files_ignore: |
|
||||
@@ -88,7 +119,7 @@ jobs:
|
||||
|
||||
- name: Build container for ${{ matrix.arch }}
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: ${{ env.API_WORKING_DIR }}
|
||||
push: false
|
||||
|
||||
@@ -32,15 +32,28 @@ jobs:
|
||||
working-directory: ./api
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
github.com:443
|
||||
auth.safetycli.com:443
|
||||
pyup.io:443
|
||||
data.safetycli.com:443
|
||||
api.github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check for API changes
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
api/**
|
||||
@@ -64,8 +77,9 @@ jobs:
|
||||
|
||||
- name: Safety
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: poetry run safety check --ignore 79023,79027
|
||||
run: poetry run safety check --ignore 79023,79027,86217
|
||||
# TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0
|
||||
# TODO: 86217 because `alibabacloud-tea-openapi == 0.4.3` don't let us upgrade `cryptography >= 46.0.0`
|
||||
|
||||
- name: Vulture
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -22,6 +22,9 @@ env:
|
||||
POSTGRES_USER: prowler_user
|
||||
POSTGRES_PASSWORD: prowler
|
||||
POSTGRES_DB: postgres-db
|
||||
VALKEY_SCHEME: redis
|
||||
VALKEY_USERNAME: ""
|
||||
VALKEY_PASSWORD: ""
|
||||
VALKEY_HOST: localhost
|
||||
VALKEY_PORT: 6379
|
||||
VALKEY_DB: 0
|
||||
@@ -72,15 +75,31 @@ jobs:
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
cli.codecov.io:443
|
||||
keybase.io:443
|
||||
ingest.codecov.io:443
|
||||
storage.googleapis.com:443
|
||||
o26192.ingest.us.sentry.io:443
|
||||
api.github.com:443
|
||||
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check for API changes
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
api/**
|
||||
|
||||
@@ -27,6 +27,15 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.github.com:443
|
||||
f27ab01db9584e008c443b7137d16425.apm.europe-west2.gcp.elastic-cloud.com:443
|
||||
github.com:443
|
||||
|
||||
- name: Check labels
|
||||
id: label_check
|
||||
uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65
|
||||
|
||||
@@ -33,8 +33,18 @@ jobs:
|
||||
actions: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
ghcr.io:443
|
||||
pkg-containers.githubusercontent.com:443
|
||||
api.github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Remove 'status/awaiting-response' label
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -25,6 +25,11 @@ jobs:
|
||||
pull-requests: read
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check PR title format
|
||||
uses: agenthunt/conventional-commit-checker-action@f1823f632e95a64547566dcd2c7da920e67117ad # v2.0.1
|
||||
with:
|
||||
|
||||
@@ -22,6 +22,11 @@ jobs:
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Create backport label for minor releases
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -27,8 +27,13 @@ jobs:
|
||||
patch_version: ${{ steps.detect.outputs.patch_version }}
|
||||
current_docs_version: ${{ steps.get_docs_version.outputs.current_docs_version }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -79,8 +84,13 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -114,7 +124,7 @@ jobs:
|
||||
git --no-pager diff
|
||||
|
||||
- name: Create PR for documentation update to master
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
@@ -137,7 +147,7 @@ jobs:
|
||||
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
|
||||
|
||||
- name: Checkout version branch
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }}
|
||||
persist-credentials: false
|
||||
@@ -174,7 +184,7 @@ jobs:
|
||||
git --no-pager diff
|
||||
|
||||
- name: Create PR for documentation update to version branch
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
@@ -204,8 +214,13 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -245,7 +260,7 @@ jobs:
|
||||
git --no-pager diff
|
||||
|
||||
- name: Create PR for documentation update to version branch
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
|
||||
@@ -22,8 +22,17 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
ghcr.io:443
|
||||
pkg-containers.githubusercontent.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
@@ -30,6 +30,11 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
|
||||
@@ -23,6 +23,11 @@ jobs:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
|
||||
Generated
+30
@@ -65,6 +65,11 @@ jobs:
|
||||
text: ${{ steps.compute-text.outputs.text }}
|
||||
title: ${{ steps.compute-text.outputs.title }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23
|
||||
with:
|
||||
@@ -129,6 +134,11 @@ jobs:
|
||||
output_types: ${{ steps.collect_output.outputs.output_types }}
|
||||
secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23
|
||||
with:
|
||||
@@ -859,6 +869,11 @@ jobs:
|
||||
tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
|
||||
total_count: ${{ steps.missing_tool.outputs.total_count }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23
|
||||
with:
|
||||
@@ -966,6 +981,11 @@ jobs:
|
||||
outputs:
|
||||
success: ${{ steps.parse_results.outputs.success }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23
|
||||
with:
|
||||
@@ -1070,6 +1090,11 @@ jobs:
|
||||
outputs:
|
||||
activated: ${{ (steps.check_membership.outputs.is_team_member == 'true') && (steps.check_rate_limit.outputs.rate_limit_ok == 'true') }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23
|
||||
with:
|
||||
@@ -1138,6 +1163,11 @@ jobs:
|
||||
process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }}
|
||||
process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
uses: github/gh-aw/actions/setup@9382be3ca9ac18917e111a99d4e6bbff58d0dccc # v0.43.23
|
||||
with:
|
||||
|
||||
@@ -24,6 +24,11 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Apply labels to PR
|
||||
uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||
with:
|
||||
@@ -38,6 +43,11 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check if author is org member
|
||||
id: check_membership
|
||||
env:
|
||||
@@ -65,7 +75,7 @@ jobs:
|
||||
"RosaRivasProwler"
|
||||
"StylusFrost"
|
||||
"toniblyx"
|
||||
"vicferpoy"
|
||||
"davidm4r"
|
||||
)
|
||||
|
||||
echo "Checking if $AUTHOR is a member of prowler-cloud organization"
|
||||
|
||||
@@ -17,9 +17,6 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -42,7 +39,14 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
|
||||
- name: Calculate short SHA
|
||||
id: set-short-sha
|
||||
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
@@ -54,9 +58,16 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
message-ts: ${{ steps.slack-notification.outputs.ts }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -92,13 +103,27 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
ghcr.io:443
|
||||
pkg-containers.githubusercontent.com:443
|
||||
files.pythonhosted.org:443
|
||||
pypi.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -109,7 +134,7 @@ jobs:
|
||||
- name: Build and push MCP container for ${{ matrix.arch }}
|
||||
id: container-push
|
||||
if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: ${{ env.WORKING_DIRECTORY }}
|
||||
push: true
|
||||
@@ -132,10 +157,23 @@ jobs:
|
||||
needs: [setup, container-build-push]
|
||||
if: always() && needs.setup.result == 'success' && needs.container-build-push.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -184,9 +222,16 @@ jobs:
|
||||
needs: [setup, notify-release-started, container-build-push, create-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -227,6 +272,13 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.github.com:443
|
||||
|
||||
- name: Trigger MCP deployment
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
with:
|
||||
|
||||
@@ -27,15 +27,22 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check if Dockerfile changed
|
||||
id: dockerfile-changed
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: mcp_server/Dockerfile
|
||||
|
||||
@@ -64,15 +71,32 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
ghcr.io:443
|
||||
pkg-containers.githubusercontent.com:443
|
||||
files.pythonhosted.org:443
|
||||
pypi.org:443
|
||||
api.github.com:443
|
||||
mirror.gcr.io:443
|
||||
check.trivy.dev:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check for MCP changes
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: mcp_server/**
|
||||
files_ignore: |
|
||||
@@ -85,7 +109,7 @@ jobs:
|
||||
|
||||
- name: Build MCP container for ${{ matrix.arch }}
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: ${{ env.MCP_WORKING_DIR }}
|
||||
push: false
|
||||
|
||||
@@ -26,6 +26,11 @@ jobs:
|
||||
major_version: ${{ steps.parse-version.outputs.major }}
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Parse and validate version
|
||||
id: parse-version
|
||||
run: |
|
||||
@@ -59,18 +64,23 @@ jobs:
|
||||
url: https://pypi.org/project/prowler-mcp/
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
with:
|
||||
enable-cache: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
|
||||
@@ -28,8 +28,16 @@ jobs:
|
||||
MONITORED_FOLDERS: 'api ui prowler mcp_server'
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.github.com:443
|
||||
github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# zizmor: ignore[artipacked]
|
||||
@@ -37,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
api/**
|
||||
|
||||
@@ -25,8 +25,13 @@ jobs:
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR head
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
@@ -34,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: '**'
|
||||
|
||||
|
||||
@@ -23,6 +23,13 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.github.com:443
|
||||
|
||||
- name: Calculate short commit SHA
|
||||
id: vars
|
||||
run: |
|
||||
|
||||
@@ -26,15 +26,20 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
@@ -345,7 +350,7 @@ jobs:
|
||||
|
||||
- name: Create PR for API dependency update
|
||||
if: ${{ env.PATCH_VERSION == '0' }}
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
commit-message: 'chore(api): update prowler dependency to ${{ env.BRANCH_NAME }} for release ${{ env.PROWLER_VERSION }}'
|
||||
|
||||
@@ -26,6 +26,11 @@ jobs:
|
||||
minor_version: ${{ steps.detect.outputs.minor_version }}
|
||||
patch_version: ${{ steps.detect.outputs.patch_version }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Detect release type and parse version
|
||||
id: detect
|
||||
run: |
|
||||
@@ -66,8 +71,13 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -96,7 +106,7 @@ jobs:
|
||||
git --no-pager diff
|
||||
|
||||
- name: Create PR for next minor version to master
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
@@ -115,7 +125,7 @@ jobs:
|
||||
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
|
||||
|
||||
- name: Checkout version branch
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }}
|
||||
persist-credentials: false
|
||||
@@ -148,7 +158,7 @@ jobs:
|
||||
git --no-pager diff
|
||||
|
||||
- name: Create PR for first patch version to version branch
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
@@ -175,8 +185,13 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -211,7 +226,7 @@ jobs:
|
||||
git --no-pager diff
|
||||
|
||||
- name: Create PR for next patch version to version branch
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
|
||||
@@ -19,8 +19,15 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -24,21 +24,29 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- '3.9'
|
||||
- '3.10'
|
||||
- '3.11'
|
||||
- '3.12'
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check for SDK changes
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: ./**
|
||||
files_ignore: |
|
||||
@@ -67,7 +75,7 @@ jobs:
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'poetry'
|
||||
|
||||
@@ -48,18 +48,28 @@ jobs:
|
||||
- 'python'
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.github.com:443
|
||||
github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
uploads.github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ./.github/codeql/sdk-codeql-config.yml
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
||||
@@ -23,9 +23,6 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -59,14 +56,26 @@ jobs:
|
||||
prowler_version_major: ${{ steps.get-prowler-version.outputs.prowler_version_major }}
|
||||
latest_tag: ${{ steps.get-prowler-version.outputs.latest_tag }}
|
||||
stable_tag: ${{ steps.get-prowler-version.outputs.stable_tag }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
dc.services.visualstudio.com:443
|
||||
files.pythonhosted.org:443
|
||||
github.com:443
|
||||
pypi.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
@@ -115,9 +124,16 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
message-ts: ${{ steps.slack-notification.outputs.ts }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -154,19 +170,40 @@ jobs:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
_http._tcp.deb.debian.org:443
|
||||
aka.ms:443
|
||||
api.ecr-public.us-east-1.amazonaws.com:443
|
||||
auth.docker.io:443
|
||||
cdn.powershellgallery.com:443
|
||||
debian.map.fastlydns.net:80
|
||||
files.pythonhosted.org:443
|
||||
github.com:443
|
||||
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
|
||||
production.cloudflare.docker.com:443
|
||||
public.ecr.aws:443
|
||||
pypi.org:443
|
||||
registry-1.docker.io:443
|
||||
release-assets.githubusercontent.com:443
|
||||
www.powershellgallery.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to Public ECR
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: public.ecr.aws
|
||||
username: ${{ secrets.PUBLIC_ECR_AWS_ACCESS_KEY_ID }}
|
||||
@@ -180,7 +217,7 @@ jobs:
|
||||
- name: Build and push SDK container for ${{ matrix.arch }}
|
||||
id: container-push
|
||||
if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
file: ${{ env.DOCKERFILE_PATH }}
|
||||
@@ -196,16 +233,32 @@ jobs:
|
||||
needs: [setup, container-build-push]
|
||||
if: always() && needs.setup.result == 'success' && needs.container-build-push.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.ecr-public.us-east-1.amazonaws.com:443
|
||||
auth.docker.io:443
|
||||
github.com:443
|
||||
production.cloudflare.docker.com:443
|
||||
public.ecr.aws:443
|
||||
registry-1.docker.io:443
|
||||
release-assets.githubusercontent.com:443
|
||||
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to Public ECR
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: public.ecr.aws
|
||||
username: ${{ secrets.PUBLIC_ECR_AWS_ACCESS_KEY_ID }}
|
||||
@@ -264,9 +317,16 @@ jobs:
|
||||
needs: [setup, notify-release-started, container-build-push, create-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -307,6 +367,11 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Calculate short SHA
|
||||
id: short-sha
|
||||
run: echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -26,15 +26,22 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check if Dockerfile changed
|
||||
id: dockerfile-changed
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: Dockerfile
|
||||
|
||||
@@ -64,15 +71,38 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
api.github.com:443
|
||||
mirror.gcr.io:443
|
||||
check.trivy.dev:443
|
||||
debian.map.fastlydns.net:80
|
||||
release-assets.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
www.powershellgallery.com:443
|
||||
aka.ms:443
|
||||
cdn.powershellgallery.com:443
|
||||
_http._tcp.deb.debian.org:443
|
||||
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
|
||||
get.trivy.dev:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check for SDK changes
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: ./**
|
||||
files_ignore: |
|
||||
@@ -101,7 +131,7 @@ jobs:
|
||||
|
||||
- name: Build SDK container for ${{ matrix.arch }}
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
|
||||
@@ -25,6 +25,11 @@ jobs:
|
||||
major_version: ${{ steps.parse-version.outputs.major }}
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Parse and validate version
|
||||
id: parse-version
|
||||
run: |
|
||||
@@ -58,8 +63,13 @@ jobs:
|
||||
url: https://pypi.org/project/prowler/${{ needs.validate-release.outputs.prowler_version }}/
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -67,7 +77,7 @@ jobs:
|
||||
run: pipx install poetry==2.1.1
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
@@ -91,8 +101,13 @@ jobs:
|
||||
url: https://pypi.org/project/prowler-cloud/${{ needs.validate-release.outputs.prowler_version }}/
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -100,7 +115,7 @@ jobs:
|
||||
run: pipx install poetry==2.1.1
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
|
||||
@@ -24,14 +24,19 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: 'master'
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
@@ -40,7 +45,7 @@ jobs:
|
||||
run: pip install boto3
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708 # v5.1.1
|
||||
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0
|
||||
with:
|
||||
aws-region: ${{ env.AWS_REGION }}
|
||||
role-to-assume: ${{ secrets.DEV_IAM_ROLE_ARN }}
|
||||
@@ -51,7 +56,7 @@ jobs:
|
||||
|
||||
- name: Create pull request
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
author: 'prowler-bot <179230569+prowler-bot@users.noreply.github.com>'
|
||||
|
||||
@@ -22,14 +22,19 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: 'master'
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: 'pip'
|
||||
@@ -48,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Create pull request
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
author: 'prowler-bot <179230569+prowler-bot@users.noreply.github.com>'
|
||||
@@ -72,12 +77,13 @@ jobs:
|
||||
This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` with the latest regions fetched from the OCI Identity API (`list_regions()`).
|
||||
|
||||
- Government regions (`OCI_GOVERNMENT_REGIONS`) are preserved unchanged
|
||||
- DOD regions (`OCI_US_DOD_REGIONS`) are preserved unchanged
|
||||
- Region display names are mapped from Oracle's official documentation
|
||||
|
||||
### Checklist
|
||||
|
||||
- [x] This is an automated update from OCI official sources
|
||||
- [x] Government regions (us-langley-1, us-luke-1) preserved
|
||||
- [x] Government regions (us-langley-1, us-luke-1) and DOD regions (us-gov-ashburn-1, us-gov-phoenix-1, us-gov-chicago-1) are preserved
|
||||
- [x] No manual review of region data required
|
||||
|
||||
### License
|
||||
|
||||
@@ -23,15 +23,28 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
github.com:443
|
||||
auth.safetycli.com:443
|
||||
pyup.io:443
|
||||
data.safetycli.com:443
|
||||
api.github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check for SDK changes
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files:
|
||||
./**
|
||||
@@ -62,7 +75,7 @@ jobs:
|
||||
|
||||
- name: Set up Python 3.12
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'poetry'
|
||||
|
||||
@@ -24,21 +24,50 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
- '3.9'
|
||||
- '3.10'
|
||||
- '3.11'
|
||||
- '3.12'
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
api.github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
*.amazonaws.com:443
|
||||
*.googleapis.com:443
|
||||
schema.ocsf.io:443
|
||||
registry-1.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
|
||||
o26192.ingest.us.sentry.io:443
|
||||
management.azure.com:443
|
||||
login.microsoftonline.com:443
|
||||
keybase.io:443
|
||||
ingest.codecov.io:443
|
||||
graph.microsoft.com:443
|
||||
dc.services.visualstudio.com:443
|
||||
cloud.mongodb.com:443
|
||||
cli.codecov.io:443
|
||||
auth.docker.io:443
|
||||
api.vercel.com:443
|
||||
api.atlassian.com:443
|
||||
aka.ms:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check for SDK changes
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: ./**
|
||||
files_ignore: |
|
||||
@@ -67,7 +96,7 @@ jobs:
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'poetry'
|
||||
@@ -80,7 +109,7 @@ jobs:
|
||||
- name: Check if AWS files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-aws
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/aws/**
|
||||
@@ -210,7 +239,7 @@ jobs:
|
||||
- name: Check if Azure files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-azure
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/azure/**
|
||||
@@ -234,7 +263,7 @@ jobs:
|
||||
- name: Check if GCP files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-gcp
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/gcp/**
|
||||
@@ -258,7 +287,7 @@ jobs:
|
||||
- name: Check if Kubernetes files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-kubernetes
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/kubernetes/**
|
||||
@@ -282,7 +311,7 @@ jobs:
|
||||
- name: Check if GitHub files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-github
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/github/**
|
||||
@@ -306,7 +335,7 @@ jobs:
|
||||
- name: Check if NHN files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-nhn
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/nhn/**
|
||||
@@ -330,7 +359,7 @@ jobs:
|
||||
- name: Check if M365 files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-m365
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/m365/**
|
||||
@@ -354,7 +383,7 @@ jobs:
|
||||
- name: Check if IaC files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-iac
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/iac/**
|
||||
@@ -378,7 +407,7 @@ jobs:
|
||||
- name: Check if MongoDB Atlas files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-mongodbatlas
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/mongodbatlas/**
|
||||
@@ -402,7 +431,7 @@ jobs:
|
||||
- name: Check if OCI files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-oraclecloud
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/oraclecloud/**
|
||||
@@ -426,7 +455,7 @@ jobs:
|
||||
- name: Check if OpenStack files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-openstack
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/openstack/**
|
||||
@@ -450,7 +479,7 @@ jobs:
|
||||
- name: Check if Google Workspace files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-googleworkspace
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/googleworkspace/**
|
||||
@@ -474,7 +503,7 @@ jobs:
|
||||
- name: Check if Lib files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-lib
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
./prowler/lib/**
|
||||
@@ -498,7 +527,7 @@ jobs:
|
||||
- name: Check if Config files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-config
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
./prowler/config/**
|
||||
|
||||
@@ -45,20 +45,31 @@ jobs:
|
||||
has-sdk-tests: ${{ steps.set-flags.outputs.has-sdk-tests }}
|
||||
has-api-tests: ${{ steps.set-flags.outputs.has-api-tests }}
|
||||
has-ui-e2e: ${{ steps.set-flags.outputs.has-ui-e2e }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@ jobs:
|
||||
minor_version: ${{ steps.detect.outputs.minor_version }}
|
||||
patch_version: ${{ steps.detect.outputs.patch_version }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Detect release type and parse version
|
||||
id: detect
|
||||
run: |
|
||||
@@ -66,8 +71,13 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -89,13 +99,13 @@ jobs:
|
||||
run: |
|
||||
set -e
|
||||
|
||||
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PROWLER_VERSION}|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_MINOR_VERSION}|" .env
|
||||
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_MINOR_VERSION}|" .env
|
||||
|
||||
echo "Files modified:"
|
||||
git --no-pager diff
|
||||
|
||||
- name: Create PR for next minor version to master
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
@@ -117,7 +127,7 @@ jobs:
|
||||
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
|
||||
|
||||
- name: Checkout version branch
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: v${{ needs.detect-release-type.outputs.major_version }}.${{ needs.detect-release-type.outputs.minor_version }}
|
||||
persist-credentials: false
|
||||
@@ -143,13 +153,13 @@ jobs:
|
||||
run: |
|
||||
set -e
|
||||
|
||||
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PROWLER_VERSION}|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${FIRST_PATCH_VERSION}|" .env
|
||||
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${FIRST_PATCH_VERSION}|" .env
|
||||
|
||||
echo "Files modified:"
|
||||
git --no-pager diff
|
||||
|
||||
- name: Create PR for first patch version to version branch
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
@@ -179,8 +189,13 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -208,13 +223,13 @@ jobs:
|
||||
run: |
|
||||
set -e
|
||||
|
||||
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${PROWLER_VERSION}|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_PATCH_VERSION}|" .env
|
||||
sed -i "s|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=.*|NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v${NEXT_PATCH_VERSION}|" .env
|
||||
|
||||
echo "Files modified:"
|
||||
git --no-pager diff
|
||||
|
||||
- name: Create PR for next patch version to version branch
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
|
||||
@@ -44,18 +44,28 @@ jobs:
|
||||
- 'javascript-typescript'
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.github.com:443
|
||||
github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
uploads.github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ./.github/codeql/ui-codeql-config.yml
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
|
||||
with:
|
||||
category: '/language:${{ matrix.language }}'
|
||||
|
||||
@@ -17,9 +17,6 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -45,7 +42,14 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Calculate short SHA
|
||||
id: set-short-sha
|
||||
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
|
||||
@@ -57,9 +61,16 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
message-ts: ${{ steps.slack-notification.outputs.ts }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -96,13 +107,27 @@ jobs:
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
registry-1.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
auth.docker.io:443
|
||||
registry.npmjs.org:443
|
||||
dl-cdn.alpinelinux.org:443
|
||||
fonts.googleapis.com:443
|
||||
fonts.gstatic.com:443
|
||||
github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -113,7 +138,7 @@ jobs:
|
||||
- name: Build and push UI container for ${{ matrix.arch }}
|
||||
id: container-push
|
||||
if: github.event_name == 'push' || github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: ${{ env.WORKING_DIRECTORY }}
|
||||
build-args: |
|
||||
@@ -131,10 +156,23 @@ jobs:
|
||||
needs: [setup, container-build-push]
|
||||
if: always() && needs.setup.result == 'success' && needs.container-build-push.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -183,9 +221,16 @@ jobs:
|
||||
needs: [setup, notify-release-started, container-build-push, create-manifest]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -226,6 +271,13 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.github.com:443
|
||||
|
||||
- name: Trigger UI deployment
|
||||
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
|
||||
with:
|
||||
|
||||
@@ -27,15 +27,22 @@ jobs:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check if Dockerfile changed
|
||||
id: dockerfile-changed
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: ui/Dockerfile
|
||||
|
||||
@@ -65,15 +72,33 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
registry-1.docker.io:443
|
||||
auth.docker.io:443
|
||||
production.cloudflare.docker.com:443
|
||||
registry.npmjs.org:443
|
||||
dl-cdn.alpinelinux.org:443
|
||||
fonts.googleapis.com:443
|
||||
fonts.gstatic.com:443
|
||||
api.github.com:443
|
||||
mirror.gcr.io:443
|
||||
check.trivy.dev:443
|
||||
get.trivy.dev:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check for UI changes
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: ui/**
|
||||
files_ignore: |
|
||||
@@ -87,7 +112,7 @@ jobs:
|
||||
|
||||
- name: Build UI container for ${{ matrix.arch }}
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: ${{ env.UI_WORKING_DIR }}
|
||||
target: prod
|
||||
|
||||
@@ -15,13 +15,12 @@ on:
|
||||
- 'ui/**'
|
||||
- 'api/**' # API changes can affect UI E2E
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# First, analyze which tests need to run
|
||||
impact-analysis:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/test-impact-analysis.yml
|
||||
|
||||
# Run E2E tests based on impact analysis
|
||||
@@ -75,10 +74,17 @@ jobs:
|
||||
# Pass E2E paths from impact analysis
|
||||
E2E_TEST_PATHS: ${{ needs.impact-analysis.outputs.ui-e2e }}
|
||||
RUN_ALL_TESTS: ${{ needs.impact-analysis.outputs.run-all }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -152,12 +158,12 @@ jobs:
|
||||
'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: '24.13.0'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
@@ -166,7 +172,7 @@ jobs:
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm and Next.js cache
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.STORE_PATH }}
|
||||
@@ -186,7 +192,7 @@ jobs:
|
||||
run: pnpm run build
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
@@ -273,7 +279,14 @@ jobs:
|
||||
needs.impact-analysis.outputs.has-ui-e2e != 'true' &&
|
||||
needs.impact-analysis.outputs.run-all != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: No E2E tests needed
|
||||
run: |
|
||||
echo "## E2E Tests Skipped" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -29,15 +29,27 @@ jobs:
|
||||
working-directory: ./ui
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
registry.npmjs.org:443
|
||||
fonts.googleapis.com:443
|
||||
fonts.gstatic.com:443
|
||||
api.github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# zizmor: ignore[artipacked]
|
||||
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
|
||||
|
||||
- name: Check for UI changes
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
ui/**
|
||||
@@ -50,7 +62,7 @@ jobs:
|
||||
- name: Get changed source files for targeted tests
|
||||
id: changed-source
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
ui/**/*.ts
|
||||
@@ -66,7 +78,7 @@ jobs:
|
||||
- name: Check for critical path changes (run all tests)
|
||||
id: critical-changes
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
uses: tj-actions/changed-files@7dee1b0c1557f278e5c7dc244927139d78c0e22a # v47.0.4
|
||||
with:
|
||||
files: |
|
||||
ui/lib/**
|
||||
@@ -78,13 +90,13 @@ jobs:
|
||||
|
||||
- name: Setup Node.js ${{ env.NODE_VERSION }}
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Setup pnpm
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
@@ -96,7 +108,7 @@ jobs:
|
||||
|
||||
- name: Setup pnpm and Next.js cache
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.STORE_PATH }}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
rules:
|
||||
secrets-outside-env:
|
||||
ignore:
|
||||
- api-bump-version.yml
|
||||
- api-container-build-push.yml
|
||||
- api-tests.yml
|
||||
- backport.yml
|
||||
- docs-bump-version.yml
|
||||
- issue-triage.lock.yml
|
||||
- mcp-container-build-push.yml
|
||||
- pr-merged.yml
|
||||
- prepare-release.yml
|
||||
- sdk-bump-version.yml
|
||||
- sdk-container-build-push.yml
|
||||
- sdk-refresh-aws-services-regions.yml
|
||||
- sdk-refresh-oci-regions.yml
|
||||
- sdk-tests.yml
|
||||
- ui-bump-version.yml
|
||||
- ui-container-build-push.yml
|
||||
- ui-e2e-tests-v2.yml
|
||||
superfluous-actions:
|
||||
ignore:
|
||||
- pr-check-changelog.yml
|
||||
- pr-conflict-checker.yml
|
||||
- prepare-release.yml
|
||||
@@ -163,3 +163,6 @@ GEMINI.md
|
||||
.codex/skills
|
||||
.github/skills
|
||||
.gemini/skills
|
||||
|
||||
# Claude Code
|
||||
.claude/*
|
||||
|
||||
@@ -22,6 +22,13 @@ repos:
|
||||
args: [--autofix]
|
||||
files: pyproject.toml
|
||||
|
||||
## GITHUB ACTIONS
|
||||
- repo: https://github.com/zizmorcore/zizmor-pre-commit
|
||||
rev: v1.6.0
|
||||
hooks:
|
||||
- id: zizmor
|
||||
files: ^\.github/
|
||||
|
||||
## BASH
|
||||
- repo: https://github.com/koalaman/shellcheck-precommit
|
||||
rev: v0.10.0
|
||||
@@ -120,7 +127,8 @@ repos:
|
||||
description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities"
|
||||
# TODO: Botocore needs urllib3 1.X so we need to ignore these vulnerabilities 77744,77745. Remove this once we upgrade to urllib3 2.X
|
||||
# TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0
|
||||
entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027'
|
||||
# TODO: 86217 because `alibabacloud-tea-openapi == 0.4.3` don't let us upgrade `cryptography >= 46.0.0`
|
||||
entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027,86217'
|
||||
language: system
|
||||
|
||||
- id: vulture
|
||||
|
||||
@@ -46,6 +46,8 @@ Use these skills for detailed patterns on-demand:
|
||||
| `prowler-commit` | Professional commits (conventional-commits) | [SKILL.md](skills/prowler-commit/SKILL.md) |
|
||||
| `prowler-pr` | Pull request conventions | [SKILL.md](skills/prowler-pr/SKILL.md) |
|
||||
| `prowler-docs` | Documentation style guide | [SKILL.md](skills/prowler-docs/SKILL.md) |
|
||||
| `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) |
|
||||
| `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) |
|
||||
| `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) |
|
||||
| `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) |
|
||||
| `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) |
|
||||
@@ -85,15 +87,15 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Fixing bug | `tdd` |
|
||||
| General Prowler development questions | `prowler` |
|
||||
| Implementing JSON:API endpoints | `django-drf` |
|
||||
| Importing Copilot Custom Agents into workflows | `gh-aw` |
|
||||
| Implementing feature | `tdd` |
|
||||
| Importing Copilot Custom Agents into workflows | `gh-aw` |
|
||||
| Inspect PR CI checks and gates (.github/workflows/*) | `prowler-ci` |
|
||||
| Inspect PR CI workflows (.github/workflows/*): conventional-commit, pr-check-changelog, pr-conflict-checker, labeler | `prowler-pr` |
|
||||
| Mapping checks to compliance controls | `prowler-compliance` |
|
||||
| Mocking AWS with moto in tests | `prowler-test-sdk` |
|
||||
| Modifying API responses | `jsonapi` |
|
||||
| Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` |
|
||||
| Modifying component | `tdd` |
|
||||
| Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` |
|
||||
| Refactoring code | `tdd` |
|
||||
| Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` |
|
||||
| Review PR requirements: template, title conventions, changelog gate | `prowler-pr` |
|
||||
@@ -138,7 +140,7 @@ Prowler is an open-source cloud security assessment tool supporting AWS, Azure,
|
||||
|
||||
| Component | Location | Tech Stack |
|
||||
|-----------|----------|------------|
|
||||
| SDK | `prowler/` | Python 3.9+, Poetry |
|
||||
| SDK | `prowler/` | Python 3.10+, Poetry |
|
||||
| API | `api/` | Django 5.1, DRF, Celery |
|
||||
| UI | `ui/` | Next.js 15, React 19, Tailwind 4 |
|
||||
| MCP Server | `mcp_server/` | FastMCP, Python 3.12+ |
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.12.11-slim-bookworm AS build
|
||||
FROM python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7 AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/prowler"
|
||||
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
|
||||
|
||||
@@ -241,7 +241,7 @@ pnpm start
|
||||
|
||||
## Prowler CLI
|
||||
### Pip package
|
||||
Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/). Consequently, it can be installed using pip with Python >3.9.1, <3.13:
|
||||
Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler-cloud/). Consequently, it can be installed using pip with Python >=3.10, <3.13:
|
||||
|
||||
```console
|
||||
pip install prowler
|
||||
@@ -273,7 +273,7 @@ The container images are available here:
|
||||
|
||||
### From GitHub
|
||||
|
||||
Python >3.9.1, <3.13 is required with pip and Poetry:
|
||||
Python >=3.10, <3.13 is required with pip and Poetry:
|
||||
|
||||
``` console
|
||||
git clone https://github.com/prowler-cloud/prowler
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
> - [`prowler-api`](../skills/prowler-api/SKILL.md) - Models, Serializers, Views, RLS patterns
|
||||
> - [`prowler-test-api`](../skills/prowler-test-api/SKILL.md) - Testing patterns (pytest-django)
|
||||
> - [`prowler-attack-paths-query`](../skills/prowler-attack-paths-query/SKILL.md) - Attack Paths openCypher queries
|
||||
> - [`django-migration-psql`](../skills/django-migration-psql/SKILL.md) - Migration best practices for PostgreSQL
|
||||
> - [`postgresql-indexing`](../skills/postgresql-indexing/SKILL.md) - PostgreSQL indexing, EXPLAIN, monitoring, maintenance
|
||||
> - [`django-drf`](../skills/django-drf/SKILL.md) - Generic DRF patterns
|
||||
> - [`jsonapi`](../skills/jsonapi/SKILL.md) - Strict JSON:API v1.1 spec compliance
|
||||
> - [`pytest`](../skills/pytest/SKILL.md) - Generic pytest patterns
|
||||
@@ -16,14 +18,20 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
|--------|-------|
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| Adding DRF pagination or permissions | `django-drf` |
|
||||
| Adding indexes or constraints to database tables | `django-migration-psql` |
|
||||
| Adding privilege escalation detection queries | `prowler-attack-paths-query` |
|
||||
| Analyzing query performance with EXPLAIN | `postgresql-indexing` |
|
||||
| Committing changes | `prowler-commit` |
|
||||
| Create PR that requires changelog entry | `prowler-changelog` |
|
||||
| Creating API endpoints | `jsonapi` |
|
||||
| Creating Attack Paths queries | `prowler-attack-paths-query` |
|
||||
| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating or modifying PostgreSQL indexes | `postgresql-indexing` |
|
||||
| Creating or reviewing Django migrations | `django-migration-psql` |
|
||||
| Creating/modifying models, views, serializers | `prowler-api` |
|
||||
| Debugging slow queries or missing indexes | `postgresql-indexing` |
|
||||
| Dropping or reindexing PostgreSQL indexes | `postgresql-indexing` |
|
||||
| Fixing bug | `tdd` |
|
||||
| Implementing JSON:API endpoints | `django-drf` |
|
||||
| Implementing feature | `tdd` |
|
||||
@@ -32,12 +40,14 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Refactoring code | `tdd` |
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Reviewing JSON:API compliance | `jsonapi` |
|
||||
| Running makemigrations or pgmakemigrations | `django-migration-psql` |
|
||||
| Testing RLS tenant isolation | `prowler-test-api` |
|
||||
| Update CHANGELOG.md in any component | `prowler-changelog` |
|
||||
| Updating existing Attack Paths queries | `prowler-attack-paths-query` |
|
||||
| Working on task | `tdd` |
|
||||
| Writing Prowler API tests | `prowler-test-api` |
|
||||
| Writing Python tests with pytest | `pytest` |
|
||||
| Writing data backfill or data migration | `django-migration-psql` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+81
-6
@@ -2,20 +2,95 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.21.0] (Prowler UNRELEASED)
|
||||
## [1.24.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `VALKEY_SCHEME`, `VALKEY_USERNAME`, and `VALKEY_PASSWORD` environment variables to configure Celery broker TLS/auth connection details for Valkey/ElastiCache [(#10420)](https://github.com/prowler-cloud/prowler/pull/10420)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Attack Paths: Periodic cleanup of stale scans with dead-worker detection via Celery inspect, marking orphaned `EXECUTING` scans as `FAILED` and recovering `graph_data_ready` [(#10387)](https://github.com/prowler-cloud/prowler/pull/10387)
|
||||
- Attack Paths: Replace `_provider_id` property with `_Provider_{uuid}` label for provider isolation, add regex-based label injection for custom queries [(#10402)](https://github.com/prowler-cloud/prowler/pull/10402)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Finding groups list/latest now apply computed status/severity filters and finding-level prefilters (delta, region, service, category, resource group, scan, resource type), plus `check_title` support for sort/filter consistency [(#10428)](https://github.com/prowler-cloud/prowler/pull/10428)
|
||||
- Populate compliance data inside `check_metadata` for findings, which was always returned as `null` [(#10449)](https://github.com/prowler-cloud/prowler/pull/10449)
|
||||
- 403 error for admin users listing tenants due to roles query not using the admin database connection [(#10460)](https://github.com/prowler-cloud/prowler/pull/10460)
|
||||
- Filter transient Neo4j defunct connection logs in Sentry `before_send` to suppress false-positive alerts handled by `RetryableSession` retries [(#10452)](https://github.com/prowler-cloud/prowler/pull/10452)
|
||||
- `MANAGE_ACCOUNT` permission no longer required for listing and creating tenants [(#10468)](https://github.com/prowler-cloud/prowler/pull/10468)
|
||||
- Finding groups muted filter, counters, metadata extraction and mute reaggregation [(#10477)](https://github.com/prowler-cloud/prowler/pull/10477)
|
||||
- Finding groups `check_title__icontains` resolution, `name__icontains` resource filter and `resource_group` field in `/resources` response [(#10486)](https://github.com/prowler-cloud/prowler/pull/10486)
|
||||
- Membership `post_delete` signal using raw FK ids to avoid `DoesNotExist` during cascade deletions [(#10497)](https://github.com/prowler-cloud/prowler/pull/10497)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Pin all unpinned dependencies to exact versions to prevent supply chain attacks and ensure reproducible builds [(#10469)](https://github.com/prowler-cloud/prowler/pull/10469)
|
||||
|
||||
---
|
||||
|
||||
## [1.23.0] (Prowler v5.22.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Finding groups support `check_title` substring filtering [(#10377)](https://github.com/prowler-cloud/prowler/pull/10377)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Finding groups latest endpoint now aggregates the latest snapshot per provider before check-level totals, keeping impacted resources aligned across providers [(#10419)](https://github.com/prowler-cloud/prowler/pull/10419)
|
||||
- Mute rule creation now triggers finding-group summary re-aggregation after historical muting, keeping stats in sync after mute operations [(#10419)](https://github.com/prowler-cloud/prowler/pull/10419)
|
||||
- Attack Paths: Deduplicate nodes before ProwlerFinding lookup in Attack Paths Cypher queries, reducing execution time [(#10424)](https://github.com/prowler-cloud/prowler/pull/10424)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Replace stdlib XML parser with `defusedxml` in SAML metadata parsing to prevent XML bomb (billion laughs) DoS attacks [(#10165)](https://github.com/prowler-cloud/prowler/pull/10165)
|
||||
- Bump `flask` to 3.1.3 (CVE-2026-27205) and `werkzeug` to 3.1.6 (CVE-2026-27199) [(#10430)](https://github.com/prowler-cloud/prowler/pull/10430)
|
||||
|
||||
---
|
||||
|
||||
## [1.22.1] (Prowler v5.21.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Threat score aggregation query to eliminate unnecessary JOINs and `COUNT(DISTINCT)` overhead [(#10394)](https://github.com/prowler-cloud/prowler/pull/10394)
|
||||
|
||||
---
|
||||
|
||||
## [1.22.0] (Prowler v5.21.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `CORS_ALLOWED_ORIGINS` configurable via environment variable [(#10355)](https://github.com/prowler-cloud/prowler/pull/10355)
|
||||
- Attack Paths: Tenant and provider related labels to the nodes so they can be easily filtered on custom queries [(#10308)](https://github.com/prowler-cloud/prowler/pull/10308)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Attack Paths: Complete migration to private graph labels and properties, removing deprecated dual-write support [(#10268)](https://github.com/prowler-cloud/prowler/pull/10268)
|
||||
- Attack Paths: Reduce sync and findings memory usage with smaller batches, cursor iteration, and sequential sessions [(#10359)](https://github.com/prowler-cloud/prowler/pull/10359)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Attack Paths: Recover `graph_data_ready` flag when scan fails during graph swap, preventing query endpoints from staying blocked until the next successful scan [(#10354)](https://github.com/prowler-cloud/prowler/pull/10354)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Use `psycopg2.sql` to safely compose DDL in `PostgresEnumMigration`, preventing SQL injection via f-string interpolation [(#10166)](https://github.com/prowler-cloud/prowler/pull/10166)
|
||||
- Replace stdlib XML parser with `defusedxml` in SAML metadata parsing to prevent XML bomb (billion laughs) DoS attacks [(#10165)](https://github.com/prowler-cloud/prowler/pull/10165)
|
||||
|
||||
---
|
||||
|
||||
## [1.21.0] (Prowler v5.20.0)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Attack Paths: Migrate network exposure queries from APOC to standard openCypher for Neo4j and Neptune compatibility [(#10266)](https://github.com/prowler-cloud/prowler/pull/10266)
|
||||
- `POST /api/v1/providers` returns `409 Conflict` if already exists [(#10293)](https://github.com/prowler-cloud/prowler/pull/10293)
|
||||
|
||||
---
|
||||
|
||||
## [1.20.1] (Prowler UNRELEASED)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Attack Paths: Add missing logging for query execution and exception details in scan error handling [(#10269)](https://github.com/prowler-cloud/prowler/pull/10269)
|
||||
- Attack Paths: Security hardening for custom query endpoint (Cypher blocklist, input validation, rate limiting, Helm lockdown) [(#10238)](https://github.com/prowler-cloud/prowler/pull/10238)
|
||||
- Attack Paths: Missing logging for query execution and exception details in scan error handling [(#10269)](https://github.com/prowler-cloud/prowler/pull/10269)
|
||||
- Attack Paths: Upgrade Cartography from 0.129.0 to 0.132.0, fixing `exposed_internet` not set on ELB/ELBv2 nodes [(#10272)](https://github.com/prowler-cloud/prowler/pull/10272)
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.12.10-slim-bookworm AS build
|
||||
FROM python:3.12.10-slim-bookworm@sha256:fd95fa221297a88e1cf49c55ec1828edd7c5a428187e67b5d1805692d11588db AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/api"
|
||||
|
||||
|
||||
@@ -30,9 +30,28 @@ start_prod_server() {
|
||||
poetry run gunicorn -c config/guniconf.py config.wsgi:application
|
||||
}
|
||||
|
||||
resolve_worker_hostname() {
|
||||
TASK_ID=""
|
||||
|
||||
if [ -n "$ECS_CONTAINER_METADATA_URI_V4" ]; then
|
||||
TASK_ID=$(wget -qO- --timeout=2 "${ECS_CONTAINER_METADATA_URI_V4}/task" | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin)['TaskARN'].split('/')[-1])" 2>/dev/null)
|
||||
fi
|
||||
|
||||
if [ -z "$TASK_ID" ]; then
|
||||
TASK_ID=$(python3 -c "import uuid; print(uuid.uuid4().hex)")
|
||||
fi
|
||||
|
||||
echo "${TASK_ID}@$(hostname)"
|
||||
}
|
||||
|
||||
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,overview,integrations,compliance,attack-paths-scans -E --max-tasks-per-child 1
|
||||
poetry run python -m celery -A config.celery worker \
|
||||
-n "$(resolve_worker_hostname)" \
|
||||
-l "${DJANGO_LOGGING_LEVEL:-info}" \
|
||||
-Q celery,scans,scan-reports,deletion,backfill,overview,integrations,compliance,attack-paths-scans \
|
||||
-E --max-tasks-per-child 1
|
||||
}
|
||||
|
||||
start_worker_beat() {
|
||||
|
||||
Generated
+118
-128
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "about-time"
|
||||
@@ -2469,22 +2469,18 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
|
||||
|
||||
[[package]]
|
||||
name = "cron-descriptor"
|
||||
version = "2.0.6"
|
||||
version = "1.4.5"
|
||||
description = "A Python library that converts cron expressions into human readable strings."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "cron_descriptor-2.0.6-py3-none-any.whl", hash = "sha256:3a1c0d837c0e5a32e415f821b36cf758eb92d510e6beff8fbfe4fa16573d93d6"},
|
||||
{file = "cron_descriptor-2.0.6.tar.gz", hash = "sha256:e39d2848e1d8913cfb6e3452e701b5eec662ee18bea8cc5aa53ee1a7bb217157"},
|
||||
{file = "cron_descriptor-1.4.5-py3-none-any.whl", hash = "sha256:736b3ae9d1a99bc3dbfc5b55b5e6e7c12031e7ba5de716625772f8b02dcd6013"},
|
||||
{file = "cron_descriptor-1.4.5.tar.gz", hash = "sha256:f51ce4ffc1d1f2816939add8524f206c376a42c87a5fca3091ce26725b3b1bca"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing_extensions = "*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["mypy", "polib", "ruff"]
|
||||
test = ["pytest"]
|
||||
dev = ["polib"]
|
||||
|
||||
[[package]]
|
||||
name = "crowdstrike-falconpy"
|
||||
@@ -2700,23 +2696,17 @@ files = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deprecated"
|
||||
version = "1.3.1"
|
||||
description = "Python @deprecated decorator to deprecate old python classes, functions or methods."
|
||||
name = "defusedxml"
|
||||
version = "0.7.1"
|
||||
description = "XML bomb protection for Python stdlib modules"
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f"},
|
||||
{file = "deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223"},
|
||||
{file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"},
|
||||
{file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
wrapt = ">=1.10,<3"
|
||||
|
||||
[package.extras]
|
||||
dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools ; python_version >= \"3.12\"", "tox"]
|
||||
|
||||
[[package]]
|
||||
name = "detect-secrets"
|
||||
version = "1.5.0"
|
||||
@@ -2807,14 +2797,14 @@ bcrypt = ["bcrypt"]
|
||||
|
||||
[[package]]
|
||||
name = "django-allauth"
|
||||
version = "65.14.0"
|
||||
version = "65.15.0"
|
||||
description = "Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "django_allauth-65.14.0-py3-none-any.whl", hash = "sha256:448f5f7877f95fcbe1657256510fe7822d7871f202521a29e23ef937f3325a97"},
|
||||
{file = "django_allauth-65.14.0.tar.gz", hash = "sha256:5529227aba2b1377d900e9274a3f24496c645e65400fbae3cad5789944bc4d0b"},
|
||||
{file = "django_allauth-65.15.0-py3-none-any.whl", hash = "sha256:ad9fc49c49a9368eaa5bb95456b76e2a4f377b3c6862ee8443507816578c098d"},
|
||||
{file = "django_allauth-65.15.0.tar.gz", hash = "sha256:b404d48cf0c3ee14dacc834c541f30adedba2ff1c433980ecc494d6cb0b395a8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2837,20 +2827,20 @@ steam = ["python3-openid (>=3.0.8,<4)"]
|
||||
|
||||
[[package]]
|
||||
name = "django-celery-beat"
|
||||
version = "2.8.1"
|
||||
version = "2.9.0"
|
||||
description = "Database-backed Periodic Tasks."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "django_celery_beat-2.8.1-py3-none-any.whl", hash = "sha256:da2b1c6939495c05a551717509d6e3b79444e114a027f7b77bf3727c2a39d171"},
|
||||
{file = "django_celery_beat-2.8.1.tar.gz", hash = "sha256:dfad0201c0ac50c91a34700ef8fa0a10ee098cc7f3375fe5debed79f2204f80a"},
|
||||
{file = "django_celery_beat-2.9.0-py3-none-any.whl", hash = "sha256:4a9e5ebe26d6f8d7215e1fc5c46e466016279dc102435a28141108649bdf2157"},
|
||||
{file = "django_celery_beat-2.9.0.tar.gz", hash = "sha256:92404650f52fcb44cf08e2b09635cb1558327c54b1a5d570f0e2d3a22130934c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
celery = ">=5.2.3,<6.0"
|
||||
cron-descriptor = ">=1.2.32"
|
||||
Django = ">=2.2,<6.0"
|
||||
cron-descriptor = ">=1.2.32,<2.0.0"
|
||||
Django = ">=2.2,<6.1"
|
||||
django-timezone-field = ">=5.0"
|
||||
python-crontab = ">=2.3.4"
|
||||
tzdata = "*"
|
||||
@@ -2971,7 +2961,7 @@ files = [
|
||||
[package.dependencies]
|
||||
autopep8 = "*"
|
||||
Django = ">=4.2"
|
||||
gprof2dot = ">=2017.09.19"
|
||||
gprof2dot = ">=2017.9.19"
|
||||
sqlparse = "*"
|
||||
|
||||
[[package]]
|
||||
@@ -3358,14 +3348,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.2"
|
||||
version = "3.1.3"
|
||||
description = "A simple framework for building complex web applications."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c"},
|
||||
{file = "flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87"},
|
||||
{file = "flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c"},
|
||||
{file = "flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -3382,62 +3372,62 @@ dotenv = ["python-dotenv"]
|
||||
|
||||
[[package]]
|
||||
name = "fonttools"
|
||||
version = "4.61.1"
|
||||
version = "4.62.1"
|
||||
description = "Tools to manipulate font files"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "fonttools-4.61.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24"},
|
||||
{file = "fonttools-4.61.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958"},
|
||||
{file = "fonttools-4.61.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da"},
|
||||
{file = "fonttools-4.61.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6"},
|
||||
{file = "fonttools-4.61.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1"},
|
||||
{file = "fonttools-4.61.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881"},
|
||||
{file = "fonttools-4.61.1-cp310-cp310-win32.whl", hash = "sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47"},
|
||||
{file = "fonttools-4.61.1-cp310-cp310-win_amd64.whl", hash = "sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6"},
|
||||
{file = "fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09"},
|
||||
{file = "fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37"},
|
||||
{file = "fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb"},
|
||||
{file = "fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9"},
|
||||
{file = "fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87"},
|
||||
{file = "fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56"},
|
||||
{file = "fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a"},
|
||||
{file = "fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7"},
|
||||
{file = "fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e"},
|
||||
{file = "fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2"},
|
||||
{file = "fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796"},
|
||||
{file = "fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d"},
|
||||
{file = "fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8"},
|
||||
{file = "fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0"},
|
||||
{file = "fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261"},
|
||||
{file = "fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9"},
|
||||
{file = "fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c"},
|
||||
{file = "fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e"},
|
||||
{file = "fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5"},
|
||||
{file = "fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd"},
|
||||
{file = "fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3"},
|
||||
{file = "fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d"},
|
||||
{file = "fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c"},
|
||||
{file = "fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b"},
|
||||
{file = "fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd"},
|
||||
{file = "fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e"},
|
||||
{file = "fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c"},
|
||||
{file = "fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75"},
|
||||
{file = "fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063"},
|
||||
{file = "fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2"},
|
||||
{file = "fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c"},
|
||||
{file = "fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c"},
|
||||
{file = "fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa"},
|
||||
{file = "fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91"},
|
||||
{file = "fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19"},
|
||||
{file = "fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba"},
|
||||
{file = "fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7"},
|
||||
{file = "fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118"},
|
||||
{file = "fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5"},
|
||||
{file = "fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b"},
|
||||
{file = "fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371"},
|
||||
{file = "fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69"},
|
||||
{file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c"},
|
||||
{file = "fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a"},
|
||||
{file = "fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3"},
|
||||
{file = "fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23"},
|
||||
{file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d"},
|
||||
{file = "fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae"},
|
||||
{file = "fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed"},
|
||||
{file = "fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9"},
|
||||
{file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7"},
|
||||
{file = "fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14"},
|
||||
{file = "fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7"},
|
||||
{file = "fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b"},
|
||||
{file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1"},
|
||||
{file = "fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416"},
|
||||
{file = "fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53"},
|
||||
{file = "fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2"},
|
||||
{file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974"},
|
||||
{file = "fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9"},
|
||||
{file = "fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936"},
|
||||
{file = "fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392"},
|
||||
{file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04"},
|
||||
{file = "fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d"},
|
||||
{file = "fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c"},
|
||||
{file = "fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42"},
|
||||
{file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79"},
|
||||
{file = "fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe"},
|
||||
{file = "fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68"},
|
||||
{file = "fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1"},
|
||||
{file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069"},
|
||||
{file = "fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9"},
|
||||
{file = "fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24"},
|
||||
{file = "fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056"},
|
||||
{file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca"},
|
||||
{file = "fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca"},
|
||||
{file = "fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782"},
|
||||
{file = "fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae"},
|
||||
{file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7"},
|
||||
{file = "fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a"},
|
||||
{file = "fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800"},
|
||||
{file = "fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e"},
|
||||
{file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82"},
|
||||
{file = "fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260"},
|
||||
{file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4"},
|
||||
{file = "fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b"},
|
||||
{file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87"},
|
||||
{file = "fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c"},
|
||||
{file = "fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a"},
|
||||
{file = "fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e"},
|
||||
{file = "fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd"},
|
||||
{file = "fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -4579,7 +4569,7 @@ files = [
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=22.2.0"
|
||||
jsonschema-specifications = ">=2023.03.6"
|
||||
jsonschema-specifications = ">=2023.3.6"
|
||||
referencing = ">=0.28.4"
|
||||
rpds-py = ">=0.7.1"
|
||||
|
||||
@@ -4787,7 +4777,7 @@ librabbitmq = ["librabbitmq (>=2.0.0) ; python_version < \"3.11\""]
|
||||
mongodb = ["pymongo (==4.15.3)"]
|
||||
msgpack = ["msgpack (==1.1.2)"]
|
||||
pyro = ["pyro4 (==4.82)"]
|
||||
qpid = ["qpid-python (==1.36.0-1)", "qpid-tools (==1.36.0-1)"]
|
||||
qpid = ["qpid-python (==1.36.0.post1)", "qpid-tools (==1.36.0.post1)"]
|
||||
redis = ["redis (>=4.5.2,!=4.5.5,!=5.0.2,<6.5)"]
|
||||
slmq = ["softlayer_messaging (>=1.0.3)"]
|
||||
sqlalchemy = ["sqlalchemy (>=1.4.48,<2.1)"]
|
||||
@@ -4808,7 +4798,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
certifi = ">=14.05.14"
|
||||
certifi = ">=14.5.14"
|
||||
durationpy = ">=0.7"
|
||||
google-auth = ">=1.0.1"
|
||||
oauthlib = ">=3.2.2"
|
||||
@@ -5052,18 +5042,18 @@ tests = ["psutil", "pytest (!=3.3.0)", "pytest-cov"]
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "3.9"
|
||||
version = "3.10.2"
|
||||
description = "Python implementation of John Gruber's Markdown."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280"},
|
||||
{file = "markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a"},
|
||||
{file = "markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36"},
|
||||
{file = "markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
|
||||
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python] (>=0.28.3)"]
|
||||
testing = ["coverage", "pyyaml"]
|
||||
|
||||
[[package]]
|
||||
@@ -5837,14 +5827,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "nltk"
|
||||
version = "3.9.2"
|
||||
version = "3.9.4"
|
||||
description = "Natural Language Toolkit"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "nltk-3.9.2-py3-none-any.whl", hash = "sha256:1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a"},
|
||||
{file = "nltk-3.9.2.tar.gz", hash = "sha256:0f409e9b069ca4177c1903c3e843eef90c7e92992fa4931ae607da6de49e1419"},
|
||||
{file = "nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f"},
|
||||
{file = "nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6642,10 +6632,10 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.19.0"
|
||||
version = "5.23.0"
|
||||
description = "Prowler is an Open Source security tool to perform AWS, GCP and Azure security best practices assessments, audits, incident response, continuous monitoring, hardening and forensics readiness. It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, FedRAMP, PCI-DSS, GDPR, HIPAA, FFIEC, SOC2, GXP, AWS Well-Architected Framework Security Pillar, AWS Foundational Technical Review (FTR), ENS (Spanish National Security Scheme) and your custom security frameworks."
|
||||
optional = false
|
||||
python-versions = ">3.9.1,<3.13"
|
||||
python-versions = ">=3.10,<3.13"
|
||||
groups = ["main"]
|
||||
files = []
|
||||
develop = false
|
||||
@@ -6700,6 +6690,7 @@ colorama = "0.4.6"
|
||||
cryptography = "44.0.3"
|
||||
dash = "3.1.1"
|
||||
dash-bootstrap-components = "2.0.3"
|
||||
defusedxml = ">=0.7.1"
|
||||
detect-secrets = "1.5.0"
|
||||
dulwich = "0.23.0"
|
||||
google-api-python-client = "2.163.0"
|
||||
@@ -6707,7 +6698,7 @@ google-auth-httplib2 = ">=0.1,<0.3"
|
||||
h2 = "4.3.0"
|
||||
jsonschema = "4.23.0"
|
||||
kubernetes = "32.0.1"
|
||||
markdown = "3.9.0"
|
||||
markdown = "3.10.2"
|
||||
microsoft-kiota-abstractions = "1.9.2"
|
||||
msgraph-sdk = "1.23.0"
|
||||
numpy = "2.0.2"
|
||||
@@ -6717,7 +6708,7 @@ pandas = "2.2.3"
|
||||
py-iam-expand = "0.1.0"
|
||||
py-ocsf-models = "0.8.1"
|
||||
pydantic = ">=2.0,<3.0"
|
||||
pygithub = "2.5.0"
|
||||
pygithub = "2.8.0"
|
||||
python-dateutil = ">=2.9.0.post0,<3.0.0"
|
||||
pytz = "2025.1"
|
||||
schema = "0.7.5"
|
||||
@@ -6725,12 +6716,13 @@ shodan = "1.31.0"
|
||||
slack-sdk = "3.39.0"
|
||||
tabulate = "0.9.0"
|
||||
tzlocal = "5.3.1"
|
||||
uuid6 = "2024.7.10"
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/prowler-cloud/prowler.git"
|
||||
reference = "master"
|
||||
resolved_reference = "6962622fd21401886371add25463f77228cd9c1f"
|
||||
resolved_reference = "2ddd5b3091bcdd8c7d44aba73b13c5c6f8f99e35"
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
@@ -7103,22 +7095,21 @@ typing-extensions = ">=4.14.1"
|
||||
|
||||
[[package]]
|
||||
name = "pygithub"
|
||||
version = "2.5.0"
|
||||
version = "2.8.0"
|
||||
description = "Use the full Github API v3"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "PyGithub-2.5.0-py3-none-any.whl", hash = "sha256:b0b635999a658ab8e08720bdd3318893ff20e2275f6446fcf35bf3f44f2c0fd2"},
|
||||
{file = "pygithub-2.5.0.tar.gz", hash = "sha256:e1613ac508a9be710920d26eb18b1905ebd9926aa49398e88151c1b526aad3cf"},
|
||||
{file = "pygithub-2.8.0-py3-none-any.whl", hash = "sha256:11a3473c1c2f1c39c525d0ee8c559f369c6d46c272cb7321c9b0cabc7aa1ce7d"},
|
||||
{file = "pygithub-2.8.0.tar.gz", hash = "sha256:72f5f2677d86bc3a8843aa720c6ce4c1c42fb7500243b136e3d5e14ddb5c3386"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
Deprecated = "*"
|
||||
pyjwt = {version = ">=2.4.0", extras = ["crypto"]}
|
||||
pynacl = ">=1.4.0"
|
||||
requests = ">=2.14.0"
|
||||
typing-extensions = ">=4.0.0"
|
||||
typing-extensions = ">=4.5.0"
|
||||
urllib3 = ">=1.26.0"
|
||||
|
||||
[[package]]
|
||||
@@ -7170,7 +7161,7 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
astroid = ">=3.2.2,<=3.3.0-dev0"
|
||||
astroid = ">=3.2.2,<=3.3.0.dev0"
|
||||
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
|
||||
dill = [
|
||||
{version = ">=0.3.7", markers = "python_version >= \"3.12\""},
|
||||
@@ -7354,14 +7345,14 @@ dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments
|
||||
|
||||
[[package]]
|
||||
name = "pytest-celery"
|
||||
version = "1.2.1"
|
||||
version = "1.3.0"
|
||||
description = "Pytest plugin for Celery"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.8"
|
||||
python-versions = "<4.0,>=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pytest_celery-1.2.1-py3-none-any.whl", hash = "sha256:0441ab0c2a712b775be16ffda3d7deb31995fd7b5e9d71630e7ea98b474346a3"},
|
||||
{file = "pytest_celery-1.2.1.tar.gz", hash = "sha256:7873fb3cf4fbfe9b0dd15d359bdb8bbab4a41c7e48f5b0adb7d36138d3704d52"},
|
||||
{file = "pytest_celery-1.3.0-py3-none-any.whl", hash = "sha256:f02201d7770584a0c412a1ded329a142170c24012467c7046f2c72cc8205ad5d"},
|
||||
{file = "pytest_celery-1.3.0.tar.gz", hash = "sha256:bd9e5b0f594ec5de9ab97cf27e3a11c644718a761bab6b997d01800fd7394f64"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -7372,14 +7363,13 @@ kombu = "*"
|
||||
psutil = ">=7.0.0"
|
||||
pytest-docker-tools = ">=3.1.3"
|
||||
redis = {version = "*", optional = true, markers = "extra == \"all\" or extra == \"redis\""}
|
||||
setuptools = {version = ">=75.8.0", markers = "python_version >= \"3.9\" and python_version < \"4.0\""}
|
||||
tenacity = ">=9.0.0"
|
||||
|
||||
[package.extras]
|
||||
all = ["boto3", "botocore", "python-memcached", "redis", "urllib3 (>=1.26.16,<2.0)"]
|
||||
all = ["boto3", "botocore", "pycurl (>=7.43) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\"", "python-memcached", "redis", "urllib3 (>=1.26.16,<2.0)"]
|
||||
memcached = ["python-memcached"]
|
||||
redis = ["redis"]
|
||||
sqs = ["boto3", "botocore", "urllib3 (>=1.26.16,<2.0)"]
|
||||
sqs = ["boto3", "botocore", "pycurl (>=7.43) ; sys_platform != \"win32\" and platform_python_implementation == \"CPython\"", "urllib3 (>=1.26.16,<2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
@@ -7864,14 +7854,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "reportlab"
|
||||
version = "4.4.9"
|
||||
version = "4.4.10"
|
||||
description = "The Reportlab Toolkit"
|
||||
optional = false
|
||||
python-versions = "<4,>=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "reportlab-4.4.9-py3-none-any.whl", hash = "sha256:68e2d103ae8041a37714e8896ec9b79a1c1e911d68c3bd2ea17546568cf17bfd"},
|
||||
{file = "reportlab-4.4.9.tar.gz", hash = "sha256:7cf487764294ee791a4781f5a157bebce262a666ae4bbb87786760a9676c9378"},
|
||||
{file = "reportlab-4.4.10-py3-none-any.whl", hash = "sha256:5abc815746ae2bc44e7ff25db96814f921349ca814c992c7eac3c26029bf7c24"},
|
||||
{file = "reportlab-4.4.10.tar.gz", hash = "sha256:5cbbb34ac3546039d0086deb2938cdec06b12da3cdb836e813258eb33cd28487"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -8184,10 +8174,10 @@ files = [
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.37.4,<2.0a.0"
|
||||
botocore = ">=1.37.4,<2.0a0"
|
||||
|
||||
[package.extras]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
|
||||
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
|
||||
|
||||
[[package]]
|
||||
name = "safety"
|
||||
@@ -8293,14 +8283,14 @@ contextlib2 = ">=0.5.5"
|
||||
|
||||
[[package]]
|
||||
name = "sentry-sdk"
|
||||
version = "2.51.0"
|
||||
version = "2.56.0"
|
||||
description = "Python client for Sentry (https://sentry.io)"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "sentry_sdk-2.51.0-py2.py3-none-any.whl", hash = "sha256:e21016d318a097c2b617bb980afd9fc737e1efc55f9b4f0cdc819982c9717d5f"},
|
||||
{file = "sentry_sdk-2.51.0.tar.gz", hash = "sha256:b89d64577075fd8c13088bc3609a2ce77a154e5beb8cba7cc16560b0539df4f7"},
|
||||
{file = "sentry_sdk-2.56.0-py2.py3-none-any.whl", hash = "sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02"},
|
||||
{file = "sentry_sdk-2.56.0.tar.gz", hash = "sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -8773,14 +8763,14 @@ test = ["pytest", "websockets"]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.5"
|
||||
version = "3.1.7"
|
||||
description = "The comprehensive WSGI web application library."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"},
|
||||
{file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"},
|
||||
{file = "werkzeug-3.1.7-py3-none-any.whl", hash = "sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f"},
|
||||
{file = "werkzeug-3.1.7.tar.gz", hash = "sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -9382,4 +9372,4 @@ files = [
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11,<3.13"
|
||||
content-hash = "6e38c38b1f8dc05b881f49703fa445eec299527e6697992b18e4613534fbcdb6"
|
||||
content-hash = "167d4549788b8bc8bb7772b9a81ade1eab73d8f354251a8d6af4901223cc7f67"
|
||||
|
||||
+21
-20
@@ -5,43 +5,44 @@ requires = ["poetry-core"]
|
||||
[project]
|
||||
authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}]
|
||||
dependencies = [
|
||||
"celery (>=5.4.0,<6.0.0)",
|
||||
"celery (==5.6.2)",
|
||||
"dj-rest-auth[with_social,jwt] (==7.0.1)",
|
||||
"django (==5.1.15)",
|
||||
"django-allauth[saml] (>=65.13.0,<66.0.0)",
|
||||
"django-celery-beat (>=2.7.0,<3.0.0)",
|
||||
"django-celery-results (>=2.5.1,<3.0.0)",
|
||||
"django-allauth[saml] (==65.15.0)",
|
||||
"django-celery-beat (==2.9.0)",
|
||||
"django-celery-results (==2.6.0)",
|
||||
"django-cors-headers==4.4.0",
|
||||
"django-environ==0.11.2",
|
||||
"django-filter==24.3",
|
||||
"django-guid==3.5.0",
|
||||
"django-postgres-extra (>=2.0.8,<3.0.0)",
|
||||
"django-postgres-extra (==2.0.9)",
|
||||
"djangorestframework==3.15.2",
|
||||
"djangorestframework-jsonapi==7.0.2",
|
||||
"djangorestframework-simplejwt (>=5.3.1,<6.0.0)",
|
||||
"drf-nested-routers (>=0.94.1,<1.0.0)",
|
||||
"djangorestframework-simplejwt (==5.5.1)",
|
||||
"drf-nested-routers (==0.95.0)",
|
||||
"drf-spectacular==0.27.2",
|
||||
"drf-spectacular-jsonapi==0.5.1",
|
||||
"defusedxml==0.7.1",
|
||||
"gunicorn==23.0.0",
|
||||
"lxml==5.3.2",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (>=1.0.1,<2.0.0)",
|
||||
"sentry-sdk[django] (>=2.20.0,<3.0.0)",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
"uuid6==2024.7.10",
|
||||
"openai (>=1.82.0,<2.0.0)",
|
||||
"openai (==1.109.1)",
|
||||
"xmlsec==1.3.14",
|
||||
"h2 (==4.3.0)",
|
||||
"markdown (>=3.9,<4.0)",
|
||||
"markdown (==3.10.2)",
|
||||
"drf-simple-apikey (==2.2.1)",
|
||||
"matplotlib (>=3.10.6,<4.0.0)",
|
||||
"reportlab (>=4.4.4,<5.0.0)",
|
||||
"neo4j (>=6.0.0,<7.0.0)",
|
||||
"matplotlib (==3.10.8)",
|
||||
"reportlab (==4.4.10)",
|
||||
"neo4j (==6.1.0)",
|
||||
"cartography (==0.132.0)",
|
||||
"gevent (>=25.9.1,<26.0.0)",
|
||||
"werkzeug (>=3.1.4)",
|
||||
"sqlparse (>=0.5.4)",
|
||||
"fonttools (>=4.60.2)"
|
||||
"gevent (==25.9.1)",
|
||||
"werkzeug (==3.1.7)",
|
||||
"sqlparse (==0.5.5)",
|
||||
"fonttools (==4.62.1)"
|
||||
]
|
||||
description = "Prowler's API (Django/DRF)"
|
||||
license = "Apache-2.0"
|
||||
@@ -49,7 +50,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.21.0"
|
||||
version = "1.24.0"
|
||||
|
||||
[project.scripts]
|
||||
celery = "src.backend.config.settings.celery"
|
||||
@@ -61,7 +62,7 @@ django-silk = "5.3.2"
|
||||
docker = "7.1.0"
|
||||
filelock = "3.20.3"
|
||||
freezegun = "1.5.1"
|
||||
marshmallow = ">=3.15.0,<4.0.0"
|
||||
marshmallow = "==3.26.2"
|
||||
mypy = "1.10.1"
|
||||
pylint = "3.2.5"
|
||||
pytest = "8.2.2"
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Cypher sanitizer for custom (user-supplied) Attack Paths queries.
|
||||
|
||||
Two responsibilities:
|
||||
|
||||
1. **Validation** - reject queries containing SSRF or dangerous procedure
|
||||
patterns (defense-in-depth; the primary control is ``neo4j.READ_ACCESS``).
|
||||
|
||||
2. **Provider-scoped label injection** - inject a dynamic
|
||||
``_Provider_{uuid}`` label into every node pattern so the database can
|
||||
use its native label index for provider isolation.
|
||||
|
||||
Label-injection pipeline:
|
||||
|
||||
1. **Protect** string literals and line comments (placeholder replacement).
|
||||
2. **Split** by top-level clause keywords to track clause context.
|
||||
3. **Pass A** - inject into *labeled* node patterns in ALL segments.
|
||||
4. **Pass B** - inject into *bare* node patterns in MATCH segments only.
|
||||
5. **Restore** protected regions.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from tasks.jobs.attack_paths.config import get_provider_label
|
||||
|
||||
|
||||
# Step 1 - String / comment protection
|
||||
# Single combined regex: strings first, then line comments.
|
||||
# The regex engine finds the leftmost match, so a string like 'https://prowler.com'
|
||||
# is consumed as a string before the // inside it can match as a comment.
|
||||
_PROTECTED_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*")
|
||||
|
||||
# Step 2 - Clause splitting
|
||||
# OPTIONAL MATCH must come before MATCH to avoid partial matching.
|
||||
_CLAUSE_RE = re.compile(
|
||||
r"\b(OPTIONAL\s+MATCH|MATCH|WHERE|RETURN|WITH|ORDER\s+BY"
|
||||
r"|SKIP|LIMIT|UNION|UNWIND|CALL)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Pass A - Labeled node patterns (all segments)
|
||||
# Matches node patterns that have at least one :Label.
|
||||
# (?<!\w)\( - open paren NOT preceded by a word char (excludes function calls).
|
||||
# Group 1: optional variable + one or more :Label
|
||||
# Group 2: optional {properties} + closing paren
|
||||
_LABELED_NODE_RE = re.compile(
|
||||
r"(?<!\w)\("
|
||||
r"("
|
||||
r"\s*(?:[a-zA-Z_]\w*)?"
|
||||
r"(?:\s*:\s*(?:`[^`]*`|[a-zA-Z_]\w*))+"
|
||||
r")"
|
||||
r"("
|
||||
r"\s*(?:\{[^}]*\})?"
|
||||
r"\s*\)"
|
||||
r")"
|
||||
)
|
||||
|
||||
# Pass B - Bare node patterns (MATCH segments only)
|
||||
# Matches (identifier) or (identifier {properties}) without any :Label.
|
||||
# Only applied in MATCH/OPTIONAL MATCH segments.
|
||||
_BARE_NODE_RE = re.compile(
|
||||
r"(?<!\w)\(" r"(\s*[a-zA-Z_]\w*)" r"(\s*(?:\{[^}]*\})?)" r"\s*\)"
|
||||
)
|
||||
|
||||
_MATCH_CLAUSES = frozenset({"MATCH", "OPTIONAL MATCH"})
|
||||
|
||||
|
||||
def _inject_labeled(segment: str, label: str) -> str:
|
||||
"""Inject provider label into all node patterns that have existing labels."""
|
||||
return _LABELED_NODE_RE.sub(rf"(\1:{label}\2", segment)
|
||||
|
||||
|
||||
def _inject_bare(segment: str, label: str) -> str:
|
||||
"""Inject provider label into bare `(identifier)` node patterns."""
|
||||
|
||||
def _replace(match):
|
||||
var = match.group(1)
|
||||
props = match.group(2).strip()
|
||||
if props:
|
||||
return f"({var}:{label} {props})"
|
||||
return f"({var}:{label})"
|
||||
|
||||
return _BARE_NODE_RE.sub(_replace, segment)
|
||||
|
||||
|
||||
def inject_provider_label(cypher: str, provider_id: str) -> str:
|
||||
"""Rewrite a Cypher query to scope every node pattern to a provider.
|
||||
|
||||
Args:
|
||||
cypher: The original Cypher query string.
|
||||
provider_id: The provider UUID (will be converted to a label via
|
||||
`get_provider_label`).
|
||||
|
||||
Returns:
|
||||
The rewritten Cypher with `:_Provider_{uuid}` appended to every
|
||||
node pattern.
|
||||
"""
|
||||
label = get_provider_label(provider_id)
|
||||
|
||||
# Step 1: Protect strings and comments (single pass, leftmost-first)
|
||||
protected: list[str] = []
|
||||
|
||||
def _save(match):
|
||||
protected.append(match.group(0))
|
||||
return f"\x00P{len(protected) - 1}\x00"
|
||||
|
||||
work = _PROTECTED_RE.sub(_save, cypher)
|
||||
|
||||
# Step 2: Split by clause keywords
|
||||
parts = _CLAUSE_RE.split(work)
|
||||
|
||||
# Steps 3-4: Apply injection passes per segment
|
||||
result: list[str] = []
|
||||
current_clause: str | None = None
|
||||
|
||||
for i, part in enumerate(parts):
|
||||
if i % 2 == 1:
|
||||
# Keyword token - normalize for clause tracking
|
||||
current_clause = re.sub(r"\s+", " ", part.strip()).upper()
|
||||
result.append(part)
|
||||
else:
|
||||
# Content segment - apply injection based on clause context
|
||||
part = _inject_labeled(part, label)
|
||||
if current_clause in _MATCH_CLAUSES:
|
||||
part = _inject_bare(part, label)
|
||||
result.append(part)
|
||||
|
||||
work = "".join(result)
|
||||
|
||||
# Step 5: Restore protected regions
|
||||
for i, original in enumerate(protected):
|
||||
work = work.replace(f"\x00P{i}\x00", original)
|
||||
|
||||
return work
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Patterns that indicate SSRF or dangerous procedure calls
|
||||
# Defense-in-depth layer - the primary control is `neo4j.READ_ACCESS`
|
||||
_BLOCKED_PATTERNS = [
|
||||
re.compile(r"\bLOAD\s+CSV\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.load\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.import\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.export\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.cypher\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.systemdb\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.config\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.periodic\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.do\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.trigger\b", re.IGNORECASE),
|
||||
re.compile(r"\bapoc\.custom\b", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
def validate_custom_query(cypher: str) -> None:
|
||||
"""Reject queries containing known SSRF or dangerous procedure patterns.
|
||||
|
||||
Raises ValidationError if a blocked pattern is found.
|
||||
String literals and comments are stripped before matching to avoid
|
||||
false positives.
|
||||
"""
|
||||
stripped = _PROTECTED_RE.sub("", cypher)
|
||||
for pattern in _BLOCKED_PATTERNS:
|
||||
if pattern.search(stripped):
|
||||
raise ValidationError({"query": "Query contains a blocked operation"})
|
||||
@@ -1,25 +1,22 @@
|
||||
import atexit
|
||||
import logging
|
||||
import threading
|
||||
|
||||
from typing import Any
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
from typing import Any, Iterator
|
||||
from uuid import UUID
|
||||
|
||||
import neo4j
|
||||
import neo4j.exceptions
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from api.attack_paths.retryable_session import RetryableSession
|
||||
from config.env import env
|
||||
from django.conf import settings
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
BATCH_SIZE,
|
||||
DEPRECATED_PROVIDER_RESOURCE_LABEL,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
get_provider_label,
|
||||
)
|
||||
|
||||
from api.attack_paths.retryable_session import RetryableSession
|
||||
|
||||
# Without this Celery goes crazy with Neo4j logging
|
||||
logging.getLogger("neo4j").setLevel(logging.ERROR)
|
||||
logging.getLogger("neo4j").propagate = False
|
||||
@@ -35,6 +32,7 @@ READ_EXCEPTION_CODES = [
|
||||
"Neo.ClientError.Statement.AccessMode",
|
||||
"Neo.ClientError.Procedure.ProcedureNotFound",
|
||||
]
|
||||
CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement."
|
||||
|
||||
# Module-level process-wide driver singleton
|
||||
_driver: neo4j.Driver | None = None
|
||||
@@ -108,6 +106,7 @@ def get_session(
|
||||
except neo4j.exceptions.Neo4jError as exc:
|
||||
if (
|
||||
default_access_mode == neo4j.READ_ACCESS
|
||||
and exc.code
|
||||
and exc.code in READ_EXCEPTION_CODES
|
||||
):
|
||||
message = "Read query not allowed"
|
||||
@@ -115,6 +114,10 @@ def get_session(
|
||||
raise WriteQueryNotAllowedException(message=message, code=code)
|
||||
|
||||
message = exc.message if exc.message is not None else str(exc)
|
||||
|
||||
if exc.code and exc.code.startswith(CLIENT_STATEMENT_EXCEPTION_PREFIX):
|
||||
raise ClientStatementException(message=message, code=exc.code)
|
||||
|
||||
raise GraphDatabaseQueryException(message=message, code=exc.code)
|
||||
|
||||
finally:
|
||||
@@ -160,11 +163,8 @@ def drop_subgraph(database: str, provider_id: str) -> int:
|
||||
Uses batched deletion to avoid memory issues with large graphs.
|
||||
Silently returns 0 if the database doesn't exist.
|
||||
"""
|
||||
provider_label = get_provider_label(provider_id)
|
||||
deleted_nodes = 0
|
||||
parameters = {
|
||||
"provider_id": provider_id,
|
||||
"batch_size": BATCH_SIZE,
|
||||
}
|
||||
|
||||
try:
|
||||
with get_session(database) as session:
|
||||
@@ -172,12 +172,12 @@ def drop_subgraph(database: str, provider_id: str) -> int:
|
||||
while deleted_count > 0:
|
||||
result = session.run(
|
||||
f"""
|
||||
MATCH (n:{DEPRECATED_PROVIDER_RESOURCE_LABEL} {{provider_id: $provider_id}})
|
||||
MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`)
|
||||
WITH n LIMIT $batch_size
|
||||
DETACH DELETE n
|
||||
RETURN COUNT(n) AS deleted_nodes_count
|
||||
""",
|
||||
parameters,
|
||||
{"batch_size": BATCH_SIZE},
|
||||
)
|
||||
deleted_count = result.single().get("deleted_nodes_count", 0)
|
||||
deleted_nodes += deleted_count
|
||||
@@ -190,6 +190,26 @@ def drop_subgraph(database: str, provider_id: str) -> int:
|
||||
return deleted_nodes
|
||||
|
||||
|
||||
def has_provider_data(database: str, provider_id: str) -> bool:
|
||||
"""
|
||||
Check if any ProviderResource node exists for this provider.
|
||||
|
||||
Returns `False` if the database doesn't exist.
|
||||
"""
|
||||
provider_label = get_provider_label(provider_id)
|
||||
query = f"MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`) RETURN 1 LIMIT 1"
|
||||
|
||||
try:
|
||||
with get_session(database, default_access_mode=neo4j.READ_ACCESS) as session:
|
||||
result = session.run(query)
|
||||
return result.single() is not None
|
||||
|
||||
except GraphDatabaseQueryException as exc:
|
||||
if exc.code == "Neo.ClientError.Database.DatabaseNotFound":
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
def clear_cache(database: str) -> None:
|
||||
query = "CALL db.clearQueryCaches()"
|
||||
|
||||
@@ -227,3 +247,7 @@ class GraphDatabaseQueryException(Exception):
|
||||
|
||||
class WriteQueryNotAllowedException(GraphDatabaseQueryException):
|
||||
pass
|
||||
|
||||
|
||||
class ClientStatementException(GraphDatabaseQueryException):
|
||||
pass
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,18 @@
|
||||
from tasks.jobs.attack_paths.config import DEPRECATED_PROVIDER_RESOURCE_LABEL
|
||||
from tasks.jobs.attack_paths.config import PROVIDER_RESOURCE_LABEL, get_provider_label
|
||||
|
||||
|
||||
def get_cartography_schema_query(provider_id: str) -> str:
|
||||
"""Build the Cartography schema metadata query scoped to a provider label."""
|
||||
provider_label = get_provider_label(provider_id)
|
||||
return f"""
|
||||
MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`)
|
||||
WHERE n._module_name STARTS WITH 'cartography:'
|
||||
AND NOT n._module_name IN ['cartography:ontology', 'cartography:prowler']
|
||||
AND n._module_version IS NOT NULL
|
||||
RETURN n._module_name AS module_name, n._module_version AS module_version
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
CARTOGRAPHY_SCHEMA_METADATA = f"""
|
||||
MATCH (n:{DEPRECATED_PROVIDER_RESOURCE_LABEL} {{provider_id: $provider_id}})
|
||||
WHERE n._module_name STARTS WITH 'cartography:'
|
||||
AND NOT n._module_name IN ['cartography:ontology', 'cartography:prowler']
|
||||
AND n._module_version IS NOT NULL
|
||||
RETURN n._module_name AS module_name, n._module_version AS module_version
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
GITHUB_SCHEMA_URL = (
|
||||
"https://github.com/cartography-cncf/cartography/blob/"
|
||||
|
||||
@@ -3,16 +3,26 @@ import logging
|
||||
from typing import Any, Iterable
|
||||
|
||||
import neo4j
|
||||
|
||||
from rest_framework.exceptions import APIException, PermissionDenied, ValidationError
|
||||
|
||||
from api.attack_paths import database as graph_database, AttackPathsQueryDefinition
|
||||
from api.attack_paths.cypher_sanitizer import (
|
||||
inject_provider_label,
|
||||
validate_custom_query,
|
||||
)
|
||||
from api.attack_paths.queries.schema import (
|
||||
CARTOGRAPHY_SCHEMA_METADATA,
|
||||
GITHUB_SCHEMA_URL,
|
||||
RAW_SCHEMA_URL,
|
||||
get_cartography_schema_query,
|
||||
)
|
||||
from config.custom_logging import BackendLogger
|
||||
from tasks.jobs.attack_paths.config import INTERNAL_LABELS, INTERNAL_PROPERTIES
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
INTERNAL_LABELS,
|
||||
INTERNAL_PROPERTIES,
|
||||
get_provider_label,
|
||||
is_dynamic_isolation_label,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(BackendLogger.API)
|
||||
|
||||
@@ -66,7 +76,6 @@ def prepare_parameters(
|
||||
|
||||
clean_parameters = {
|
||||
"provider_uid": str(provider_uid),
|
||||
"provider_id": str(provider_id),
|
||||
}
|
||||
|
||||
for definition_parameter in definition.parameters:
|
||||
@@ -135,6 +144,16 @@ def execute_custom_query(
|
||||
cypher: str,
|
||||
provider_id: str,
|
||||
) -> dict[str, Any]:
|
||||
# Defense-in-depth for custom queries:
|
||||
# 1. neo4j.READ_ACCESS — prevents mutations at the driver level
|
||||
# 2. inject_provider_label() — regex-based label injection scopes node patterns
|
||||
# 3. _serialize_graph() — post-query filter drops nodes without the provider label
|
||||
#
|
||||
# Layer 2 is best-effort (regex can't fully parse Cypher);
|
||||
# layer 3 is the safety net that guarantees provider isolation.
|
||||
validate_custom_query(cypher)
|
||||
cypher = inject_provider_label(cypher, provider_id)
|
||||
|
||||
try:
|
||||
graph = graph_database.execute_read_query(
|
||||
database=database_name,
|
||||
@@ -143,6 +162,9 @@ def execute_custom_query(
|
||||
serialized = _serialize_graph(graph, provider_id)
|
||||
return _truncate_graph(serialized)
|
||||
|
||||
except graph_database.ClientStatementException as exc:
|
||||
raise ValidationError({"query": exc.message})
|
||||
|
||||
except graph_database.WriteQueryNotAllowedException:
|
||||
raise PermissionDenied(
|
||||
"Attack Paths query execution failed: read-only queries are enforced"
|
||||
@@ -165,10 +187,7 @@ def get_cartography_schema(
|
||||
with graph_database.get_session(
|
||||
database_name, default_access_mode=neo4j.READ_ACCESS
|
||||
) as session:
|
||||
result = session.run(
|
||||
CARTOGRAPHY_SCHEMA_METADATA,
|
||||
{"provider_id": provider_id},
|
||||
)
|
||||
result = session.run(get_cartography_schema_query(provider_id))
|
||||
record = result.single()
|
||||
except graph_database.GraphDatabaseQueryException as exc:
|
||||
logger.error(f"Cartography schema query failed: {exc}")
|
||||
@@ -212,10 +231,12 @@ def _truncate_graph(graph: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
|
||||
provider_label = get_provider_label(provider_id)
|
||||
|
||||
nodes = []
|
||||
kept_node_ids = set()
|
||||
for node in graph.nodes:
|
||||
if node._properties.get("provider_id") != provider_id:
|
||||
if provider_label not in node.labels:
|
||||
continue
|
||||
|
||||
kept_node_ids.add(node.element_id)
|
||||
@@ -227,11 +248,14 @@ def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
|
||||
},
|
||||
)
|
||||
|
||||
filtered_count = len(graph.nodes) - len(nodes)
|
||||
if filtered_count > 0:
|
||||
logger.debug(
|
||||
f"Filtered {filtered_count} nodes without provider label {provider_label}"
|
||||
)
|
||||
|
||||
relationships = []
|
||||
for relationship in graph.relationships:
|
||||
if relationship._properties.get("provider_id") != provider_id:
|
||||
continue
|
||||
|
||||
if (
|
||||
relationship.start_node.element_id not in kept_node_ids
|
||||
or relationship.end_node.element_id not in kept_node_ids
|
||||
@@ -257,7 +281,11 @@ def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _filter_labels(labels: Iterable[str]) -> list[str]:
|
||||
return [label for label in labels if label not in INTERNAL_LABELS]
|
||||
return [
|
||||
label
|
||||
for label in labels
|
||||
if label not in INTERNAL_LABELS and not is_dynamic_isolation_label(label)
|
||||
]
|
||||
|
||||
|
||||
def _serialize_properties(properties: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -18,6 +18,7 @@ from django.db import (
|
||||
)
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from psycopg2 import connect as psycopg2_connect
|
||||
from psycopg2 import sql as psycopg2_sql
|
||||
from psycopg2.extensions import AsIs, new_type, register_adapter, register_type
|
||||
from rest_framework_json_api.serializers import ValidationError
|
||||
|
||||
@@ -280,15 +281,23 @@ class PostgresEnumMigration:
|
||||
self.enum_values = enum_values
|
||||
|
||||
def create_enum_type(self, apps, schema_editor): # noqa: F841
|
||||
string_enum_values = ", ".join([f"'{value}'" for value in self.enum_values])
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
f"CREATE TYPE {self.enum_name} AS ENUM ({string_enum_values});"
|
||||
psycopg2_sql.SQL("CREATE TYPE {} AS ENUM ({})").format(
|
||||
psycopg2_sql.Identifier(self.enum_name),
|
||||
psycopg2_sql.SQL(", ").join(
|
||||
psycopg2_sql.Literal(v) for v in self.enum_values
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def drop_enum_type(self, apps, schema_editor): # noqa: F841
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
cursor.execute(f"DROP TYPE {self.enum_name};")
|
||||
cursor.execute(
|
||||
psycopg2_sql.SQL("DROP TYPE {}").format(
|
||||
psycopg2_sql.Identifier(self.enum_name)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PostgresEnumField(models.Field):
|
||||
|
||||
+170
-12
@@ -15,6 +15,7 @@ from django_filters.rest_framework import (
|
||||
from rest_framework_json_api.django_filters.backends import DjangoFilterBackend
|
||||
from rest_framework_json_api.serializers import ValidationError
|
||||
|
||||
from api.constants import SEVERITY_ORDER
|
||||
from api.db_utils import (
|
||||
FindingDeltaEnumField,
|
||||
InvitationStateEnumField,
|
||||
@@ -43,6 +44,7 @@ from api.models import (
|
||||
ProviderGroup,
|
||||
ProviderSecret,
|
||||
Resource,
|
||||
ResourceFindingMapping,
|
||||
ResourceTag,
|
||||
Role,
|
||||
Scan,
|
||||
@@ -196,17 +198,13 @@ class CommonFindingFilters(FilterSet):
|
||||
field_name="resource_services", lookup_expr="icontains"
|
||||
)
|
||||
|
||||
resource_uid = CharFilter(field_name="resources__uid")
|
||||
resource_uid__in = CharInFilter(field_name="resources__uid", lookup_expr="in")
|
||||
resource_uid__icontains = CharFilter(
|
||||
field_name="resources__uid", lookup_expr="icontains"
|
||||
)
|
||||
resource_uid = CharFilter(method="filter_resource_uid")
|
||||
resource_uid__in = CharInFilter(method="filter_resource_uid_in")
|
||||
resource_uid__icontains = CharFilter(method="filter_resource_uid_icontains")
|
||||
|
||||
resource_name = CharFilter(field_name="resources__name")
|
||||
resource_name__in = CharInFilter(field_name="resources__name", lookup_expr="in")
|
||||
resource_name__icontains = CharFilter(
|
||||
field_name="resources__name", lookup_expr="icontains"
|
||||
)
|
||||
resource_name = CharFilter(method="filter_resource_name")
|
||||
resource_name__in = CharInFilter(method="filter_resource_name_in")
|
||||
resource_name__icontains = CharFilter(method="filter_resource_name_icontains")
|
||||
|
||||
resource_type = CharFilter(method="filter_resource_type")
|
||||
resource_type__in = CharInFilter(field_name="resource_types", lookup_expr="overlap")
|
||||
@@ -264,6 +262,52 @@ class CommonFindingFilters(FilterSet):
|
||||
)
|
||||
return queryset.filter(overall_query).distinct()
|
||||
|
||||
def filter_check_title_icontains(self, queryset, name, value):
|
||||
# Resolve from the summary table (has check_title column + trigram
|
||||
# GIN index) instead of scanning JSON in the findings table.
|
||||
matching_check_ids = (
|
||||
FindingGroupDailySummary.objects.filter(
|
||||
check_title__icontains=value,
|
||||
)
|
||||
.values_list("check_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
return queryset.filter(check_id__in=matching_check_ids)
|
||||
|
||||
# --- Resource subquery filters ---
|
||||
# Resolve resource → RFM → finding_ids first, then filter findings
|
||||
# by id__in. This avoids a 3-way JOIN driven from the (huge)
|
||||
# findings side and lets PostgreSQL start from the resources
|
||||
# unique-constraint index instead.
|
||||
|
||||
@staticmethod
|
||||
def _finding_ids_for_resources(**lookup):
|
||||
return ResourceFindingMapping.objects.filter(
|
||||
resource__in=Resource.objects.filter(**lookup).values("id")
|
||||
).values("finding_id")
|
||||
|
||||
def filter_resource_uid(self, queryset, name, value):
|
||||
return queryset.filter(id__in=self._finding_ids_for_resources(uid=value))
|
||||
|
||||
def filter_resource_uid_in(self, queryset, name, value):
|
||||
return queryset.filter(id__in=self._finding_ids_for_resources(uid__in=value))
|
||||
|
||||
def filter_resource_uid_icontains(self, queryset, name, value):
|
||||
return queryset.filter(
|
||||
id__in=self._finding_ids_for_resources(uid__icontains=value)
|
||||
)
|
||||
|
||||
def filter_resource_name(self, queryset, name, value):
|
||||
return queryset.filter(id__in=self._finding_ids_for_resources(name=value))
|
||||
|
||||
def filter_resource_name_in(self, queryset, name, value):
|
||||
return queryset.filter(id__in=self._finding_ids_for_resources(name__in=value))
|
||||
|
||||
def filter_resource_name_icontains(self, queryset, name, value):
|
||||
return queryset.filter(
|
||||
id__in=self._finding_ids_for_resources(name__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class TenantFilter(FilterSet):
|
||||
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
|
||||
@@ -803,11 +847,15 @@ class FindingGroupFilter(CommonFindingFilters):
|
||||
check_id = CharFilter(field_name="check_id", lookup_expr="exact")
|
||||
check_id__in = CharInFilter(field_name="check_id", lookup_expr="in")
|
||||
check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains")
|
||||
check_title__icontains = CharFilter(method="filter_check_title_icontains")
|
||||
scan = UUIDFilter(field_name="scan_id", lookup_expr="exact")
|
||||
scan__in = UUIDInFilter(field_name="scan_id", lookup_expr="in")
|
||||
|
||||
class Meta:
|
||||
model = Finding
|
||||
fields = {
|
||||
"check_id": ["exact", "in", "icontains"],
|
||||
"scan": ["exact", "in"],
|
||||
}
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
@@ -895,15 +943,31 @@ class LatestFindingGroupFilter(CommonFindingFilters):
|
||||
check_id = CharFilter(field_name="check_id", lookup_expr="exact")
|
||||
check_id__in = CharInFilter(field_name="check_id", lookup_expr="in")
|
||||
check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains")
|
||||
check_title__icontains = CharFilter(method="filter_check_title_icontains")
|
||||
scan = UUIDFilter(field_name="scan_id", lookup_expr="exact")
|
||||
scan__in = UUIDInFilter(field_name="scan_id", lookup_expr="in")
|
||||
|
||||
class Meta:
|
||||
model = Finding
|
||||
fields = {
|
||||
"check_id": ["exact", "in", "icontains"],
|
||||
"scan": ["exact", "in"],
|
||||
}
|
||||
|
||||
|
||||
class FindingGroupSummaryFilter(FilterSet):
|
||||
class _CheckTitleToCheckIdMixin:
|
||||
"""Resolve check_title search to check_ids so all provider rows are kept."""
|
||||
|
||||
def filter_check_title_to_check_ids(self, queryset, name, value):
|
||||
matching_check_ids = (
|
||||
queryset.filter(check_title__icontains=value)
|
||||
.values_list("check_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
return queryset.filter(check_id__in=matching_check_ids)
|
||||
|
||||
|
||||
class FindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet):
|
||||
"""
|
||||
Filter for FindingGroupDailySummary queries.
|
||||
|
||||
@@ -926,6 +990,7 @@ class FindingGroupSummaryFilter(FilterSet):
|
||||
check_id = CharFilter(field_name="check_id", lookup_expr="exact")
|
||||
check_id__in = CharInFilter(field_name="check_id", lookup_expr="in")
|
||||
check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains")
|
||||
check_title__icontains = CharFilter(method="filter_check_title_to_check_ids")
|
||||
|
||||
# Provider filters
|
||||
provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact")
|
||||
@@ -1013,7 +1078,7 @@ class FindingGroupSummaryFilter(FilterSet):
|
||||
return dt
|
||||
|
||||
|
||||
class LatestFindingGroupSummaryFilter(FilterSet):
|
||||
class LatestFindingGroupSummaryFilter(_CheckTitleToCheckIdMixin, FilterSet):
|
||||
"""
|
||||
Filter for FindingGroupDailySummary /latest endpoint.
|
||||
|
||||
@@ -1025,6 +1090,7 @@ class LatestFindingGroupSummaryFilter(FilterSet):
|
||||
check_id = CharFilter(field_name="check_id", lookup_expr="exact")
|
||||
check_id__in = CharInFilter(field_name="check_id", lookup_expr="in")
|
||||
check_id__icontains = CharFilter(field_name="check_id", lookup_expr="icontains")
|
||||
check_title__icontains = CharFilter(method="filter_check_title_to_check_ids")
|
||||
|
||||
# Provider filters
|
||||
provider_id = UUIDFilter(field_name="provider_id", lookup_expr="exact")
|
||||
@@ -1042,6 +1108,98 @@ class LatestFindingGroupSummaryFilter(FilterSet):
|
||||
}
|
||||
|
||||
|
||||
class FindingGroupAggregatedComputedFilter(FilterSet):
|
||||
"""Filter aggregated finding-group rows by computed status/severity/muted."""
|
||||
|
||||
STATUS_CHOICES = (
|
||||
("FAIL", "Fail"),
|
||||
("PASS", "Pass"),
|
||||
("MUTED", "Muted"),
|
||||
)
|
||||
|
||||
status = ChoiceFilter(method="filter_status", choices=STATUS_CHOICES)
|
||||
status__in = CharInFilter(method="filter_status_in", lookup_expr="in")
|
||||
severity = ChoiceFilter(method="filter_severity", choices=SeverityChoices)
|
||||
severity__in = CharInFilter(method="filter_severity_in", lookup_expr="in")
|
||||
include_muted = BooleanFilter(method="filter_include_muted")
|
||||
|
||||
def filter_status(self, queryset, name, value):
|
||||
return queryset.filter(aggregated_status=value)
|
||||
|
||||
def filter_status_in(self, queryset, name, value):
|
||||
values = value
|
||||
if isinstance(value, str):
|
||||
values = [part.strip() for part in value.split(",") if part.strip()]
|
||||
|
||||
allowed = {choice[0] for choice in self.STATUS_CHOICES}
|
||||
invalid = [
|
||||
status_value for status_value in values if status_value not in allowed
|
||||
]
|
||||
if invalid:
|
||||
raise ValidationError(
|
||||
[
|
||||
{
|
||||
"detail": f"invalid status filter: {invalid[0]}",
|
||||
"status": "400",
|
||||
"source": {"pointer": "/data"},
|
||||
"code": "invalid",
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
if not values:
|
||||
return queryset
|
||||
|
||||
return queryset.filter(aggregated_status__in=values)
|
||||
|
||||
def filter_severity(self, queryset, name, value):
|
||||
severity_order = SEVERITY_ORDER.get(value)
|
||||
if severity_order is None:
|
||||
raise ValidationError(
|
||||
[
|
||||
{
|
||||
"detail": f"invalid severity filter: {value}",
|
||||
"status": "400",
|
||||
"source": {"pointer": "/data"},
|
||||
"code": "invalid",
|
||||
}
|
||||
]
|
||||
)
|
||||
return queryset.filter(severity_order=severity_order)
|
||||
|
||||
def filter_severity_in(self, queryset, name, value):
|
||||
values = value
|
||||
if isinstance(value, str):
|
||||
values = [part.strip() for part in value.split(",") if part.strip()]
|
||||
|
||||
orders = []
|
||||
for severity_value in values:
|
||||
severity_order = SEVERITY_ORDER.get(severity_value)
|
||||
if severity_order is None:
|
||||
raise ValidationError(
|
||||
[
|
||||
{
|
||||
"detail": f"invalid severity filter: {severity_value}",
|
||||
"status": "400",
|
||||
"source": {"pointer": "/data"},
|
||||
"code": "invalid",
|
||||
}
|
||||
]
|
||||
)
|
||||
orders.append(severity_order)
|
||||
|
||||
if not orders:
|
||||
return queryset
|
||||
|
||||
return queryset.filter(severity_order__in=orders)
|
||||
|
||||
def filter_include_muted(self, queryset, name, value):
|
||||
if value is True:
|
||||
return queryset
|
||||
# include_muted=false: exclude fully-muted groups
|
||||
return queryset.exclude(fail_count=0, pass_count=0, muted_count__gt=0)
|
||||
|
||||
|
||||
class ProviderSecretFilter(FilterSet):
|
||||
inserted_at = DateFilter(
|
||||
field_name="inserted_at",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.1.15 on 2026-03-18
|
||||
|
||||
from django.contrib.postgres.indexes import GinIndex, OpClass
|
||||
from django.contrib.postgres.operations import AddIndexConcurrently
|
||||
from django.db import migrations
|
||||
from django.db.models.functions import Upper
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
("api", "0084_googleworkspace_provider"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
AddIndexConcurrently(
|
||||
model_name="findinggroupdailysummary",
|
||||
index=GinIndex(
|
||||
OpClass(Upper("check_id"), name="gin_trgm_ops"),
|
||||
name="fgds_check_id_trgm_idx",
|
||||
),
|
||||
),
|
||||
AddIndexConcurrently(
|
||||
model_name="findinggroupdailysummary",
|
||||
index=GinIndex(
|
||||
OpClass(Upper("check_title"), name="gin_trgm_ops"),
|
||||
name="fgds_check_title_trgm_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
TASK_NAME = "attack-paths-cleanup-stale-scans"
|
||||
INTERVAL_HOURS = 1
|
||||
|
||||
|
||||
def create_periodic_task(apps, schema_editor):
|
||||
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
|
||||
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
|
||||
|
||||
schedule, _ = IntervalSchedule.objects.get_or_create(
|
||||
every=INTERVAL_HOURS,
|
||||
period="hours",
|
||||
)
|
||||
|
||||
PeriodicTask.objects.update_or_create(
|
||||
name=TASK_NAME,
|
||||
defaults={
|
||||
"task": TASK_NAME,
|
||||
"interval": schedule,
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def delete_periodic_task(apps, schema_editor):
|
||||
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
|
||||
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
|
||||
|
||||
PeriodicTask.objects.filter(name=TASK_NAME).delete()
|
||||
|
||||
# Clean up the schedule if no other task references it
|
||||
IntervalSchedule.objects.filter(
|
||||
every=INTERVAL_HOURS,
|
||||
period="hours",
|
||||
periodictask__isnull=True,
|
||||
).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0085_finding_group_daily_summary_trgm_indexes"),
|
||||
("django_celery_beat", "0019_alter_periodictasks_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_periodic_task, delete_periodic_task),
|
||||
]
|
||||
@@ -1,7 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -9,6 +8,8 @@ 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
|
||||
import defusedxml
|
||||
from defusedxml import ElementTree as ET
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractBaseUser
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
@@ -1783,6 +1784,15 @@ class FindingGroupDailySummary(RowLevelSecurityProtectedModel):
|
||||
fields=["tenant_id", "provider", "inserted_at"],
|
||||
name="fgds_tenant_prov_ins_idx",
|
||||
),
|
||||
# Trigram indexes for case-insensitive search
|
||||
GinIndex(
|
||||
OpClass(Upper("check_id"), name="gin_trgm_ops"),
|
||||
name="fgds_check_id_trgm_idx",
|
||||
),
|
||||
GinIndex(
|
||||
OpClass(Upper("check_title"), name="gin_trgm_ops"),
|
||||
name="fgds_check_title_trgm_idx",
|
||||
),
|
||||
]
|
||||
|
||||
class JSONAPIMeta:
|
||||
@@ -2058,6 +2068,8 @@ class SAMLConfiguration(RowLevelSecurityProtectedModel):
|
||||
root = ET.fromstring(self.metadata_xml)
|
||||
except ET.ParseError as e:
|
||||
raise ValidationError({"metadata_xml": f"Invalid XML: {e}"})
|
||||
except defusedxml.DefusedXmlException as e:
|
||||
raise ValidationError({"metadata_xml": f"Unsafe XML content rejected: {e}"})
|
||||
|
||||
# Entity ID
|
||||
entity_id = root.attrib.get("entityID")
|
||||
|
||||
@@ -30,7 +30,10 @@ class HasPermissions(BasePermission):
|
||||
return True
|
||||
|
||||
user_roles = (
|
||||
User.objects.using(MainRouter.admin_db).get(id=request.user.id).roles.all()
|
||||
User.objects.using(MainRouter.admin_db)
|
||||
.get(id=request.user.id)
|
||||
.roles.using(MainRouter.admin_db)
|
||||
.all()
|
||||
)
|
||||
if not user_roles:
|
||||
return False
|
||||
|
||||
@@ -61,7 +61,7 @@ def revoke_membership_api_keys(sender, instance, **kwargs): # noqa: F841
|
||||
in that tenant should be revoked to prevent further access.
|
||||
"""
|
||||
TenantAPIKey.objects.filter(
|
||||
entity=instance.user, tenant_id=instance.tenant.id
|
||||
entity_id=instance.user_id, tenant_id=instance.tenant_id
|
||||
).update(revoked=True)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.21.0
|
||||
version: 1.24.0
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -301,7 +301,7 @@ class TestTokenSwitchTenant:
|
||||
assert invalid_tenant_response.status_code == 400
|
||||
assert invalid_tenant_response.json()["errors"][0]["code"] == "invalid"
|
||||
assert invalid_tenant_response.json()["errors"][0]["detail"] == (
|
||||
"Tenant does not exist or user is not a " "member."
|
||||
"Tenant does not exist or user is not a member."
|
||||
)
|
||||
|
||||
|
||||
@@ -912,10 +912,9 @@ class TestAPIKeyLifecycle:
|
||||
auth_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
|
||||
# Must return 401 Unauthorized, not 500 Internal Server Error
|
||||
assert auth_response.status_code == 401, (
|
||||
f"Expected 401 but got {auth_response.status_code}: "
|
||||
f"{auth_response.json()}"
|
||||
)
|
||||
assert (
|
||||
auth_response.status_code == 401
|
||||
), f"Expected 401 but got {auth_response.status_code}: {auth_response.json()}"
|
||||
|
||||
# Verify error message is present
|
||||
response_json = auth_response.json()
|
||||
|
||||
@@ -10,11 +10,11 @@ from django.conf import settings
|
||||
import api
|
||||
import api.apps as api_apps_module
|
||||
from api.apps import (
|
||||
ApiConfig,
|
||||
PRIVATE_KEY_FILE,
|
||||
PUBLIC_KEY_FILE,
|
||||
SIGNING_KEY_ENV,
|
||||
VERIFYING_KEY_ENV,
|
||||
ApiConfig,
|
||||
)
|
||||
|
||||
|
||||
@@ -187,9 +187,10 @@ def test_ready_initializes_driver_for_api_process(monkeypatch):
|
||||
_set_argv(monkeypatch, ["gunicorn"])
|
||||
_set_testing(monkeypatch, False)
|
||||
|
||||
with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch(
|
||||
"api.attack_paths.database.init_driver"
|
||||
) as init_driver:
|
||||
with (
|
||||
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
|
||||
patch("api.attack_paths.database.init_driver") as init_driver,
|
||||
):
|
||||
config.ready()
|
||||
|
||||
init_driver.assert_called_once()
|
||||
@@ -200,9 +201,10 @@ def test_ready_skips_driver_for_celery(monkeypatch):
|
||||
_set_argv(monkeypatch, ["celery", "-A", "api"])
|
||||
_set_testing(monkeypatch, False)
|
||||
|
||||
with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch(
|
||||
"api.attack_paths.database.init_driver"
|
||||
) as init_driver:
|
||||
with (
|
||||
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
|
||||
patch("api.attack_paths.database.init_driver") as init_driver,
|
||||
):
|
||||
config.ready()
|
||||
|
||||
init_driver.assert_not_called()
|
||||
@@ -213,9 +215,10 @@ def test_ready_skips_driver_for_manage_py_skip_command(monkeypatch):
|
||||
_set_argv(monkeypatch, ["manage.py", "migrate"])
|
||||
_set_testing(monkeypatch, False)
|
||||
|
||||
with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch(
|
||||
"api.attack_paths.database.init_driver"
|
||||
) as init_driver:
|
||||
with (
|
||||
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
|
||||
patch("api.attack_paths.database.init_driver") as init_driver,
|
||||
):
|
||||
config.ready()
|
||||
|
||||
init_driver.assert_not_called()
|
||||
@@ -226,9 +229,10 @@ def test_ready_skips_driver_when_testing(monkeypatch):
|
||||
_set_argv(monkeypatch, ["gunicorn"])
|
||||
_set_testing(monkeypatch, True)
|
||||
|
||||
with patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None), patch(
|
||||
"api.attack_paths.database.init_driver"
|
||||
) as init_driver:
|
||||
with (
|
||||
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
|
||||
patch("api.attack_paths.database.init_driver") as init_driver,
|
||||
):
|
||||
config.ready()
|
||||
|
||||
init_driver.assert_not_called()
|
||||
|
||||
@@ -9,6 +9,10 @@ from rest_framework.exceptions import APIException, PermissionDenied, Validation
|
||||
|
||||
from api.attack_paths import database as graph_database
|
||||
from api.attack_paths import views_helpers
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
PROVIDER_ELEMENT_ID_PROPERTY,
|
||||
get_provider_label,
|
||||
)
|
||||
|
||||
|
||||
def _make_neo4j_error(message, code):
|
||||
@@ -49,7 +53,7 @@ def test_prepare_parameters_includes_provider_and_casts(
|
||||
)
|
||||
|
||||
assert result["provider_uid"] == "123456789012"
|
||||
assert result["provider_id"] == "test-provider-id"
|
||||
assert "provider_id" not in result
|
||||
assert result["limit"] == 5
|
||||
|
||||
|
||||
@@ -103,12 +107,12 @@ def test_execute_query_serializes_graph(
|
||||
parameters = {"provider_uid": "123"}
|
||||
|
||||
provider_id = "test-provider-123"
|
||||
plabel = get_provider_label(provider_id)
|
||||
node = attack_paths_graph_stub_classes.Node(
|
||||
element_id="node-1",
|
||||
labels=["AWSAccount"],
|
||||
labels=["AWSAccount", plabel],
|
||||
properties={
|
||||
"name": "account",
|
||||
"provider_id": provider_id,
|
||||
"complex": {
|
||||
"items": [
|
||||
attack_paths_graph_stub_classes.NativeValue("value"),
|
||||
@@ -117,15 +121,13 @@ def test_execute_query_serializes_graph(
|
||||
},
|
||||
},
|
||||
)
|
||||
node_2 = attack_paths_graph_stub_classes.Node(
|
||||
"node-2", ["RDSInstance"], {"provider_id": provider_id}
|
||||
)
|
||||
node_2 = attack_paths_graph_stub_classes.Node("node-2", ["RDSInstance", plabel], {})
|
||||
relationship = attack_paths_graph_stub_classes.Relationship(
|
||||
element_id="rel-1",
|
||||
rel_type="OWNS",
|
||||
start_node=node,
|
||||
end_node=node_2,
|
||||
properties={"weight": 1, "provider_id": provider_id},
|
||||
properties={"weight": 1},
|
||||
)
|
||||
graph = SimpleNamespace(nodes=[node, node_2], relationships=[relationship])
|
||||
|
||||
@@ -209,29 +211,27 @@ def test_execute_query_raises_permission_denied_on_read_only(
|
||||
)
|
||||
|
||||
|
||||
def test_serialize_graph_filters_by_provider_id(attack_paths_graph_stub_classes):
|
||||
def test_serialize_graph_filters_by_provider_label(attack_paths_graph_stub_classes):
|
||||
provider_id = "provider-keep"
|
||||
plabel = get_provider_label(provider_id)
|
||||
other_label = get_provider_label("provider-other")
|
||||
|
||||
node_keep = attack_paths_graph_stub_classes.Node(
|
||||
"n1", ["AWSAccount"], {"provider_id": provider_id}
|
||||
)
|
||||
node_keep = attack_paths_graph_stub_classes.Node("n1", ["AWSAccount", plabel], {})
|
||||
node_drop = attack_paths_graph_stub_classes.Node(
|
||||
"n2", ["AWSAccount"], {"provider_id": "provider-other"}
|
||||
"n2", ["AWSAccount", other_label], {}
|
||||
)
|
||||
|
||||
rel_keep = attack_paths_graph_stub_classes.Relationship(
|
||||
"r1", "OWNS", node_keep, node_keep, {"provider_id": provider_id}
|
||||
)
|
||||
rel_drop_by_provider = attack_paths_graph_stub_classes.Relationship(
|
||||
"r2", "OWNS", node_keep, node_drop, {"provider_id": "provider-other"}
|
||||
"r1", "OWNS", node_keep, node_keep, {}
|
||||
)
|
||||
# Relationship connecting a kept node to a dropped node — filtered by endpoint check
|
||||
rel_drop_orphaned = attack_paths_graph_stub_classes.Relationship(
|
||||
"r3", "OWNS", node_keep, node_drop, {"provider_id": provider_id}
|
||||
"r2", "OWNS", node_keep, node_drop, {}
|
||||
)
|
||||
|
||||
graph = SimpleNamespace(
|
||||
nodes=[node_keep, node_drop],
|
||||
relationships=[rel_keep, rel_drop_by_provider, rel_drop_orphaned],
|
||||
relationships=[rel_keep, rel_drop_orphaned],
|
||||
)
|
||||
|
||||
result = views_helpers._serialize_graph(graph, provider_id)
|
||||
@@ -350,10 +350,7 @@ def test_serialize_properties_filters_internal_fields():
|
||||
"_module_name": "cartography:aws",
|
||||
"_module_version": "0.98.0",
|
||||
# Provider isolation
|
||||
"_provider_id": "42",
|
||||
"_provider_element_id": "42:abc123",
|
||||
"provider_id": "42",
|
||||
"provider_element_id": "42:abc123",
|
||||
PROVIDER_ELEMENT_ID_PROPERTY: "42:abc123",
|
||||
}
|
||||
|
||||
result = views_helpers._serialize_properties(properties)
|
||||
@@ -361,6 +358,14 @@ def test_serialize_properties_filters_internal_fields():
|
||||
assert result == {"name": "prod"}
|
||||
|
||||
|
||||
def test_filter_labels_strips_dynamic_isolation_labels():
|
||||
labels = ["AWSRole", "_Tenant_abc123", "_Provider_def456", "_ProviderResource"]
|
||||
|
||||
result = views_helpers._filter_labels(labels)
|
||||
|
||||
assert result == ["AWSRole"]
|
||||
|
||||
|
||||
def test_serialize_graph_as_text_node_without_properties():
|
||||
graph = {
|
||||
"nodes": [{"id": "n1", "labels": ["AWSAccount"], "properties": {}}],
|
||||
@@ -439,14 +444,11 @@ def test_execute_custom_query_serializes_graph(
|
||||
attack_paths_graph_stub_classes,
|
||||
):
|
||||
provider_id = "test-provider-123"
|
||||
node_1 = attack_paths_graph_stub_classes.Node(
|
||||
"node-1", ["AWSAccount"], {"provider_id": provider_id}
|
||||
)
|
||||
node_2 = attack_paths_graph_stub_classes.Node(
|
||||
"node-2", ["RDSInstance"], {"provider_id": provider_id}
|
||||
)
|
||||
plabel = get_provider_label(provider_id)
|
||||
node_1 = attack_paths_graph_stub_classes.Node("node-1", ["AWSAccount", plabel], {})
|
||||
node_2 = attack_paths_graph_stub_classes.Node("node-2", ["RDSInstance", plabel], {})
|
||||
relationship = attack_paths_graph_stub_classes.Relationship(
|
||||
"rel-1", "OWNS", node_1, node_2, {"provider_id": provider_id}
|
||||
"rel-1", "OWNS", node_1, node_2, {}
|
||||
)
|
||||
|
||||
graph_result = MagicMock()
|
||||
@@ -461,10 +463,11 @@ def test_execute_custom_query_serializes_graph(
|
||||
"db-tenant-test", "MATCH (n) RETURN n", provider_id
|
||||
)
|
||||
|
||||
mock_execute.assert_called_once_with(
|
||||
database="db-tenant-test",
|
||||
cypher="MATCH (n) RETURN n",
|
||||
)
|
||||
mock_execute.assert_called_once()
|
||||
call_kwargs = mock_execute.call_args[1]
|
||||
assert call_kwargs["database"] == "db-tenant-test"
|
||||
# The cypher is rewritten with the provider label injection
|
||||
assert plabel in call_kwargs["cypher"]
|
||||
assert len(result["nodes"]) == 2
|
||||
assert result["relationships"][0]["label"] == "OWNS"
|
||||
assert result["truncated"] is False
|
||||
|
||||
@@ -442,3 +442,78 @@ class TestThreadSafety:
|
||||
# All threads got the same driver instance
|
||||
assert all(r is mock_driver for r in results)
|
||||
assert len(results) == 10
|
||||
|
||||
|
||||
class TestHasProviderData:
|
||||
"""Test has_provider_data helper for checking provider nodes in Neo4j."""
|
||||
|
||||
def test_returns_true_when_nodes_exist(self):
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.single.return_value = MagicMock() # non-None record
|
||||
mock_session.run.return_value = mock_result
|
||||
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__enter__.return_value = mock_session
|
||||
session_ctx.__exit__.return_value = False
|
||||
|
||||
with patch(
|
||||
"api.attack_paths.database.get_session",
|
||||
return_value=session_ctx,
|
||||
):
|
||||
assert db_module.has_provider_data("db-tenant-abc", "provider-123") is True
|
||||
|
||||
mock_session.run.assert_called_once()
|
||||
|
||||
def test_returns_false_when_no_nodes(self):
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.single.return_value = None
|
||||
mock_session.run.return_value = mock_result
|
||||
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__enter__.return_value = mock_session
|
||||
session_ctx.__exit__.return_value = False
|
||||
|
||||
with patch(
|
||||
"api.attack_paths.database.get_session",
|
||||
return_value=session_ctx,
|
||||
):
|
||||
assert db_module.has_provider_data("db-tenant-abc", "provider-123") is False
|
||||
|
||||
def test_returns_false_when_database_not_found(self):
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException(
|
||||
message="Database does not exist",
|
||||
code="Neo.ClientError.Database.DatabaseNotFound",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"api.attack_paths.database.get_session",
|
||||
return_value=session_ctx,
|
||||
):
|
||||
assert (
|
||||
db_module.has_provider_data("db-tenant-gone", "provider-123") is False
|
||||
)
|
||||
|
||||
def test_raises_on_other_errors(self):
|
||||
import api.attack_paths.database as db_module
|
||||
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException(
|
||||
message="Connection refused",
|
||||
code="Neo.TransientError.General.UnknownError",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"api.attack_paths.database.get_session",
|
||||
return_value=session_ctx,
|
||||
):
|
||||
with pytest.raises(db_module.GraphDatabaseQueryException):
|
||||
db_module.has_provider_data("db-tenant-abc", "provider-123")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import pytest
|
||||
from config.settings.celery import _build_celery_broker_url
|
||||
|
||||
|
||||
class TestBuildCeleryBrokerUrl:
|
||||
def test_without_credentials(self):
|
||||
broker_url = _build_celery_broker_url("redis", "", "", "valkey", "6379", "0")
|
||||
|
||||
assert broker_url == "redis://valkey:6379/0"
|
||||
|
||||
def test_with_password_only(self):
|
||||
broker_url = _build_celery_broker_url(
|
||||
"rediss", "", "secret", "cache.example.com", "6379", "0"
|
||||
)
|
||||
|
||||
assert broker_url == "rediss://:secret@cache.example.com:6379/0"
|
||||
|
||||
def test_with_username_and_password(self):
|
||||
broker_url = _build_celery_broker_url(
|
||||
"rediss", "default", "secret", "cache.example.com", "6379", "0"
|
||||
)
|
||||
|
||||
assert broker_url == "rediss://default:secret@cache.example.com:6379/0"
|
||||
|
||||
def test_with_username_only(self):
|
||||
broker_url = _build_celery_broker_url(
|
||||
"redis", "admin", "", "valkey", "6379", "0"
|
||||
)
|
||||
|
||||
assert broker_url == "redis://admin@valkey:6379/0"
|
||||
|
||||
def test_url_encodes_credentials(self):
|
||||
broker_url = _build_celery_broker_url(
|
||||
"rediss", "user@name", "p@ss:word", "cache.example.com", "6379", "0"
|
||||
)
|
||||
|
||||
assert (
|
||||
broker_url == "rediss://user%40name:p%40ss%3Aword@cache.example.com:6379/0"
|
||||
)
|
||||
|
||||
def test_invalid_scheme_raises_error(self):
|
||||
with pytest.raises(ValueError, match="Invalid VALKEY_SCHEME 'http'"):
|
||||
_build_celery_broker_url("http", "", "", "valkey", "6379", "0")
|
||||
@@ -0,0 +1,429 @@
|
||||
"""Unit tests for the Cypher sanitizer (validation + provider-label injection)."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from api.attack_paths.cypher_sanitizer import (
|
||||
inject_provider_label,
|
||||
validate_custom_query,
|
||||
)
|
||||
|
||||
PROVIDER_ID = "019c41ee-7df3-7dec-a684-d839f95619f8"
|
||||
LABEL = "_Provider_019c41ee7df37deca684d839f95619f8"
|
||||
|
||||
|
||||
def _inject(cypher: str) -> str:
|
||||
"""Shortcut that patches `get_provider_label` to avoid config imports."""
|
||||
with patch(
|
||||
"api.attack_paths.cypher_sanitizer.get_provider_label", return_value=LABEL
|
||||
):
|
||||
return inject_provider_label(cypher, PROVIDER_ID)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pass A - Labeled node patterns (all clauses)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLabeledNodes:
|
||||
def test_single_label(self):
|
||||
result = _inject("MATCH (n:AWSRole) RETURN n")
|
||||
assert f"(n:AWSRole:{LABEL})" in result
|
||||
|
||||
def test_label_with_properties(self):
|
||||
result = _inject("MATCH (n:AWSRole {name: 'admin'}) RETURN n")
|
||||
assert f"(n:AWSRole:{LABEL} {{name: 'admin'}})" in result
|
||||
|
||||
def test_multiple_labels(self):
|
||||
result = _inject("MATCH (n:AWSRole:AWSPrincipal) RETURN n")
|
||||
assert f"(n:AWSRole:AWSPrincipal:{LABEL})" in result
|
||||
|
||||
def test_anonymous_labeled(self):
|
||||
result = _inject(
|
||||
"MATCH (:AWSPrincipal {arn: 'ecs-tasks.amazonaws.com'}) RETURN 1"
|
||||
)
|
||||
assert f"(:AWSPrincipal:{LABEL} {{arn: 'ecs-tasks.amazonaws.com'}})" in result
|
||||
|
||||
def test_backtick_label(self):
|
||||
result = _inject("MATCH (n:`My Label`) RETURN n")
|
||||
assert f"(n:`My Label`:{LABEL})" in result
|
||||
|
||||
def test_labeled_in_where_clause(self):
|
||||
"""Labeled nodes in WHERE (pattern existence) still get the label."""
|
||||
result = _inject(
|
||||
"MATCH (n:AWSRole) WHERE EXISTS((n)-[:REL]->(:Target)) RETURN n"
|
||||
)
|
||||
assert f"(n:AWSRole:{LABEL})" in result
|
||||
assert f"(:Target:{LABEL})" in result
|
||||
|
||||
def test_labeled_in_return_clause(self):
|
||||
"""Labeled nodes in RETURN still get the label (they're always node patterns)."""
|
||||
result = _inject("MATCH (n:AWSRole) RETURN (n:AWSRole)")
|
||||
assert result.count(f":AWSRole:{LABEL}") == 2
|
||||
|
||||
def test_labeled_in_optional_match(self):
|
||||
result = _inject(
|
||||
"OPTIONAL MATCH (pf:ProwlerFinding {status: 'FAIL'}) RETURN pf"
|
||||
)
|
||||
assert f"(pf:ProwlerFinding:{LABEL} {{status: 'FAIL'}})" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pass B - Bare node patterns (MATCH/OPTIONAL MATCH only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBareNodes:
|
||||
def test_bare_in_match(self):
|
||||
result = _inject("MATCH (a)-[:HAS_POLICY]->(b) RETURN a, b")
|
||||
assert f"(a:{LABEL})" in result
|
||||
assert f"(b:{LABEL})" in result
|
||||
|
||||
def test_bare_with_properties_in_match(self):
|
||||
result = _inject("MATCH (n {name: 'x'}) RETURN n")
|
||||
assert f"(n:{LABEL} {{name: 'x'}})" in result
|
||||
|
||||
def test_bare_in_optional_match(self):
|
||||
result = _inject("OPTIONAL MATCH (n)-[r]-(m) RETURN n")
|
||||
assert f"(n:{LABEL})" in result
|
||||
assert f"(m:{LABEL})" in result
|
||||
|
||||
def test_bare_not_injected_in_return(self):
|
||||
"""Bare (identifier) in RETURN could be expression grouping."""
|
||||
cypher = "MATCH (n:AWSRole) RETURN (n)"
|
||||
result = _inject(cypher)
|
||||
# The labeled (n:AWSRole) gets the label, but the bare (n) in RETURN should not
|
||||
assert f"(n:AWSRole:{LABEL})" in result
|
||||
# Count how many times the label appears - should be 1 (from MATCH only)
|
||||
assert result.count(LABEL) == 1
|
||||
|
||||
def test_bare_not_injected_in_where(self):
|
||||
cypher = "MATCH (n:AWSRole) WHERE (n.x > 1) RETURN n"
|
||||
result = _inject(cypher)
|
||||
# (n.x > 1) is an expression group, not a node pattern - should be untouched
|
||||
assert "(n.x > 1)" in result
|
||||
|
||||
def test_bare_not_injected_in_with(self):
|
||||
cypher = "MATCH (n:AWSRole) WITH (n) RETURN n"
|
||||
result = _inject(cypher)
|
||||
assert result.count(LABEL) == 1
|
||||
|
||||
def test_bare_not_injected_in_unwind(self):
|
||||
cypher = "UNWIND nodes(path) as n OPTIONAL MATCH (n)-[r]-(m) RETURN n"
|
||||
result = _inject(cypher)
|
||||
# (n) and (m) in OPTIONAL MATCH get injected, but nodes(path) in UNWIND does not
|
||||
assert f"(n:{LABEL})" in result
|
||||
assert f"(m:{LABEL})" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Function call exclusion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFunctionCallExclusion:
|
||||
@pytest.mark.parametrize(
|
||||
"func_call",
|
||||
[
|
||||
"collect(DISTINCT pf)",
|
||||
"any(x IN stmt.action WHERE toLower(x) = 'iam:*')",
|
||||
"toLower(action)",
|
||||
"nodes(path)",
|
||||
"count(n)",
|
||||
"apoc.create.vNode(labels)",
|
||||
"EXISTS(n.prop)",
|
||||
"size(n.list)",
|
||||
],
|
||||
)
|
||||
def test_function_calls_not_injected(self, func_call):
|
||||
cypher = f"MATCH (n:AWSRole) WHERE {func_call} RETURN n"
|
||||
result = _inject(cypher)
|
||||
# The function call should remain unchanged
|
||||
assert func_call in result
|
||||
# Only the MATCH labeled node should get the label
|
||||
assert result.count(LABEL) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# String and comment protection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProtection:
|
||||
def test_string_with_fake_node_pattern(self):
|
||||
cypher = "MATCH (n:AWSRole) WHERE n.name = '(fake:Label)' RETURN n"
|
||||
result = _inject(cypher)
|
||||
assert "'(fake:Label)'" in result
|
||||
assert result.count(LABEL) == 1
|
||||
|
||||
def test_double_quoted_string(self):
|
||||
cypher = 'MATCH (n:AWSRole) WHERE n.name = "(fake:Label)" RETURN n'
|
||||
result = _inject(cypher)
|
||||
assert '"(fake:Label)"' in result
|
||||
assert result.count(LABEL) == 1
|
||||
|
||||
def test_line_comment_with_node_pattern(self):
|
||||
cypher = "// (n:Fake)\nMATCH (n:AWSRole) RETURN n"
|
||||
result = _inject(cypher)
|
||||
assert "// (n:Fake)" in result
|
||||
assert result.count(LABEL) == 1
|
||||
|
||||
def test_string_containing_double_slash(self):
|
||||
"""Strings with // inside should be consumed as strings, not comments."""
|
||||
cypher = "MATCH (n:AWSRole {url: 'https://example.com'}) RETURN n"
|
||||
result = _inject(cypher)
|
||||
assert "'https://example.com'" in result
|
||||
assert f"(n:AWSRole:{LABEL}" in result
|
||||
|
||||
def test_escaped_quotes_in_string(self):
|
||||
cypher = r"MATCH (n:AWSRole) WHERE n.name = 'it\'s a test' RETURN n"
|
||||
result = _inject(cypher)
|
||||
assert result.count(LABEL) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Clause splitting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClauseSplitting:
|
||||
def test_case_insensitive_keywords(self):
|
||||
cypher = "match (n:AWSRole) where n.x = 1 return n"
|
||||
result = _inject(cypher)
|
||||
assert f"(n:AWSRole:{LABEL})" in result
|
||||
|
||||
def test_optional_match_with_extra_whitespace(self):
|
||||
cypher = "OPTIONAL MATCH (n:AWSRole) RETURN n"
|
||||
result = _inject(cypher)
|
||||
assert f"(n:AWSRole:{LABEL})" in result
|
||||
|
||||
def test_multiple_match_clauses(self):
|
||||
cypher = (
|
||||
"MATCH (a:AWSAccount)--(b:AWSRole) "
|
||||
"MATCH (b)--(c:AWSPolicy) "
|
||||
"RETURN a, b, c"
|
||||
)
|
||||
result = _inject(cypher)
|
||||
assert f"(a:AWSAccount:{LABEL})" in result
|
||||
assert f"(b:AWSRole:{LABEL})" in result
|
||||
assert f"(c:AWSPolicy:{LABEL})" in result
|
||||
# (b) in second MATCH is bare and gets injected
|
||||
assert result.count(LABEL) == 4 # a, b (labeled), b (bare in 2nd MATCH), c
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real-world query patterns from aws.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRealWorldQueries:
|
||||
def test_basic_resource_query(self):
|
||||
cypher = (
|
||||
"MATCH path = (aws:AWSAccount {id: $provider_uid})--(rds:RDSInstance)\n"
|
||||
"UNWIND nodes(path) as n\n"
|
||||
"OPTIONAL MATCH (n)-[pfr]-(pf:ProwlerFinding {status: 'FAIL'})\n"
|
||||
"RETURN path, collect(DISTINCT pf) as dpf"
|
||||
)
|
||||
result = _inject(cypher)
|
||||
assert f"(aws:AWSAccount:{LABEL} {{id: $provider_uid}})" in result
|
||||
assert f"(rds:RDSInstance:{LABEL})" in result
|
||||
assert f"(n:{LABEL})" in result
|
||||
assert f"(pf:ProwlerFinding:{LABEL} {{status: 'FAIL'}})" in result
|
||||
assert "nodes(path)" in result # function call untouched
|
||||
assert "collect(DISTINCT pf)" in result # function call untouched
|
||||
|
||||
def test_privilege_escalation_query(self):
|
||||
cypher = (
|
||||
"MATCH path_principal = (aws:AWSAccount {id: $uid})"
|
||||
"--(principal:AWSPrincipal)--(pol:AWSPolicy)\n"
|
||||
"WHERE pol.effect = 'Allow'\n"
|
||||
"MATCH (principal)--(cfn_policy:AWSPolicy)"
|
||||
"--(stmt_cfn:AWSPolicyStatement)\n"
|
||||
"WHERE any(action IN stmt_cfn.action WHERE toLower(action) = 'iam:passrole')\n"
|
||||
"MATCH path_target = (aws)--(target_role:AWSRole)"
|
||||
"-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {arn: 'cloudformation.amazonaws.com'})\n"
|
||||
"RETURN path_principal, path_target"
|
||||
)
|
||||
result = _inject(cypher)
|
||||
assert f"(aws:AWSAccount:{LABEL} {{id: $uid}})" in result
|
||||
assert f"(principal:AWSPrincipal:{LABEL})" in result
|
||||
assert f"(pol:AWSPolicy:{LABEL})" in result
|
||||
assert f"(principal:{LABEL})" in result # bare in 2nd MATCH
|
||||
assert f"(cfn_policy:AWSPolicy:{LABEL})" in result
|
||||
assert f"(stmt_cfn:AWSPolicyStatement:{LABEL})" in result
|
||||
assert f"(aws:{LABEL})" in result # bare in 3rd MATCH
|
||||
assert f"(target_role:AWSRole:{LABEL})" in result
|
||||
assert (
|
||||
f"(:AWSPrincipal:{LABEL} {{arn: 'cloudformation.amazonaws.com'}})" in result
|
||||
)
|
||||
# Function calls in WHERE untouched
|
||||
assert "any(action IN" in result
|
||||
assert "toLower(action)" in result
|
||||
|
||||
def test_custom_bare_query(self):
|
||||
cypher = (
|
||||
"MATCH (a)-[:HAS_POLICY]->(b)\n"
|
||||
"WHERE a.name CONTAINS 'admin'\n"
|
||||
"RETURN a, b"
|
||||
)
|
||||
result = _inject(cypher)
|
||||
assert f"(a:{LABEL})" in result
|
||||
assert f"(b:{LABEL})" in result
|
||||
assert result.count(LABEL) == 2
|
||||
|
||||
def test_internet_via_path_connectivity(self):
|
||||
"""Post-refactor pattern: Internet reached via CAN_ACCESS, not standalone."""
|
||||
cypher = (
|
||||
"MATCH path = (aws:AWSAccount {id: $provider_uid})--(ec2:EC2Instance)\n"
|
||||
"WHERE ec2.exposed_internet = true\n"
|
||||
"OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(ec2)\n"
|
||||
"RETURN path, internet, can_access"
|
||||
)
|
||||
result = _inject(cypher)
|
||||
assert f"(aws:AWSAccount:{LABEL}" in result
|
||||
assert f"(ec2:EC2Instance:{LABEL})" in result
|
||||
assert f"(internet:Internet:{LABEL})" in result
|
||||
# ec2 in OPTIONAL MATCH is bare, but already labeled via Pass A won't match it
|
||||
# because it has no label. It IS bare, so Pass B injects.
|
||||
assert f"(ec2:{LABEL})" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
def test_empty_query(self):
|
||||
assert _inject("") == ""
|
||||
|
||||
def test_no_node_patterns(self):
|
||||
cypher = "RETURN 1 + 2"
|
||||
assert _inject(cypher) == cypher
|
||||
|
||||
def test_anonymous_empty_parens_not_injected(self):
|
||||
"""Empty () in MATCH is extremely rare but should not be injected."""
|
||||
cypher = "MATCH ()--(m:AWSRole) RETURN m"
|
||||
result = _inject(cypher)
|
||||
assert "()" in result # empty parens untouched
|
||||
assert f"(m:AWSRole:{LABEL})" in result
|
||||
|
||||
def test_fully_anonymous_query_bypasses_injection(self):
|
||||
"""All-anonymous patterns bypass injection entirely.
|
||||
|
||||
MATCH ()--()--() has no labels and no variables, so neither Pass A
|
||||
(labeled) nor Pass B (bare identifier) can inject the provider label.
|
||||
This is safe because _serialize_graph() (Layer 3) filters every
|
||||
returned node by provider label, dropping cross-provider data before
|
||||
it reaches the user.
|
||||
"""
|
||||
cypher = "MATCH ()--()--() RETURN *"
|
||||
result = _inject(cypher)
|
||||
assert result == cypher # completely unmodified
|
||||
assert LABEL not in result
|
||||
|
||||
def test_relationship_patterns_untouched(self):
|
||||
cypher = "MATCH (a:X)-[r:REL_TYPE {x: 1}]->(b:Y) RETURN a"
|
||||
result = _inject(cypher)
|
||||
assert "[r:REL_TYPE {x: 1}]" in result # relationship untouched
|
||||
assert f"(a:X:{LABEL})" in result
|
||||
assert f"(b:Y:{LABEL})" in result
|
||||
|
||||
def test_call_subquery(self):
|
||||
cypher = (
|
||||
"CALL {\n"
|
||||
" MATCH (inner:AWSRole) RETURN inner\n"
|
||||
"}\n"
|
||||
"MATCH (outer:AWSAccount) RETURN outer, inner"
|
||||
)
|
||||
result = _inject(cypher)
|
||||
assert f"(inner:AWSRole:{LABEL})" in result
|
||||
assert f"(outer:AWSAccount:{LABEL})" in result
|
||||
|
||||
def test_multiple_protected_regions(self):
|
||||
cypher = (
|
||||
"MATCH (n:X {a: 'hello'}) " 'WHERE n.b = "world" ' "// comment\n" "RETURN n"
|
||||
)
|
||||
result = _inject(cypher)
|
||||
assert "'hello'" in result
|
||||
assert '"world"' in result
|
||||
assert "// comment" in result
|
||||
assert f"(n:X:{LABEL}" in result
|
||||
|
||||
def test_idempotent_on_already_injected(self):
|
||||
"""Running injection twice should add the label twice (not ideal, but predictable)."""
|
||||
first = _inject("MATCH (n:AWSRole) RETURN n")
|
||||
second = _inject(first)
|
||||
# The label appears twice (stacked)
|
||||
assert second.count(LABEL) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidation:
|
||||
@pytest.mark.parametrize(
|
||||
"cypher",
|
||||
[
|
||||
"LOAD CSV FROM 'http://169.254.169.254/' AS x RETURN x",
|
||||
"load csv from 'http://evil.com' as row return row",
|
||||
"CALL apoc.load.json('http://evil.com/') YIELD value RETURN value",
|
||||
"CALL apoc.load.csvParams('http://evil.com/', {}, null) YIELD list RETURN list",
|
||||
"CALL apoc.import.csv([{fileName: 'f'}], [], {}) YIELD node RETURN node",
|
||||
"CALL apoc.export.csv.all('file.csv', {})",
|
||||
"CALL apoc.cypher.run('CREATE (n)', {}) YIELD value RETURN value",
|
||||
"CALL apoc.systemdb.graph() YIELD nodes RETURN nodes",
|
||||
"CALL apoc.config.list() YIELD key, value RETURN key, value",
|
||||
"CALL apoc.periodic.iterate('MATCH (n) RETURN n', 'DELETE n', {batchSize: 100})",
|
||||
"CALL apoc.do.when(true, 'CREATE (n) RETURN n', '', {}) YIELD value RETURN value",
|
||||
"CALL apoc.trigger.add('t', 'RETURN 1', {phase: 'before'})",
|
||||
"CALL apoc.custom.asProcedure('myProc', 'RETURN 1')",
|
||||
],
|
||||
ids=[
|
||||
"LOAD_CSV",
|
||||
"LOAD_CSV_lowercase",
|
||||
"apoc.load.json",
|
||||
"apoc.load.csvParams",
|
||||
"apoc.import.csv",
|
||||
"apoc.export.csv",
|
||||
"apoc.cypher.run",
|
||||
"apoc.systemdb.graph",
|
||||
"apoc.config.list",
|
||||
"apoc.periodic.iterate",
|
||||
"apoc.do.when",
|
||||
"apoc.trigger.add",
|
||||
"apoc.custom.asProcedure",
|
||||
],
|
||||
)
|
||||
def test_rejects_blocked_patterns(self, cypher):
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
validate_custom_query(cypher)
|
||||
|
||||
assert "blocked operation" in str(exc.value.detail)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cypher",
|
||||
[
|
||||
"MATCH (n:AWSAccount) RETURN n LIMIT 10",
|
||||
"MATCH (a)-[r]->(b) RETURN a, r, b",
|
||||
"MATCH (n) WHERE n.name CONTAINS 'load' RETURN n",
|
||||
"CALL apoc.create.vNode(['Label'], {}) YIELD node RETURN node",
|
||||
"MATCH (n) WHERE n.name = 'apoc.load.json' RETURN n",
|
||||
'MATCH (n) WHERE n.description = "LOAD CSV is cool" RETURN n',
|
||||
],
|
||||
ids=[
|
||||
"simple_match",
|
||||
"traversal",
|
||||
"contains_load_substring",
|
||||
"apoc_virtual_node",
|
||||
"apoc_load_inside_single_quotes",
|
||||
"load_csv_inside_double_quotes",
|
||||
],
|
||||
)
|
||||
def test_allows_clean_queries(self, cypher):
|
||||
validate_custom_query(cypher)
|
||||
@@ -6,10 +6,12 @@ import pytest
|
||||
from django.conf import settings
|
||||
from django.db import DEFAULT_DB_ALIAS, OperationalError
|
||||
from freezegun import freeze_time
|
||||
from psycopg2 import sql as psycopg2_sql
|
||||
from rest_framework_json_api.serializers import ValidationError
|
||||
|
||||
from api.db_utils import (
|
||||
POSTGRES_TENANT_VAR,
|
||||
PostgresEnumMigration,
|
||||
_should_create_index_on_partition,
|
||||
batch_delete,
|
||||
create_objects_in_batches,
|
||||
@@ -910,3 +912,61 @@ class TestRlsTransaction:
|
||||
cursor.execute("SELECT 1")
|
||||
result = cursor.fetchone()
|
||||
assert result[0] == 1
|
||||
|
||||
|
||||
class TestPostgresEnumMigration:
|
||||
"""
|
||||
Verify that PostgresEnumMigration builds DDL statements via psycopg2.sql
|
||||
so that enum type names and values are always properly quoted — preventing
|
||||
SQL injection through f-string interpolation.
|
||||
"""
|
||||
|
||||
def _make_mock_schema_editor(self):
|
||||
mock_cursor = MagicMock()
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
|
||||
mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
|
||||
mock_schema_editor = MagicMock()
|
||||
mock_schema_editor.connection = mock_conn
|
||||
return mock_schema_editor, mock_cursor
|
||||
|
||||
def test_create_enum_type_generates_correct_sql(self):
|
||||
"""create_enum_type builds a proper CREATE TYPE … AS ENUM via psycopg2.sql."""
|
||||
migration = PostgresEnumMigration("my_enum", ("val_a", "val_b"))
|
||||
schema_editor, mock_cursor = self._make_mock_schema_editor()
|
||||
|
||||
migration.create_enum_type(apps=None, schema_editor=schema_editor)
|
||||
|
||||
mock_cursor.execute.assert_called_once()
|
||||
query_arg = mock_cursor.execute.call_args[0][0]
|
||||
assert isinstance(
|
||||
query_arg, psycopg2_sql.Composable
|
||||
), "create_enum_type must pass a psycopg2.sql.Composable, not a raw string."
|
||||
# Verify the composed SQL structure: CREATE TYPE <Identifier> AS ENUM (<Literals>)
|
||||
parts = query_arg.seq
|
||||
assert parts[0] == psycopg2_sql.SQL("CREATE TYPE ")
|
||||
assert isinstance(parts[1], psycopg2_sql.Identifier)
|
||||
assert parts[1].strings == ("my_enum",)
|
||||
assert parts[2] == psycopg2_sql.SQL(" AS ENUM (")
|
||||
# The enum values are a Composed of Literal items joined by ", "
|
||||
enum_literals = [p for p in parts[3].seq if isinstance(p, psycopg2_sql.Literal)]
|
||||
assert [lit._wrapped for lit in enum_literals] == ["val_a", "val_b"]
|
||||
assert parts[4] == psycopg2_sql.SQL(")")
|
||||
|
||||
def test_drop_enum_type_generates_correct_sql(self):
|
||||
"""drop_enum_type builds a proper DROP TYPE via psycopg2.sql."""
|
||||
migration = PostgresEnumMigration("my_enum", ("val_a",))
|
||||
schema_editor, mock_cursor = self._make_mock_schema_editor()
|
||||
|
||||
migration.drop_enum_type(apps=None, schema_editor=schema_editor)
|
||||
|
||||
mock_cursor.execute.assert_called_once()
|
||||
query_arg = mock_cursor.execute.call_args[0][0]
|
||||
assert isinstance(
|
||||
query_arg, psycopg2_sql.Composable
|
||||
), "drop_enum_type must pass a psycopg2.sql.Composable, not a raw string."
|
||||
# Verify the composed SQL structure: DROP TYPE <Identifier>
|
||||
parts = query_arg.seq
|
||||
assert parts[0] == psycopg2_sql.SQL("DROP TYPE ")
|
||||
assert isinstance(parts[1], psycopg2_sql.Identifier)
|
||||
assert parts[1].strings == ("my_enum",)
|
||||
|
||||
@@ -243,6 +243,39 @@ class TestSAMLConfigurationModel:
|
||||
assert "Invalid XML" in errors["metadata_xml"][0]
|
||||
assert "not well-formed" in errors["metadata_xml"][0]
|
||||
|
||||
def test_xml_bomb_rejected(self, tenants_fixture):
|
||||
"""
|
||||
Regression test: a 'billion laughs' XML bomb in the SAML metadata field
|
||||
must be rejected and not allowed to exhaust server memory / CPU.
|
||||
|
||||
Before the fix, xml.etree.ElementTree was used directly, which does not
|
||||
protect against entity-expansion attacks. The fix switches to defusedxml
|
||||
which raises an exception for any XML containing entity definitions.
|
||||
"""
|
||||
tenant = tenants_fixture[0]
|
||||
xml_bomb = (
|
||||
"<?xml version='1.0'?>"
|
||||
"<!DOCTYPE bomb ["
|
||||
" <!ENTITY a 'aaaaaaaaaa'>"
|
||||
" <!ENTITY b '&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;'>"
|
||||
" <!ENTITY c '&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;'>"
|
||||
" <!ENTITY d '&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;'>"
|
||||
"]>"
|
||||
"<md:EntityDescriptor entityID='&d;' "
|
||||
"xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'/>"
|
||||
)
|
||||
config = SAMLConfiguration(
|
||||
email_domain="xmlbomb.com",
|
||||
metadata_xml=xml_bomb,
|
||||
tenant=tenant,
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
config._parse_metadata()
|
||||
|
||||
errors = exc_info.value.message_dict
|
||||
assert "metadata_xml" in errors
|
||||
|
||||
def test_metadata_missing_sso_fails(self, tenants_fixture):
|
||||
tenant = tenants_fixture[0]
|
||||
xml = """<md:EntityDescriptor entityID="x" xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata">
|
||||
|
||||
@@ -4,14 +4,25 @@ from unittest.mock import MagicMock
|
||||
from config.settings.sentry import before_send
|
||||
|
||||
|
||||
def _make_log_record(msg, level=logging.ERROR, name="test", args=None):
|
||||
"""Build a real LogRecord so getMessage() works like in production."""
|
||||
record = logging.LogRecord(
|
||||
name=name,
|
||||
level=level,
|
||||
pathname="",
|
||||
lineno=0,
|
||||
msg=msg,
|
||||
args=args,
|
||||
exc_info=None,
|
||||
)
|
||||
return record
|
||||
|
||||
|
||||
def test_before_send_ignores_log_with_ignored_exception():
|
||||
"""Test that before_send ignores logs containing ignored exceptions."""
|
||||
log_record = MagicMock()
|
||||
log_record.msg = "Provider kubernetes is not connected"
|
||||
log_record.levelno = logging.ERROR # 40
|
||||
log_record = _make_log_record("Provider kubernetes is not connected")
|
||||
|
||||
hint = {"log_record": log_record}
|
||||
|
||||
event = MagicMock()
|
||||
|
||||
result = before_send(event, hint)
|
||||
@@ -36,12 +47,9 @@ def test_before_send_ignores_exception_with_ignored_exception():
|
||||
|
||||
def test_before_send_passes_through_non_ignored_log():
|
||||
"""Test that before_send passes through logs that don't contain ignored exceptions."""
|
||||
log_record = MagicMock()
|
||||
log_record.msg = "Some other error message"
|
||||
log_record.levelno = logging.ERROR # 40
|
||||
log_record = _make_log_record("Some other error message")
|
||||
|
||||
hint = {"log_record": log_record}
|
||||
|
||||
event = MagicMock()
|
||||
|
||||
result = before_send(event, hint)
|
||||
@@ -66,15 +74,53 @@ def test_before_send_passes_through_non_ignored_exception():
|
||||
|
||||
def test_before_send_handles_warning_level():
|
||||
"""Test that before_send handles warning level logs."""
|
||||
log_record = MagicMock()
|
||||
log_record.msg = "Provider kubernetes is not connected"
|
||||
log_record.levelno = logging.WARNING # 30
|
||||
log_record = _make_log_record(
|
||||
"Provider kubernetes is not connected", level=logging.WARNING
|
||||
)
|
||||
|
||||
hint = {"log_record": log_record}
|
||||
|
||||
event = MagicMock()
|
||||
|
||||
result = before_send(event, hint)
|
||||
|
||||
# Assert that the event was dropped (None returned)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_before_send_ignores_neo4j_defunct_connection():
|
||||
"""Test that before_send drops neo4j.io defunct connection logs.
|
||||
|
||||
The Neo4j driver logs transient connection errors at ERROR level
|
||||
before RetryableSession retries them. These are noise.
|
||||
|
||||
The driver uses %s formatting, so "defunct" is in the args, not
|
||||
in the template. This test mirrors the real LogRecord structure.
|
||||
"""
|
||||
log_record = _make_log_record(
|
||||
msg="[#%04X] _: <CONNECTION> error: %s: %r",
|
||||
name="neo4j.io",
|
||||
args=(
|
||||
0xE5CC,
|
||||
"Failed to read from defunct connection "
|
||||
"IPv4Address(('cloud-neo4j.prowler.com', 7687))",
|
||||
ConnectionResetError(104, "Connection reset by peer"),
|
||||
),
|
||||
)
|
||||
|
||||
hint = {"log_record": log_record}
|
||||
event = MagicMock()
|
||||
|
||||
assert before_send(event, hint) is None
|
||||
|
||||
|
||||
def test_before_send_passes_non_defunct_neo4j_log():
|
||||
"""Test that before_send passes through neo4j.io logs that are not about defunct connections."""
|
||||
log_record = _make_log_record(
|
||||
msg="Some other neo4j transport error",
|
||||
name="neo4j.io",
|
||||
)
|
||||
|
||||
hint = {"log_record": log_record}
|
||||
event = MagicMock()
|
||||
|
||||
assert before_send(event, hint) == event
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1241,7 +1241,7 @@ class AttackPathsQueryRunRequestSerializer(BaseSerializerV1):
|
||||
|
||||
|
||||
class AttackPathsCustomQueryRunRequestSerializer(BaseSerializerV1):
|
||||
query = serializers.CharField()
|
||||
query = serializers.CharField(max_length=10000, min_length=1, trim_whitespace=True)
|
||||
|
||||
class JSONAPIMeta:
|
||||
resource_name = "attack-paths-custom-query-run-requests"
|
||||
@@ -4180,6 +4180,7 @@ class FindingGroupResourceSerializer(BaseSerializerV1):
|
||||
severity = serializers.CharField()
|
||||
first_seen_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||
last_seen_at = serializers.DateTimeField(required=False, allow_null=True)
|
||||
muted_reason = serializers.CharField(required=False, allow_null=True)
|
||||
|
||||
class JSONAPIMeta:
|
||||
resource_name = "finding-group-resources"
|
||||
@@ -4193,6 +4194,7 @@ class FindingGroupResourceSerializer(BaseSerializerV1):
|
||||
"service": {"type": "string"},
|
||||
"region": {"type": "string"},
|
||||
"type": {"type": "string"},
|
||||
"resource_group": {"type": "string"},
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -4204,6 +4206,7 @@ class FindingGroupResourceSerializer(BaseSerializerV1):
|
||||
"service": obj.get("resource_service", ""),
|
||||
"region": obj.get("resource_region", ""),
|
||||
"type": obj.get("resource_type", ""),
|
||||
"resource_group": obj.get("resource_group", ""),
|
||||
}
|
||||
|
||||
@extend_schema_field(
|
||||
|
||||
@@ -51,6 +51,13 @@ from api.v1.views import (
|
||||
)
|
||||
|
||||
|
||||
# This helper view is used to block any endpoints that should not be available
|
||||
# To use it, add a new entry in the `urlpatterns` list, for example (old but real one):
|
||||
# path(
|
||||
# "attack-paths-scans/<uuid:pk>/queries/custom",
|
||||
# _blocked_endpoint,
|
||||
# name="attack-paths-scans-queries-custom-blocked",
|
||||
# ),
|
||||
@csrf_exempt
|
||||
def _blocked_endpoint(request, *args, **kwargs):
|
||||
return JsonResponse(
|
||||
@@ -209,17 +216,6 @@ urlpatterns = [
|
||||
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"),
|
||||
# TODO: Remove these blocked endpoints once they are properly tested
|
||||
path(
|
||||
"attack-paths-scans/<uuid:pk>/queries/custom",
|
||||
_blocked_endpoint,
|
||||
name="attack-paths-scans-queries-custom-blocked",
|
||||
),
|
||||
path(
|
||||
"attack-paths-scans/<uuid:pk>/schema",
|
||||
_blocked_endpoint,
|
||||
name="attack-paths-scans-schema-blocked",
|
||||
),
|
||||
path("", include(router.urls)),
|
||||
path("", include(tenants_router.urls)),
|
||||
path("", include(users_router.urls)),
|
||||
|
||||
+312
-124
@@ -4,7 +4,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -12,12 +11,12 @@ from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import sentry_sdk
|
||||
|
||||
from allauth.socialaccount.models import SocialAccount, SocialApp
|
||||
from allauth.socialaccount.providers.github.views import GitHubOAuth2Adapter
|
||||
from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter
|
||||
from allauth.socialaccount.providers.saml.views import FinishACSView, LoginView
|
||||
from botocore.exceptions import ClientError, NoCredentialsError, ParamValidationError
|
||||
from celery import chain
|
||||
from celery.result import AsyncResult
|
||||
from config.custom_logging import BackendLogger
|
||||
from config.env import env
|
||||
@@ -32,6 +31,7 @@ from django.contrib.postgres.search import SearchQuery
|
||||
from django.db import transaction
|
||||
from django.db.models import (
|
||||
Case,
|
||||
CharField,
|
||||
Count,
|
||||
DecimalField,
|
||||
ExpressionWrapper,
|
||||
@@ -48,7 +48,8 @@ from django.db.models import (
|
||||
When,
|
||||
Window,
|
||||
)
|
||||
from django.db.models.functions import Coalesce, RowNumber
|
||||
from django.db.models.fields.json import KeyTextTransform
|
||||
from django.db.models.functions import Cast, Coalesce, RowNumber
|
||||
from django.http import HttpResponse, QueryDict
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
@@ -76,6 +77,7 @@ from rest_framework.exceptions import (
|
||||
)
|
||||
from rest_framework.generics import GenericAPIView, get_object_or_404
|
||||
from rest_framework.permissions import SAFE_METHODS
|
||||
from rest_framework_json_api import filters as jsonapi_filters
|
||||
from rest_framework_json_api.views import RelationshipView, Response
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||
from tasks.beat import schedule_provider_scan
|
||||
@@ -93,6 +95,7 @@ from tasks.tasks import (
|
||||
jira_integration_task,
|
||||
mute_historical_findings_task,
|
||||
perform_scan_task,
|
||||
reaggregate_all_finding_group_summaries_task,
|
||||
refresh_lighthouse_provider_models_task,
|
||||
)
|
||||
|
||||
@@ -100,7 +103,6 @@ from api.attack_paths import database as graph_database
|
||||
from api.attack_paths import get_queries_for_provider, get_query_by_id
|
||||
from api.attack_paths import views_helpers as attack_paths_views_helpers
|
||||
from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset
|
||||
from api.renderers import APIJSONRenderer, PlainTextRenderer
|
||||
from api.compliance import (
|
||||
PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
|
||||
get_compliance_frameworks,
|
||||
@@ -124,6 +126,7 @@ from api.filters import (
|
||||
CustomDjangoFilterBackend,
|
||||
DailySeveritySummaryFilter,
|
||||
FindingFilter,
|
||||
FindingGroupAggregatedComputedFilter,
|
||||
FindingGroupFilter,
|
||||
FindingGroupSummaryFilter,
|
||||
IntegrationFilter,
|
||||
@@ -199,6 +202,7 @@ from api.models import (
|
||||
)
|
||||
from api.pagination import ComplianceOverviewPagination
|
||||
from api.rbac.permissions import Permissions, get_providers, get_role
|
||||
from api.renderers import APIJSONRenderer, PlainTextRenderer
|
||||
from api.rls import Tenant
|
||||
from api.utils import (
|
||||
CustomOAuth2Client,
|
||||
@@ -408,7 +412,7 @@ class SchemaView(SpectacularAPIView):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
spectacular_settings.TITLE = "Prowler API"
|
||||
spectacular_settings.VERSION = "1.21.0"
|
||||
spectacular_settings.VERSION = "1.24.0"
|
||||
spectacular_settings.DESCRIPTION = (
|
||||
"Prowler API specification.\n\nThis file is auto-generated."
|
||||
)
|
||||
@@ -1207,6 +1211,17 @@ class TenantViewSet(BaseTenantViewset):
|
||||
# RBAC required permissions
|
||||
required_permissions = [Permissions.MANAGE_ACCOUNT]
|
||||
|
||||
def set_required_permissions(self):
|
||||
"""
|
||||
Returns the required permissions based on the request method.
|
||||
"""
|
||||
if self.action in ("list", "retrieve", "create"):
|
||||
# No permissions required for listing, retrieving or creating tenants
|
||||
self.required_permissions = []
|
||||
else:
|
||||
# Require MANAGE_ACCOUNT for update and delete
|
||||
self.required_permissions = [Permissions.MANAGE_ACCOUNT]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Tenant.objects.filter(membership__user=self.request.user)
|
||||
return queryset.prefetch_related("memberships")
|
||||
@@ -2452,6 +2467,11 @@ class AttackPathsScanViewSet(BaseRLSViewSet):
|
||||
# RBAC required permissions
|
||||
required_permissions = [Permissions.MANAGE_SCANS]
|
||||
|
||||
def get_throttles(self):
|
||||
if self.action == "run_custom_attack_paths_query":
|
||||
self.throttle_scope = "attack-paths-custom-query"
|
||||
return super().get_throttles()
|
||||
|
||||
def set_required_permissions(self):
|
||||
if self.request.method in SAFE_METHODS:
|
||||
self.required_permissions = []
|
||||
@@ -3463,7 +3483,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
request,
|
||||
filtered_queryset,
|
||||
manager=Finding.all_objects,
|
||||
select_related=["scan"],
|
||||
select_related=["scan__provider"],
|
||||
prefetch_related=["resources"],
|
||||
)
|
||||
|
||||
@@ -3633,7 +3653,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
tenant_id = request.tenant_id
|
||||
filtered_queryset = self.filter_queryset(self.get_queryset())
|
||||
|
||||
latest_scan_ids = (
|
||||
latest_scan_ids = list(
|
||||
Scan.all_objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
|
||||
.order_by("provider_id", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
@@ -3647,7 +3667,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
request,
|
||||
filtered_queryset,
|
||||
manager=Finding.all_objects,
|
||||
select_related=["scan"],
|
||||
select_related=["scan__provider"],
|
||||
prefetch_related=["resources"],
|
||||
)
|
||||
|
||||
@@ -6720,11 +6740,18 @@ class MuteRuleViewSet(BaseRLSViewSet):
|
||||
muted_reason=mute_rule.reason,
|
||||
)
|
||||
|
||||
# Launch background task for historical muting
|
||||
with transaction.atomic():
|
||||
mute_historical_findings_task.apply_async(
|
||||
kwargs={"tenant_id": tenant_id, "mute_rule_id": str(mute_rule.id)}
|
||||
)
|
||||
# Launch background task for historical muting + reaggregation
|
||||
transaction.on_commit(
|
||||
lambda: chain(
|
||||
mute_historical_findings_task.si(
|
||||
tenant_id=tenant_id,
|
||||
mute_rule_id=str(mute_rule.id),
|
||||
),
|
||||
reaggregate_all_finding_group_summaries_task.si(
|
||||
tenant_id=tenant_id,
|
||||
),
|
||||
).apply_async()
|
||||
)
|
||||
|
||||
# Return the created mute rule
|
||||
serializer = self.get_serializer(mute_rule)
|
||||
@@ -6765,21 +6792,37 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
security analysts to see which checks are failing across their
|
||||
infrastructure without scrolling through thousands of individual findings.
|
||||
|
||||
Uses pre-aggregated FindingGroupDailySummary table for efficient queries.
|
||||
Daily summaries are re-aggregated across the requested date range.
|
||||
Uses a hybrid strategy: pre-aggregated daily summaries when possible,
|
||||
and raw findings when finding-level filters require precise subset metrics.
|
||||
"""
|
||||
|
||||
queryset = FindingGroupDailySummary.objects.all()
|
||||
serializer_class = FindingGroupSerializer
|
||||
filterset_class = FindingGroupSummaryFilter
|
||||
filterset_class = FindingGroupFilter
|
||||
filter_backends = [
|
||||
jsonapi_filters.QueryParameterValidationFilter,
|
||||
jsonapi_filters.OrderingFilter,
|
||||
CustomDjangoFilterBackend,
|
||||
]
|
||||
http_method_names = ["get"]
|
||||
required_permissions = []
|
||||
|
||||
def get_filterset_class(self):
|
||||
"""Return appropriate filter based on action."""
|
||||
"""Return the filterset class used for schema generation and the list action.
|
||||
|
||||
Note: The resources and latest_resources actions do not use this method
|
||||
at runtime. They manually instantiate FindingGroupFilter /
|
||||
LatestFindingGroupFilter against a Finding queryset (see
|
||||
_get_finding_queryset). The class returned here for those actions only
|
||||
affects the OpenAPI schema generated by drf-spectacular.
|
||||
"""
|
||||
if self.action == "latest":
|
||||
return LatestFindingGroupSummaryFilter
|
||||
return FindingGroupSummaryFilter
|
||||
return LatestFindingGroupFilter
|
||||
if self.action == "resources":
|
||||
return FindingGroupFilter
|
||||
if self.action == "latest_resources":
|
||||
return LatestFindingGroupFilter
|
||||
return FindingGroupFilter
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get the base FindingGroupDailySummary queryset with RLS filtering."""
|
||||
@@ -6835,8 +6878,15 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
"resource_type__icontains": "type__icontains",
|
||||
}
|
||||
|
||||
# Fields accepted directly by LatestResourceFilter (no translation needed)
|
||||
_RESOURCE_FILTER_FIELDS = {
|
||||
f"{field}__{lookup}"
|
||||
for field, lookups in LatestResourceFilter.Meta.fields.items()
|
||||
for lookup in lookups
|
||||
} | set(LatestResourceFilter.Meta.fields.keys())
|
||||
|
||||
def _split_resource_filters(self, params: QueryDict) -> tuple[QueryDict, QueryDict]:
|
||||
resource_keys = set(self.RESOURCE_FILTER_MAP)
|
||||
resource_keys = set(self.RESOURCE_FILTER_MAP) | self._RESOURCE_FILTER_FIELDS
|
||||
finding_params = QueryDict(mutable=True)
|
||||
resource_params = QueryDict(mutable=True)
|
||||
for key, values in params.lists():
|
||||
@@ -6857,11 +6907,16 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
queryset = queryset.filter(tenant_id=tenant_id)
|
||||
|
||||
filter_params = QueryDict(mutable=True)
|
||||
for key, mapped_key in self.RESOURCE_FILTER_MAP.items():
|
||||
if key not in params:
|
||||
for key, values in params.lists():
|
||||
# Translate resource_* prefixed keys via the map
|
||||
if key in self.RESOURCE_FILTER_MAP:
|
||||
mapped_key = self.RESOURCE_FILTER_MAP[key]
|
||||
elif key in self._RESOURCE_FILTER_FIELDS:
|
||||
mapped_key = key
|
||||
else:
|
||||
continue
|
||||
|
||||
if key == "resources" or key.endswith("__in"):
|
||||
values = params.getlist(key)
|
||||
items: list[str] = []
|
||||
for value in values:
|
||||
if value is None:
|
||||
@@ -6886,20 +6941,27 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
|
||||
return filterset.qs.values("id")
|
||||
|
||||
def _get_finding_level_filter_keys(self, latest: bool = False) -> set[str]:
|
||||
"""Derive filters that require querying raw findings."""
|
||||
summary_filterset = (
|
||||
LatestFindingGroupSummaryFilter if latest else FindingGroupSummaryFilter
|
||||
)
|
||||
finding_filterset = LatestFindingGroupFilter if latest else FindingGroupFilter
|
||||
|
||||
summary_supported = set(summary_filterset.base_filters.keys())
|
||||
finding_supported = set(finding_filterset.base_filters.keys())
|
||||
return finding_supported - summary_supported
|
||||
|
||||
def _requires_finding_level_aggregation(
|
||||
self, params: QueryDict, latest: bool = False
|
||||
) -> bool:
|
||||
finding_level_keys = self._get_finding_level_filter_keys(latest=latest)
|
||||
return any(key in finding_level_keys for key in params.keys())
|
||||
|
||||
def _aggregate_daily_summaries(self, queryset):
|
||||
"""
|
||||
Re-aggregate daily summaries across the date range.
|
||||
|
||||
Takes pre-computed daily summaries and aggregates them by check_id
|
||||
to produce totals across the selected date range.
|
||||
"""
|
||||
from django.db.models import CharField
|
||||
from django.db.models.functions import Cast
|
||||
|
||||
"""Re-aggregate summary rows by check_id."""
|
||||
return queryset.values("check_id").annotate(
|
||||
# Max severity across days
|
||||
severity_order=Max("severity_order"),
|
||||
# Sum counts across days
|
||||
pass_count=Sum("pass_count"),
|
||||
fail_count=Sum("fail_count"),
|
||||
muted_count=Sum("muted_count"),
|
||||
@@ -6907,22 +6969,99 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
changed_count=Sum("changed_count"),
|
||||
resources_total=Sum("resources_total"),
|
||||
resources_fail=Sum("resources_fail"),
|
||||
# Collect provider types using StringAgg (cast enum to text first)
|
||||
impacted_providers_str=StringAgg(
|
||||
Cast("provider__provider", CharField()),
|
||||
delimiter=",",
|
||||
distinct=True,
|
||||
default="",
|
||||
),
|
||||
# Min/Max timing across days
|
||||
first_seen_at=Min("first_seen_at"),
|
||||
last_seen_at=Max("last_seen_at"),
|
||||
failing_since=Min("failing_since"),
|
||||
# Get check metadata from first row (same for all days)
|
||||
agg_first_seen_at=Min("first_seen_at"),
|
||||
agg_last_seen_at=Max("last_seen_at"),
|
||||
agg_failing_since=Min("failing_since"),
|
||||
check_title=Max("check_title"),
|
||||
check_description=Max("check_description"),
|
||||
)
|
||||
|
||||
def _aggregate_findings(self, queryset):
|
||||
"""Aggregate findings by check_id for finding-group endpoints."""
|
||||
severity_case = Case(
|
||||
*[
|
||||
When(severity=severity, then=Value(order))
|
||||
for severity, order in SEVERITY_ORDER.items()
|
||||
],
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
|
||||
return queryset.values("check_id").annotate(
|
||||
severity_order=Max(severity_case),
|
||||
pass_count=Count("id", filter=Q(status="PASS", muted=False)),
|
||||
fail_count=Count("id", filter=Q(status="FAIL", muted=False)),
|
||||
muted_count=Count("id", filter=Q(muted=True)),
|
||||
new_count=Count("id", filter=Q(delta="new", muted=False)),
|
||||
changed_count=Count("id", filter=Q(delta="changed", muted=False)),
|
||||
resources_total=Count("resources__id", distinct=True),
|
||||
resources_fail=Count(
|
||||
"resources__id",
|
||||
distinct=True,
|
||||
filter=Q(status="FAIL", muted=False),
|
||||
),
|
||||
impacted_providers_str=StringAgg(
|
||||
Cast("scan__provider__provider", CharField()),
|
||||
delimiter=",",
|
||||
distinct=True,
|
||||
default="",
|
||||
),
|
||||
agg_first_seen_at=Min("first_seen_at"),
|
||||
agg_last_seen_at=Max("inserted_at"),
|
||||
agg_failing_since=Min(
|
||||
"first_seen_at", filter=Q(status="FAIL", muted=False)
|
||||
),
|
||||
check_title=Coalesce(
|
||||
Max(KeyTextTransform("checktitle", "check_metadata")),
|
||||
Max(KeyTextTransform("CheckTitle", "check_metadata")),
|
||||
Max(KeyTextTransform("Checktitle", "check_metadata")),
|
||||
),
|
||||
check_description=Coalesce(
|
||||
Max(KeyTextTransform("description", "check_metadata")),
|
||||
Max(KeyTextTransform("Description", "check_metadata")),
|
||||
),
|
||||
)
|
||||
|
||||
def _split_computed_aggregate_filters(
|
||||
self, params: QueryDict
|
||||
) -> tuple[QueryDict, QueryDict]:
|
||||
"""Split finding filters from computed aggregate filters."""
|
||||
computed_keys = {
|
||||
"status",
|
||||
"status__in",
|
||||
"severity",
|
||||
"severity__in",
|
||||
"include_muted",
|
||||
}
|
||||
finding_params = QueryDict(mutable=True)
|
||||
computed_params = QueryDict(mutable=True)
|
||||
|
||||
for key, values in params.lists():
|
||||
if key in computed_keys:
|
||||
computed_params.setlist(key, values)
|
||||
else:
|
||||
finding_params.setlist(key, values)
|
||||
|
||||
return finding_params, computed_params
|
||||
|
||||
def _get_latest_findings_per_provider(self, filtered_queryset):
|
||||
"""Keep only findings from each provider's most recent completed scan."""
|
||||
latest_scan_ids = (
|
||||
Scan.objects.filter(
|
||||
tenant_id=self.request.tenant_id,
|
||||
state=StateChoices.COMPLETED,
|
||||
)
|
||||
.order_by("provider_id", "-completed_at", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values("id")
|
||||
)
|
||||
return filtered_queryset.filter(scan_id__in=latest_scan_ids)
|
||||
|
||||
def _post_process_aggregation(self, aggregated_data):
|
||||
"""
|
||||
Post-process aggregation results to add computed fields.
|
||||
@@ -6939,6 +7078,13 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
severity_order, "informational"
|
||||
)
|
||||
|
||||
if "agg_first_seen_at" in row:
|
||||
row["first_seen_at"] = row.pop("agg_first_seen_at")
|
||||
if "agg_last_seen_at" in row:
|
||||
row["last_seen_at"] = row.pop("agg_last_seen_at")
|
||||
if "agg_failing_since" in row:
|
||||
row["failing_since"] = row.pop("agg_failing_since")
|
||||
|
||||
# Compute aggregated status
|
||||
if row.get("fail_count", 0) > 0:
|
||||
row["status"] = "FAIL"
|
||||
@@ -6961,6 +7107,7 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
"""Validate and map JSON:API sort fields for aggregated finding groups."""
|
||||
sort_field_map = {
|
||||
"check_id": "check_id",
|
||||
"check_title": "check_title",
|
||||
"severity": "severity_order",
|
||||
"fail_count": "fail_count",
|
||||
"pass_count": "pass_count",
|
||||
@@ -6969,9 +7116,9 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
"changed_count": "changed_count",
|
||||
"resources_total": "resources_total",
|
||||
"resources_fail": "resources_fail",
|
||||
"first_seen_at": "first_seen_at",
|
||||
"last_seen_at": "last_seen_at",
|
||||
"failing_since": "failing_since",
|
||||
"first_seen_at": "agg_first_seen_at",
|
||||
"last_seen_at": "agg_last_seen_at",
|
||||
"failing_since": "agg_failing_since",
|
||||
}
|
||||
|
||||
ordering = []
|
||||
@@ -6998,6 +7145,33 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
|
||||
return ordering
|
||||
|
||||
def _apply_aggregated_computed_filters(self, queryset, computed_params: QueryDict):
|
||||
"""Apply computed filters (status/severity) on aggregated finding-group rows."""
|
||||
if not computed_params:
|
||||
return queryset
|
||||
|
||||
if computed_params.get("status") or computed_params.getlist("status__in"):
|
||||
queryset = queryset.annotate(
|
||||
aggregated_status=Case(
|
||||
When(fail_count__gt=0, then=Value("FAIL")),
|
||||
When(pass_count__gt=0, then=Value("PASS")),
|
||||
default=Value("MUTED"),
|
||||
output_field=CharField(),
|
||||
)
|
||||
)
|
||||
|
||||
# Exclude fully-muted groups by default unless include_muted is set
|
||||
if "include_muted" not in computed_params:
|
||||
queryset = queryset.exclude(fail_count=0, pass_count=0, muted_count__gt=0)
|
||||
|
||||
filterset = FindingGroupAggregatedComputedFilter(
|
||||
computed_params, queryset=queryset
|
||||
)
|
||||
if not filterset.is_valid():
|
||||
raise ValidationError(filterset.errors)
|
||||
|
||||
return filterset.qs
|
||||
|
||||
def _build_resource_mapping_queryset(
|
||||
self, filtered_queryset, resource_ids=None, tenant_id: str | None = None
|
||||
):
|
||||
@@ -7070,6 +7244,13 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
),
|
||||
first_seen_at=Min("finding__first_seen_at"),
|
||||
last_seen_at=Max("finding__inserted_at"),
|
||||
# Max() on muted_reason / check_metadata is safe because
|
||||
# all findings for the same resource+check share identical
|
||||
# values (mute rules and metadata are applied per-check).
|
||||
muted_reason=Max("finding__muted_reason"),
|
||||
resource_group=Max(
|
||||
KeyTextTransform("resourcegroup", "finding__check_metadata")
|
||||
),
|
||||
)
|
||||
.filter(resource_id__isnull=False)
|
||||
.order_by("resource_id")
|
||||
@@ -7105,56 +7286,95 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
),
|
||||
"first_seen_at": row["first_seen_at"],
|
||||
"last_seen_at": row["last_seen_at"],
|
||||
"muted_reason": row.get("muted_reason"),
|
||||
"resource_group": row.get("resource_group", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _build_aggregated_queryset(self, finding_params, latest=False):
|
||||
"""Select the summary or findings path and return an aggregated queryset."""
|
||||
finding_filterset_class = (
|
||||
LatestFindingGroupFilter if latest else FindingGroupFilter
|
||||
)
|
||||
summary_filterset_class = (
|
||||
LatestFindingGroupSummaryFilter if latest else FindingGroupSummaryFilter
|
||||
)
|
||||
|
||||
if self._requires_finding_level_aggregation(finding_params, latest=latest):
|
||||
finding_queryset = self._get_finding_queryset()
|
||||
filterset = finding_filterset_class(
|
||||
finding_params, queryset=finding_queryset
|
||||
)
|
||||
if not filterset.is_valid():
|
||||
raise ValidationError(filterset.errors)
|
||||
filtered_queryset = filterset.qs
|
||||
if latest:
|
||||
filtered_queryset = self._get_latest_findings_per_provider(
|
||||
filtered_queryset
|
||||
)
|
||||
return self._aggregate_findings(filtered_queryset)
|
||||
|
||||
summary_queryset = self.get_queryset()
|
||||
filterset = summary_filterset_class(finding_params, queryset=summary_queryset)
|
||||
if not filterset.is_valid():
|
||||
raise ValidationError(filterset.errors)
|
||||
filtered_queryset = filterset.qs
|
||||
# Only include summaries from each provider's most recent date
|
||||
# (within the filtered range).
|
||||
# We use a subquery to strip the Window annotation so it does not
|
||||
# leak into the GROUP BY of _aggregate_daily_summaries.
|
||||
latest_per_provider = filtered_queryset.annotate(
|
||||
_max_provider_date=Window(
|
||||
expression=Max("inserted_at"),
|
||||
partition_by=[F("provider_id")],
|
||||
),
|
||||
).filter(inserted_at=F("_max_provider_date"))
|
||||
clean_queryset = FindingGroupDailySummary.objects.filter(
|
||||
pk__in=latest_per_provider.values("pk")
|
||||
)
|
||||
return self._aggregate_daily_summaries(clean_queryset)
|
||||
|
||||
def _sorted_paginated_response(self, request, aggregated_queryset):
|
||||
"""Apply ordering, pagination, post-processing, and return the Response."""
|
||||
sort_param = request.query_params.get("sort")
|
||||
if sort_param:
|
||||
ordering = self._validate_sort_fields(sort_param)
|
||||
if ordering:
|
||||
aggregated_queryset = aggregated_queryset.order_by(*ordering)
|
||||
else:
|
||||
aggregated_queryset = aggregated_queryset.order_by(
|
||||
"-fail_count", "-severity_order", "check_id"
|
||||
)
|
||||
|
||||
page = self.paginate_queryset(aggregated_queryset)
|
||||
if page is not None:
|
||||
processed_data = self._post_process_aggregation(page)
|
||||
serializer = self.get_serializer(processed_data, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
processed_data = self._post_process_aggregation(aggregated_queryset)
|
||||
serializer = self.get_serializer(processed_data, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
List finding groups with aggregation and filtering.
|
||||
|
||||
Returns findings grouped by check_id with aggregated metrics.
|
||||
Requires at least one date filter for performance.
|
||||
Uses pre-aggregated daily summaries for efficient queries.
|
||||
Uses summaries when possible and raw findings for finding-level filters.
|
||||
"""
|
||||
queryset = self.get_queryset()
|
||||
|
||||
# Apply filters
|
||||
normalized_params = self._normalize_jsonapi_params(request.query_params)
|
||||
filterset = self.filterset_class(normalized_params, queryset=queryset)
|
||||
if not filterset.is_valid():
|
||||
raise ValidationError(filterset.errors)
|
||||
filtered_queryset = filterset.qs
|
||||
|
||||
# Re-aggregate daily summaries across the date range
|
||||
aggregated_queryset = self._aggregate_daily_summaries(filtered_queryset)
|
||||
|
||||
# Apply ordering (respect JSON:API sort param or use default)
|
||||
sort_param = request.query_params.get("sort")
|
||||
if sort_param:
|
||||
# Convert JSON:API sort notation (prefix '-' for descending)
|
||||
ordering = self._validate_sort_fields(sort_param)
|
||||
if ordering:
|
||||
aggregated_queryset = aggregated_queryset.order_by(*ordering)
|
||||
else:
|
||||
# Default ordering: failures first, then severity, then check_id
|
||||
aggregated_queryset = aggregated_queryset.order_by(
|
||||
"-fail_count", "-severity_order", "check_id"
|
||||
)
|
||||
|
||||
# Paginate
|
||||
page = self.paginate_queryset(aggregated_queryset)
|
||||
if page is not None:
|
||||
# Post-process the page
|
||||
processed_data = self._post_process_aggregation(page)
|
||||
serializer = self.get_serializer(processed_data, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
# Post-process all results (no pagination)
|
||||
processed_data = self._post_process_aggregation(aggregated_queryset)
|
||||
serializer = self.get_serializer(processed_data, many=True)
|
||||
return Response(serializer.data)
|
||||
finding_params, computed_params = self._split_computed_aggregate_filters(
|
||||
normalized_params
|
||||
)
|
||||
aggregated_qs = self._build_aggregated_queryset(finding_params, latest=False)
|
||||
aggregated_qs = self._apply_aggregated_computed_filters(
|
||||
aggregated_qs, computed_params
|
||||
)
|
||||
return self._sorted_paginated_response(request, aggregated_qs)
|
||||
|
||||
@extend_schema(
|
||||
summary="List latest finding groups",
|
||||
@@ -7172,56 +7392,22 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
"""
|
||||
List the latest finding group state per check_id.
|
||||
|
||||
Returns findings grouped by check_id using the latest available
|
||||
inserted_at date per check_id, without requiring date filters.
|
||||
Returns findings grouped by check_id using latest data per
|
||||
(check_id, provider), without requiring date filters.
|
||||
"""
|
||||
queryset = self.get_queryset()
|
||||
|
||||
# Apply other filters (provider_id, provider_type, check_id, etc.)
|
||||
normalized_params = self._normalize_jsonapi_params(request.query_params)
|
||||
# Remove date filters since we're using latest
|
||||
for key in list(normalized_params.keys()):
|
||||
if key.startswith("inserted_at"):
|
||||
del normalized_params[key]
|
||||
|
||||
filterset_class = self.get_filterset_class()
|
||||
filterset = filterset_class(normalized_params, queryset=queryset)
|
||||
if not filterset.is_valid():
|
||||
raise ValidationError(filterset.errors)
|
||||
filtered_queryset = filterset.qs
|
||||
|
||||
# Keep only rows from the latest inserted_at date per check_id
|
||||
latest_per_check = filtered_queryset.annotate(
|
||||
latest_inserted_at=Window(
|
||||
expression=Max("inserted_at"),
|
||||
partition_by=[F("check_id")],
|
||||
)
|
||||
).filter(inserted_at=F("latest_inserted_at"))
|
||||
|
||||
# Re-aggregate daily summaries
|
||||
aggregated_queryset = self._aggregate_daily_summaries(latest_per_check)
|
||||
|
||||
# Apply ordering
|
||||
sort_param = request.query_params.get("sort")
|
||||
if sort_param:
|
||||
ordering = self._validate_sort_fields(sort_param)
|
||||
if ordering:
|
||||
aggregated_queryset = aggregated_queryset.order_by(*ordering)
|
||||
else:
|
||||
aggregated_queryset = aggregated_queryset.order_by(
|
||||
"-fail_count", "-severity_order", "check_id"
|
||||
)
|
||||
|
||||
# Paginate
|
||||
page = self.paginate_queryset(aggregated_queryset)
|
||||
if page is not None:
|
||||
processed_data = self._post_process_aggregation(page)
|
||||
serializer = self.get_serializer(processed_data, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
processed_data = self._post_process_aggregation(aggregated_queryset)
|
||||
serializer = self.get_serializer(processed_data, many=True)
|
||||
return Response(serializer.data)
|
||||
finding_params, computed_params = self._split_computed_aggregate_filters(
|
||||
normalized_params
|
||||
)
|
||||
aggregated_qs = self._build_aggregated_queryset(finding_params, latest=True)
|
||||
aggregated_qs = self._apply_aggregated_computed_filters(
|
||||
aggregated_qs, computed_params
|
||||
)
|
||||
return self._sorted_paginated_response(request, aggregated_qs)
|
||||
|
||||
@extend_schema(
|
||||
summary="List resources for a finding group",
|
||||
@@ -7232,6 +7418,7 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
and timing information including how long they have been failing.
|
||||
""",
|
||||
tags=["Finding Groups"],
|
||||
filters=True,
|
||||
)
|
||||
@action(detail=True, methods=["get"], url_path="resources")
|
||||
def resources(self, request, pk=None):
|
||||
@@ -7306,6 +7493,7 @@ class FindingGroupViewSet(BaseRLSViewSet):
|
||||
and timing information. No date filters required.
|
||||
""",
|
||||
tags=["Finding Groups"],
|
||||
filters=True,
|
||||
)
|
||||
@action(
|
||||
detail=False,
|
||||
|
||||
@@ -113,8 +113,11 @@ REST_FRAMEWORK = {
|
||||
"rest_framework.throttling.ScopedRateThrottle",
|
||||
],
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"token-obtain": env("DJANGO_THROTTLE_TOKEN_OBTAIN", default=None),
|
||||
"dj_rest_auth": None,
|
||||
"token-obtain": env("DJANGO_THROTTLE_TOKEN_OBTAIN", default=None),
|
||||
"attack-paths-custom-query": env(
|
||||
"DJANGO_THROTTLE_ATTACK_PATHS_CUSTOM_QUERY", default="10/min"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -296,3 +299,8 @@ DJANGO_DELETION_BATCH_SIZE = env.int("DJANGO_DELETION_BATCH_SIZE", 5000)
|
||||
# SAML requirement
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
|
||||
# Attack Paths
|
||||
ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int(
|
||||
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880
|
||||
) # 48h
|
||||
|
||||
@@ -3,6 +3,10 @@ from config.env import env
|
||||
|
||||
DEBUG = env.bool("DJANGO_DEBUG", default=False)
|
||||
ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["localhost", "127.0.0.1"])
|
||||
CORS_ALLOWED_ORIGINS = env.list(
|
||||
"DJANGO_CORS_ALLOWED_ORIGINS",
|
||||
default=["http://localhost", "http://127.0.0.1"],
|
||||
)
|
||||
|
||||
# Database
|
||||
# TODO Use Django database routers https://docs.djangoproject.com/en/5.0/topics/db/multi-db/#automatic-database-routing
|
||||
|
||||
@@ -1,10 +1,52 @@
|
||||
from urllib.parse import quote
|
||||
|
||||
from config.env import env
|
||||
|
||||
_VALID_SCHEMES = {"redis", "rediss"}
|
||||
|
||||
|
||||
def _build_celery_broker_url(
|
||||
scheme: str,
|
||||
username: str,
|
||||
password: str,
|
||||
host: str,
|
||||
port: str,
|
||||
db: str,
|
||||
) -> str:
|
||||
if scheme not in _VALID_SCHEMES:
|
||||
raise ValueError(
|
||||
f"Invalid VALKEY_SCHEME '{scheme}'. Must be one of: {', '.join(sorted(_VALID_SCHEMES))}"
|
||||
)
|
||||
|
||||
encoded_username = quote(username, safe="") if username else ""
|
||||
encoded_password = quote(password, safe="") if password else ""
|
||||
|
||||
auth = ""
|
||||
if encoded_username and encoded_password:
|
||||
auth = f"{encoded_username}:{encoded_password}@"
|
||||
elif encoded_password:
|
||||
auth = f":{encoded_password}@"
|
||||
elif encoded_username:
|
||||
auth = f"{encoded_username}@"
|
||||
|
||||
return f"{scheme}://{auth}{host}:{port}/{db}"
|
||||
|
||||
|
||||
VALKEY_SCHEME = env("VALKEY_SCHEME", default="redis")
|
||||
VALKEY_USERNAME = env("VALKEY_USERNAME", default="")
|
||||
VALKEY_PASSWORD = env("VALKEY_PASSWORD", default="")
|
||||
VALKEY_HOST = env("VALKEY_HOST", default="valkey")
|
||||
VALKEY_PORT = env("VALKEY_PORT", default="6379")
|
||||
VALKEY_DB = env("VALKEY_DB", default="0")
|
||||
|
||||
CELERY_BROKER_URL = f"redis://{VALKEY_HOST}:{VALKEY_PORT}/{VALKEY_DB}"
|
||||
CELERY_BROKER_URL = _build_celery_broker_url(
|
||||
VALKEY_SCHEME,
|
||||
VALKEY_USERNAME,
|
||||
VALKEY_PASSWORD,
|
||||
VALKEY_HOST,
|
||||
VALKEY_PORT,
|
||||
VALKEY_DB,
|
||||
)
|
||||
CELERY_RESULT_BACKEND = "django-db"
|
||||
CELERY_TASK_TRACK_STARTED = True
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import sentry_sdk
|
||||
|
||||
from config.env import env
|
||||
|
||||
IGNORED_EXCEPTIONS = [
|
||||
@@ -85,8 +86,20 @@ def before_send(event, hint):
|
||||
# Ignore logs with the ignored_exceptions
|
||||
# https://docs.python.org/3/library/logging.html#logrecord-objects
|
||||
if "log_record" in hint:
|
||||
log_msg = hint["log_record"].msg
|
||||
log_lvl = hint["log_record"].levelno
|
||||
log_record = hint["log_record"]
|
||||
log_msg = log_record.getMessage()
|
||||
log_lvl = log_record.levelno
|
||||
|
||||
# The Neo4j driver logs transient connection errors (defunct
|
||||
# connections, resets) at ERROR level via the `neo4j.io` logger.
|
||||
# `RetryableSession` handles these with retries. If all retries
|
||||
# are exhausted, the exception propagates and Sentry captures
|
||||
# it as a normal exception event.
|
||||
if (
|
||||
getattr(log_record, "name", "").startswith("neo4j.io")
|
||||
and "defunct" in log_msg
|
||||
):
|
||||
return None
|
||||
|
||||
# Handle Error and Critical events and discard the rest
|
||||
if log_lvl <= 40 and any(ignored in log_msg for ignored in IGNORED_EXCEPTIONS):
|
||||
|
||||
+103
-2
@@ -111,8 +111,9 @@ def disable_logging():
|
||||
logging.disable(logging.CRITICAL)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def create_test_user(django_db_setup, django_db_blocker):
|
||||
@pytest.fixture(scope="session")
|
||||
def _session_test_user(django_db_setup, django_db_blocker):
|
||||
"""Create the test user once per session. Internal; use create_test_user instead."""
|
||||
with django_db_blocker.unblock():
|
||||
user = User.objects.create_user(
|
||||
name="testing",
|
||||
@@ -122,6 +123,21 @@ def create_test_user(django_db_setup, django_db_blocker):
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def create_test_user(_session_test_user, django_db_blocker):
|
||||
"""Re-create the session-scoped test user when a TransactionTestCase
|
||||
has truncated the users table."""
|
||||
with django_db_blocker.unblock():
|
||||
if not User.objects.filter(pk=_session_test_user.pk).exists():
|
||||
User.objects.create_user(
|
||||
id=_session_test_user.pk,
|
||||
name="testing",
|
||||
email=TEST_USER,
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
return _session_test_user
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def create_test_user_rbac(django_db_setup, django_db_blocker, tenants_fixture):
|
||||
with django_db_blocker.unblock():
|
||||
@@ -2012,6 +2028,7 @@ def finding_groups_fixture(
|
||||
"CheckId": "s3_bucket_public_access",
|
||||
"checktitle": "Ensure S3 buckets do not allow public access",
|
||||
"Description": "S3 buckets should be configured to restrict public access.",
|
||||
"resourcegroup": "storage",
|
||||
},
|
||||
first_seen_at="2024-01-02T00:00:00Z",
|
||||
muted=False,
|
||||
@@ -2036,6 +2053,7 @@ def finding_groups_fixture(
|
||||
"CheckId": "s3_bucket_public_access",
|
||||
"checktitle": "Ensure S3 buckets do not allow public access",
|
||||
"Description": "S3 buckets should be configured to restrict public access.",
|
||||
"resourcegroup": "storage",
|
||||
},
|
||||
first_seen_at="2024-01-03T00:00:00Z",
|
||||
muted=False,
|
||||
@@ -2234,6 +2252,89 @@ def finding_groups_fixture(
|
||||
return findings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finding_groups_title_variants_fixture(
|
||||
tenants_fixture, providers_fixture, scans_fixture, resources_fixture
|
||||
):
|
||||
"""
|
||||
Two providers report the same check_id with different checktitle values.
|
||||
|
||||
Simulates a Prowler version upgrade where the check title changed but the
|
||||
check_id stayed the same. Used to verify that check_title__icontains
|
||||
resolves to check_id first, so results include all providers regardless
|
||||
of which title variant matches the search term.
|
||||
"""
|
||||
tenant = tenants_fixture[0]
|
||||
provider1, provider2, *_ = providers_fixture
|
||||
scan1, scan2, *_ = scans_fixture
|
||||
resource1, resource2, *_ = resources_fixture
|
||||
|
||||
findings = []
|
||||
|
||||
# Provider 1 — OLD title variant
|
||||
finding_old = Finding.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
uid="fg_title_variant_old",
|
||||
scan=scan1,
|
||||
delta="new",
|
||||
status=Status.FAIL,
|
||||
status_extended="Secret scanning not enabled",
|
||||
impact=Severity.high,
|
||||
impact_extended="High risk",
|
||||
severity=Severity.high,
|
||||
raw_result={"status": Status.FAIL, "severity": Severity.high},
|
||||
tags={},
|
||||
check_id="github_secret_scanning_enabled",
|
||||
check_metadata={
|
||||
"CheckId": "github_secret_scanning_enabled",
|
||||
"checktitle": "Ensure repository has secret scanning enabled",
|
||||
"Description": "Checks if secret scanning is enabled.",
|
||||
},
|
||||
first_seen_at="2024-01-01T00:00:00Z",
|
||||
muted=False,
|
||||
)
|
||||
finding_old.add_resources([resource1])
|
||||
findings.append(finding_old)
|
||||
|
||||
# Provider 2 — NEW title variant (same check_id, different checktitle)
|
||||
finding_new = Finding.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
uid="fg_title_variant_new",
|
||||
scan=scan2,
|
||||
delta="new",
|
||||
status=Status.FAIL,
|
||||
status_extended="Secret scanning not enabled on repo",
|
||||
impact=Severity.high,
|
||||
impact_extended="High risk",
|
||||
severity=Severity.high,
|
||||
raw_result={"status": Status.FAIL, "severity": Severity.high},
|
||||
tags={},
|
||||
check_id="github_secret_scanning_enabled",
|
||||
check_metadata={
|
||||
"CheckId": "github_secret_scanning_enabled",
|
||||
"checktitle": "Check if secret scanning is enabled in GitHub",
|
||||
"Description": "Checks if secret scanning is enabled.",
|
||||
},
|
||||
first_seen_at="2024-01-02T00:00:00Z",
|
||||
muted=False,
|
||||
)
|
||||
finding_new.add_resources([resource2])
|
||||
findings.append(finding_new)
|
||||
|
||||
from tasks.jobs.scan import aggregate_finding_group_summaries
|
||||
|
||||
aggregate_finding_group_summaries(
|
||||
tenant_id=str(tenant.id),
|
||||
scan_id=str(scan1.id),
|
||||
)
|
||||
aggregate_finding_group_summaries(
|
||||
tenant_id=str(tenant.id),
|
||||
scan_id=str(scan2.id),
|
||||
)
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(items):
|
||||
"""Ensure test_rbac.py is executed first."""
|
||||
items.sort(key=lambda item: 0 if "test_rbac.py" in item.nodeid else 1)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from celery import current_app, states
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.django.base import ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES
|
||||
from tasks.jobs.attack_paths.db_utils import (
|
||||
_mark_scan_finished,
|
||||
recover_graph_data_ready,
|
||||
)
|
||||
|
||||
from api.attack_paths import database as graph_database
|
||||
from api.db_router import MainRouter
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import AttackPathsScan, StateChoices
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def cleanup_stale_attack_paths_scans() -> dict:
|
||||
"""
|
||||
Find `EXECUTING` `AttackPathsScan` scans whose workers are dead or that have
|
||||
exceeded the stale threshold, and mark them as `FAILED`.
|
||||
|
||||
Two-pass detection:
|
||||
1. If `TaskResult.worker` exists, ping the worker.
|
||||
- Dead worker: cleanup immediately (any age).
|
||||
- Alive + past threshold: revoke the task, then cleanup.
|
||||
- Alive + within threshold: skip.
|
||||
2. If no worker field: fall back to time-based heuristic only.
|
||||
"""
|
||||
threshold = timedelta(minutes=ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
cutoff = now - threshold
|
||||
|
||||
executing_scans = (
|
||||
AttackPathsScan.all_objects.using(MainRouter.admin_db)
|
||||
.filter(state=StateChoices.EXECUTING)
|
||||
.select_related("task__task_runner_task")
|
||||
)
|
||||
|
||||
# Cache worker liveness so each worker is pinged at most once
|
||||
executing_scans = list(executing_scans)
|
||||
workers = {
|
||||
tr.worker
|
||||
for scan in executing_scans
|
||||
if (tr := getattr(scan.task, "task_runner_task", None) if scan.task else None)
|
||||
and tr.worker
|
||||
}
|
||||
worker_alive = {w: _is_worker_alive(w) for w in workers}
|
||||
|
||||
cleaned_up = []
|
||||
|
||||
for scan in executing_scans:
|
||||
task_result = (
|
||||
getattr(scan.task, "task_runner_task", None) if scan.task else None
|
||||
)
|
||||
worker = task_result.worker if task_result else None
|
||||
|
||||
if worker:
|
||||
alive = worker_alive.get(worker, True)
|
||||
|
||||
if alive:
|
||||
if scan.started_at and scan.started_at >= cutoff:
|
||||
continue
|
||||
|
||||
# Alive but stale — revoke before cleanup
|
||||
_revoke_task(task_result)
|
||||
reason = (
|
||||
"Scan exceeded stale threshold — " "cleaned up by periodic task"
|
||||
)
|
||||
else:
|
||||
reason = "Worker dead — cleaned up by periodic task"
|
||||
else:
|
||||
# No worker recorded — time-based heuristic only
|
||||
if scan.started_at and scan.started_at >= cutoff:
|
||||
continue
|
||||
reason = (
|
||||
"No worker recorded, scan exceeded stale threshold — "
|
||||
"cleaned up by periodic task"
|
||||
)
|
||||
|
||||
if _cleanup_scan(scan, task_result, reason):
|
||||
cleaned_up.append(str(scan.id))
|
||||
|
||||
logger.info(
|
||||
f"Stale `AttackPathsScan` cleanup: {len(cleaned_up)} scan(s) cleaned up"
|
||||
)
|
||||
return {"cleaned_up_count": len(cleaned_up), "scan_ids": cleaned_up}
|
||||
|
||||
|
||||
def _is_worker_alive(worker: str) -> bool:
|
||||
"""Ping a specific Celery worker. Returns `True` if it responds or on error."""
|
||||
try:
|
||||
response = current_app.control.inspect(destination=[worker], timeout=1.0).ping()
|
||||
return response is not None and worker in response
|
||||
except Exception:
|
||||
logger.exception(f"Failed to ping worker {worker}, treating as alive")
|
||||
return True
|
||||
|
||||
|
||||
def _revoke_task(task_result) -> None:
|
||||
"""Send `SIGTERM` to a hung Celery task. Non-fatal on failure."""
|
||||
try:
|
||||
current_app.control.revoke(
|
||||
task_result.task_id, terminate=True, signal="SIGTERM"
|
||||
)
|
||||
logger.info(f"Revoked task {task_result.task_id}")
|
||||
except Exception:
|
||||
logger.exception(f"Failed to revoke task {task_result.task_id}")
|
||||
|
||||
|
||||
def _cleanup_scan(scan, task_result, reason: str) -> bool:
|
||||
"""
|
||||
Clean up a single stale `AttackPathsScan`:
|
||||
drop temp DB, mark `FAILED`, update `TaskResult`, recover `graph_data_ready`.
|
||||
|
||||
Returns `True` if the scan was actually cleaned up, `False` if skipped.
|
||||
"""
|
||||
scan_id_str = str(scan.id)
|
||||
|
||||
# 1. Drop temp Neo4j database
|
||||
tmp_db_name = graph_database.get_database_name(scan.id, temporary=True)
|
||||
try:
|
||||
graph_database.drop_database(tmp_db_name)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to drop temp database {tmp_db_name}")
|
||||
|
||||
# 2. Lock row, verify still EXECUTING, mark FAILED — all atomic
|
||||
with rls_transaction(str(scan.tenant_id)):
|
||||
try:
|
||||
fresh_scan = AttackPathsScan.objects.select_for_update().get(id=scan.id)
|
||||
except AttackPathsScan.DoesNotExist:
|
||||
logger.warning(f"Scan {scan_id_str} no longer exists, skipping")
|
||||
return False
|
||||
|
||||
if fresh_scan.state != StateChoices.EXECUTING:
|
||||
logger.info(f"Scan {scan_id_str} is now {fresh_scan.state}, skipping")
|
||||
return False
|
||||
|
||||
_mark_scan_finished(fresh_scan, StateChoices.FAILED, {"global_error": reason})
|
||||
|
||||
# 3. Mark `TaskResult` as `FAILURE` (not RLS-protected, outside lock)
|
||||
if task_result:
|
||||
task_result.status = states.FAILURE
|
||||
task_result.date_done = datetime.now(tz=timezone.utc)
|
||||
task_result.save(update_fields=["status", "date_done"])
|
||||
|
||||
# 4. Recover graph_data_ready if provider data still exists
|
||||
recover_graph_data_ready(fresh_scan)
|
||||
|
||||
logger.info(f"Cleaned up stale scan {scan_id_str}: {reason}")
|
||||
return True
|
||||
@@ -1,25 +1,30 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
from uuid import UUID
|
||||
|
||||
from config.env import env
|
||||
|
||||
from tasks.jobs.attack_paths import aws
|
||||
|
||||
|
||||
# Batch size for Neo4j operations
|
||||
# Batch size for Neo4j write operations (resource labeling, cleanup)
|
||||
BATCH_SIZE = env.int("ATTACK_PATHS_BATCH_SIZE", 1000)
|
||||
# Batch size for Postgres findings fetch (keyset pagination page size)
|
||||
FINDINGS_BATCH_SIZE = env.int("ATTACK_PATHS_FINDINGS_BATCH_SIZE", 500)
|
||||
# Batch size for temp-to-tenant graph sync (nodes and relationships per cursor page)
|
||||
SYNC_BATCH_SIZE = env.int("ATTACK_PATHS_SYNC_BATCH_SIZE", 250)
|
||||
|
||||
# Neo4j internal labels (Prowler-specific, not provider-specific)
|
||||
# - `Internet`: Singleton node representing external internet access for exposed-resource queries
|
||||
# - `ProwlerFinding`: Label for finding nodes created by Prowler and linked to cloud resources
|
||||
# - `_ProviderResource`: Added to ALL synced nodes for provider isolation and drop/query ops
|
||||
# - `Internet`: Singleton node representing external internet access for exposed-resource queries
|
||||
INTERNET_NODE_LABEL = "Internet"
|
||||
PROWLER_FINDING_LABEL = "ProwlerFinding"
|
||||
PROVIDER_RESOURCE_LABEL = "_ProviderResource"
|
||||
INTERNET_NODE_LABEL = "Internet"
|
||||
|
||||
# Phase 1 dual-write: deprecated label kept for drop_subgraph and infrastructure queries
|
||||
# Remove in Phase 2 once all nodes use the private label exclusively
|
||||
DEPRECATED_PROVIDER_RESOURCE_LABEL = "ProviderResource"
|
||||
# Dynamic isolation labels that contain entity UUIDs and are added to every synced node during sync
|
||||
# Format: _Tenant_{uuid_no_hyphens}, _Provider_{uuid_no_hyphens}
|
||||
TENANT_LABEL_PREFIX = "_Tenant_"
|
||||
PROVIDER_LABEL_PREFIX = "_Provider_"
|
||||
DYNAMIC_ISOLATION_PREFIXES = [TENANT_LABEL_PREFIX, PROVIDER_LABEL_PREFIX]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -31,7 +36,6 @@ class ProviderConfig:
|
||||
uid_field: str # e.g., "arn"
|
||||
# Label for resources connected to the account node, enabling indexed finding lookups.
|
||||
resource_label: str # e.g., "_AWSResource"
|
||||
deprecated_resource_label: str # e.g., "AWSResource"
|
||||
ingestion_function: Callable
|
||||
|
||||
|
||||
@@ -43,7 +47,6 @@ AWS_CONFIG = ProviderConfig(
|
||||
root_node_label="AWSAccount",
|
||||
uid_field="arn",
|
||||
resource_label="_AWSResource",
|
||||
deprecated_resource_label="AWSResource",
|
||||
ingestion_function=aws.start_aws_ingestion,
|
||||
)
|
||||
|
||||
@@ -56,18 +59,14 @@ PROVIDER_CONFIGS: dict[str, ProviderConfig] = {
|
||||
INTERNAL_LABELS: list[str] = [
|
||||
"Tenant", # From Cartography, but it looks like it's ours
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
DEPRECATED_PROVIDER_RESOURCE_LABEL,
|
||||
# Add all provider-specific resource labels
|
||||
*[config.resource_label for config in PROVIDER_CONFIGS.values()],
|
||||
*[config.deprecated_resource_label for config in PROVIDER_CONFIGS.values()],
|
||||
]
|
||||
|
||||
# Provider isolation properties
|
||||
PROVIDER_ELEMENT_ID_PROPERTY = "_provider_element_id"
|
||||
|
||||
PROVIDER_ISOLATION_PROPERTIES: list[str] = [
|
||||
"_provider_id",
|
||||
"_provider_element_id",
|
||||
"provider_id",
|
||||
"provider_element_id",
|
||||
PROVIDER_ELEMENT_ID_PROPERTY,
|
||||
]
|
||||
|
||||
# Cartography bookkeeping metadata
|
||||
@@ -117,7 +116,25 @@ def get_provider_resource_label(provider_type: str) -> str:
|
||||
return config.resource_label if config else "_UnknownProviderResource"
|
||||
|
||||
|
||||
def get_deprecated_provider_resource_label(provider_type: str) -> str:
|
||||
"""Get the deprecated resource label for a provider type (e.g., `AWSResource`)."""
|
||||
config = PROVIDER_CONFIGS.get(provider_type)
|
||||
return config.deprecated_resource_label if config else "UnknownProviderResource"
|
||||
# Dynamic Isolation Label Helpers
|
||||
# --------------------------------
|
||||
|
||||
|
||||
def _normalize_uuid(value: str | UUID) -> str:
|
||||
"""Strip hyphens from a UUID string for use in Neo4j labels."""
|
||||
return str(value).replace("-", "")
|
||||
|
||||
|
||||
def get_tenant_label(tenant_id: str | UUID) -> str:
|
||||
"""Get the Neo4j label for a tenant (e.g., `_Tenant_019c41ee7df37deca684d839f95619f8`)."""
|
||||
return f"{TENANT_LABEL_PREFIX}{_normalize_uuid(tenant_id)}"
|
||||
|
||||
|
||||
def get_provider_label(provider_id: str | UUID) -> str:
|
||||
"""Get the Neo4j label for a provider (e.g., `_Provider_019c41ee7df37deca684d839f95619f8`)."""
|
||||
return f"{PROVIDER_LABEL_PREFIX}{_normalize_uuid(provider_id)}"
|
||||
|
||||
|
||||
def is_dynamic_isolation_label(label: str) -> bool:
|
||||
"""Check if a label is a dynamic tenant/provider isolation label."""
|
||||
return any(label.startswith(prefix) for prefix in DYNAMIC_ISOLATION_PREFIXES)
|
||||
|
||||
@@ -3,15 +3,13 @@ from typing import Any
|
||||
|
||||
from cartography.config import Config as CartographyConfig
|
||||
from celery.utils.log import get_task_logger
|
||||
from tasks.jobs.attack_paths.config import is_provider_available
|
||||
|
||||
from api.attack_paths import database as graph_database
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import (
|
||||
AttackPathsScan as ProwlerAPIAttackPathsScan,
|
||||
Provider as ProwlerAPIProvider,
|
||||
StateChoices,
|
||||
)
|
||||
from tasks.jobs.attack_paths.config import is_provider_available
|
||||
from api.models import AttackPathsScan as ProwlerAPIAttackPathsScan
|
||||
from api.models import Provider as ProwlerAPIProvider
|
||||
from api.models import StateChoices
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
@@ -90,34 +88,41 @@ def starting_attack_paths_scan(
|
||||
)
|
||||
|
||||
|
||||
def _mark_scan_finished(
|
||||
attack_paths_scan: ProwlerAPIAttackPathsScan,
|
||||
state: StateChoices,
|
||||
ingestion_exceptions: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set terminal fields on a scan. Caller must be inside a transaction."""
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
duration = (
|
||||
int((now - attack_paths_scan.started_at).total_seconds())
|
||||
if attack_paths_scan.started_at
|
||||
else 0
|
||||
)
|
||||
attack_paths_scan.state = state
|
||||
attack_paths_scan.progress = 100
|
||||
attack_paths_scan.completed_at = now
|
||||
attack_paths_scan.duration = duration
|
||||
attack_paths_scan.ingestion_exceptions = ingestion_exceptions
|
||||
attack_paths_scan.save(
|
||||
update_fields=[
|
||||
"state",
|
||||
"progress",
|
||||
"completed_at",
|
||||
"duration",
|
||||
"ingestion_exceptions",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def finish_attack_paths_scan(
|
||||
attack_paths_scan: ProwlerAPIAttackPathsScan,
|
||||
state: StateChoices,
|
||||
ingestion_exceptions: dict[str, Any],
|
||||
) -> None:
|
||||
with rls_transaction(attack_paths_scan.tenant_id):
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
duration = (
|
||||
int((now - attack_paths_scan.started_at).total_seconds())
|
||||
if attack_paths_scan.started_at
|
||||
else 0
|
||||
)
|
||||
|
||||
attack_paths_scan.state = state
|
||||
attack_paths_scan.progress = 100
|
||||
attack_paths_scan.completed_at = now
|
||||
attack_paths_scan.duration = duration
|
||||
attack_paths_scan.ingestion_exceptions = ingestion_exceptions
|
||||
|
||||
attack_paths_scan.save(
|
||||
update_fields=[
|
||||
"state",
|
||||
"progress",
|
||||
"completed_at",
|
||||
"duration",
|
||||
"ingestion_exceptions",
|
||||
]
|
||||
)
|
||||
_mark_scan_finished(attack_paths_scan, state, ingestion_exceptions)
|
||||
|
||||
|
||||
def update_attack_paths_scan_progress(
|
||||
@@ -155,6 +160,37 @@ def set_provider_graph_data_ready(
|
||||
attack_paths_scan.refresh_from_db(fields=["graph_data_ready"])
|
||||
|
||||
|
||||
def recover_graph_data_ready(
|
||||
attack_paths_scan: ProwlerAPIAttackPathsScan,
|
||||
) -> None:
|
||||
"""
|
||||
Best-effort recovery of `graph_data_ready` after a scan failure.
|
||||
|
||||
Queries Neo4j to check if the provider still has data in the tenant
|
||||
database. If data exists, restores `graph_data_ready=True` for all scans
|
||||
of this provider. Never raises.
|
||||
|
||||
Trade-off: if the worker crashed mid-sync, partial data may exist and
|
||||
this will re-enable queries against it. We accept that because leaving
|
||||
`graph_data_ready=False` permanently (blocking all queries until the
|
||||
next successful scan) is a worse outcome for the user.
|
||||
"""
|
||||
try:
|
||||
tenant_db = graph_database.get_database_name(attack_paths_scan.tenant_id)
|
||||
if graph_database.has_provider_data(
|
||||
tenant_db, str(attack_paths_scan.provider_id)
|
||||
):
|
||||
set_provider_graph_data_ready(attack_paths_scan, True)
|
||||
logger.info(
|
||||
f"Recovered `graph_data_ready` for provider {attack_paths_scan.provider_id}"
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Failed to recover `graph_data_ready` for provider {attack_paths_scan.provider_id}"
|
||||
)
|
||||
|
||||
|
||||
def fail_attack_paths_scan(
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
@@ -165,23 +201,26 @@ def fail_attack_paths_scan(
|
||||
Used as a safety net when the Celery task fails outside the job's own error handling.
|
||||
"""
|
||||
attack_paths_scan = retrieve_attack_paths_scan(tenant_id, scan_id)
|
||||
if attack_paths_scan and attack_paths_scan.state not in (
|
||||
StateChoices.COMPLETED,
|
||||
StateChoices.FAILED,
|
||||
):
|
||||
tmp_db_name = graph_database.get_database_name(
|
||||
attack_paths_scan.id, temporary=True
|
||||
if not attack_paths_scan:
|
||||
return
|
||||
|
||||
tmp_db_name = graph_database.get_database_name(attack_paths_scan.id, temporary=True)
|
||||
try:
|
||||
graph_database.drop_database(tmp_db_name)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Failed to drop temp database {tmp_db_name} during failure handling"
|
||||
)
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
try:
|
||||
graph_database.drop_database(tmp_db_name)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Failed to drop temp database {tmp_db_name} during failure handling"
|
||||
fresh = ProwlerAPIAttackPathsScan.objects.select_for_update().get(
|
||||
id=attack_paths_scan.id
|
||||
)
|
||||
except ProwlerAPIAttackPathsScan.DoesNotExist:
|
||||
return
|
||||
if fresh.state in (StateChoices.COMPLETED, StateChoices.FAILED):
|
||||
return
|
||||
_mark_scan_finished(fresh, StateChoices.FAILED, {"global_error": error})
|
||||
|
||||
finish_attack_paths_scan(
|
||||
attack_paths_scan,
|
||||
StateChoices.FAILED,
|
||||
{"global_error": error},
|
||||
)
|
||||
recover_graph_data_ready(fresh)
|
||||
|
||||
@@ -9,28 +9,19 @@ This module handles:
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from typing import Any, Generator
|
||||
from uuid import UUID
|
||||
|
||||
import neo4j
|
||||
|
||||
from cartography.config import Config as CartographyConfig
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Finding as FindingModel
|
||||
from api.models import Provider, ResourceFindingMapping
|
||||
from prowler.config import config as ProwlerConfig
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
BATCH_SIZE,
|
||||
get_deprecated_provider_resource_label,
|
||||
FINDINGS_BATCH_SIZE,
|
||||
get_node_uid_field,
|
||||
get_provider_resource_label,
|
||||
get_root_node_label,
|
||||
)
|
||||
from tasks.jobs.attack_paths.indexes import IndexType, create_indexes
|
||||
from tasks.jobs.attack_paths.queries import (
|
||||
ADD_RESOURCE_LABEL_TEMPLATE,
|
||||
CLEANUP_FINDINGS_TEMPLATE,
|
||||
@@ -38,86 +29,60 @@ from tasks.jobs.attack_paths.queries import (
|
||||
render_cypher_template,
|
||||
)
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Finding as FindingModel
|
||||
from api.models import Provider, ResourceFindingMapping
|
||||
from prowler.config import config as ProwlerConfig
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
# Type Definitions
|
||||
# -----------------
|
||||
|
||||
# Maps dataclass field names to Django ORM query field names
|
||||
_DB_FIELD_MAP: dict[str, str] = {
|
||||
"check_title": "check_metadata__checktitle",
|
||||
}
|
||||
# Django ORM field names for `.values()` queries
|
||||
# Most map 1:1 to Neo4j property names, exceptions are remapped in `_to_neo4j_dict`
|
||||
_DB_QUERY_FIELDS = [
|
||||
"id",
|
||||
"uid",
|
||||
"inserted_at",
|
||||
"updated_at",
|
||||
"first_seen_at",
|
||||
"scan_id",
|
||||
"delta",
|
||||
"status",
|
||||
"status_extended",
|
||||
"severity",
|
||||
"check_id",
|
||||
"check_metadata__checktitle",
|
||||
"muted",
|
||||
"muted_reason",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Finding:
|
||||
"""
|
||||
Finding data for Neo4j ingestion.
|
||||
|
||||
Can be created from a Django .values() query result using from_db_record().
|
||||
"""
|
||||
|
||||
id: str
|
||||
uid: str
|
||||
inserted_at: str
|
||||
updated_at: str
|
||||
first_seen_at: str
|
||||
scan_id: str
|
||||
delta: str
|
||||
status: str
|
||||
status_extended: str
|
||||
severity: str
|
||||
check_id: str
|
||||
check_title: str
|
||||
muted: bool
|
||||
muted_reason: str | None
|
||||
resource_uid: str | None = None
|
||||
|
||||
@classmethod
|
||||
def get_db_query_fields(cls) -> tuple[str, ...]:
|
||||
"""Get field names for Django .values() query."""
|
||||
return tuple(
|
||||
_DB_FIELD_MAP.get(f.name, f.name)
|
||||
for f in fields(cls)
|
||||
if f.name != "resource_uid"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_db_record(cls, record: dict[str, Any], resource_uid: str) -> "Finding":
|
||||
"""Create a Finding from a Django .values() query result."""
|
||||
return cls(
|
||||
id=str(record["id"]),
|
||||
uid=record["uid"],
|
||||
inserted_at=record["inserted_at"],
|
||||
updated_at=record["updated_at"],
|
||||
first_seen_at=record["first_seen_at"],
|
||||
scan_id=str(record["scan_id"]),
|
||||
delta=record["delta"],
|
||||
status=record["status"],
|
||||
status_extended=record["status_extended"],
|
||||
severity=record["severity"],
|
||||
check_id=str(record["check_id"]),
|
||||
check_title=record["check_metadata__checktitle"],
|
||||
muted=record["muted"],
|
||||
muted_reason=record["muted_reason"],
|
||||
resource_uid=resource_uid,
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dict for Neo4j ingestion."""
|
||||
return asdict(self)
|
||||
def _to_neo4j_dict(record: dict[str, Any], resource_uid: str) -> dict[str, Any]:
|
||||
"""Transform a Django `.values()` record into a `dict` ready for Neo4j ingestion."""
|
||||
return {
|
||||
"id": str(record["id"]),
|
||||
"uid": record["uid"],
|
||||
"inserted_at": record["inserted_at"],
|
||||
"updated_at": record["updated_at"],
|
||||
"first_seen_at": record["first_seen_at"],
|
||||
"scan_id": str(record["scan_id"]),
|
||||
"delta": record["delta"],
|
||||
"status": record["status"],
|
||||
"status_extended": record["status_extended"],
|
||||
"severity": record["severity"],
|
||||
"check_id": str(record["check_id"]),
|
||||
"check_title": record["check_metadata__checktitle"],
|
||||
"muted": record["muted"],
|
||||
"muted_reason": record["muted_reason"],
|
||||
"resource_uid": resource_uid,
|
||||
}
|
||||
|
||||
|
||||
# Public API
|
||||
# ----------
|
||||
|
||||
|
||||
def create_findings_indexes(neo4j_session: neo4j.Session) -> None:
|
||||
"""Create indexes for Prowler findings and resource lookups."""
|
||||
create_indexes(neo4j_session, IndexType.FINDINGS)
|
||||
|
||||
|
||||
def analysis(
|
||||
neo4j_session: neo4j.Session,
|
||||
prowler_api_provider: Provider,
|
||||
@@ -153,9 +118,6 @@ def add_resource_label(
|
||||
{
|
||||
"__ROOT_LABEL__": get_root_node_label(provider_type),
|
||||
"__RESOURCE_LABEL__": get_provider_resource_label(provider_type),
|
||||
"__DEPRECATED_RESOURCE_LABEL__": get_deprecated_provider_resource_label(
|
||||
provider_type
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -184,7 +146,7 @@ def add_resource_label(
|
||||
|
||||
def load_findings(
|
||||
neo4j_session: neo4j.Session,
|
||||
findings_batches: Generator[list[Finding], None, None],
|
||||
findings_batches: Generator[list[dict[str, Any]], None, None],
|
||||
prowler_api_provider: Provider,
|
||||
config: CartographyConfig,
|
||||
) -> None:
|
||||
@@ -213,7 +175,7 @@ def load_findings(
|
||||
batch_size = len(batch)
|
||||
total_records += batch_size
|
||||
|
||||
parameters["findings_data"] = [f.to_dict() for f in batch]
|
||||
parameters["findings_data"] = batch
|
||||
|
||||
logger.info(f"Loading findings batch {batch_num} ({batch_size} records)")
|
||||
neo4j_session.run(query, parameters)
|
||||
@@ -228,7 +190,6 @@ def cleanup_findings(
|
||||
) -> None:
|
||||
"""Remove stale findings (classic Cartography behaviour)."""
|
||||
parameters = {
|
||||
"provider_uid": str(prowler_api_provider.uid),
|
||||
"last_updated": config.update_tag,
|
||||
"batch_size": BATCH_SIZE,
|
||||
}
|
||||
@@ -251,16 +212,17 @@ def cleanup_findings(
|
||||
def stream_findings_with_resources(
|
||||
prowler_api_provider: Provider,
|
||||
scan_id: str,
|
||||
) -> Generator[list[Finding], None, None]:
|
||||
) -> Generator[list[dict[str, Any]], None, None]:
|
||||
"""
|
||||
Stream findings with their associated resources in batches.
|
||||
|
||||
Uses keyset pagination for efficient traversal of large datasets.
|
||||
Memory efficient: yields one batch at a time, never holds all findings in memory.
|
||||
Memory efficient: yields one batch at a time as dicts ready for Neo4j ingestion,
|
||||
never holds all findings in memory.
|
||||
"""
|
||||
logger.info(
|
||||
f"Starting findings stream for scan {scan_id} "
|
||||
f"(tenant {prowler_api_provider.tenant_id}) with batch size {BATCH_SIZE}"
|
||||
f"(tenant {prowler_api_provider.tenant_id}) with batch size {FINDINGS_BATCH_SIZE}"
|
||||
)
|
||||
|
||||
tenant_id = prowler_api_provider.tenant_id
|
||||
@@ -309,15 +271,14 @@ def _fetch_findings_batch(
|
||||
Uses read replica and RLS-scoped transaction.
|
||||
"""
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
# Use all_objects to avoid the ActiveProviderManager's implicit JOIN
|
||||
# through Scan -> Provider (to check is_deleted=False).
|
||||
# The provider is already validated as active in this context.
|
||||
# Use `all_objects` to get `Findings` even on soft-deleted `Providers`
|
||||
# But even the provider is already validated as active in this context
|
||||
qs = FindingModel.all_objects.filter(scan_id=scan_id).order_by("id")
|
||||
|
||||
if after_id is not None:
|
||||
qs = qs.filter(id__gt=after_id)
|
||||
|
||||
return list(qs.values(*Finding.get_db_query_fields())[:BATCH_SIZE])
|
||||
return list(qs.values(*_DB_QUERY_FIELDS)[:FINDINGS_BATCH_SIZE])
|
||||
|
||||
|
||||
# Batch Enrichment
|
||||
@@ -327,7 +288,7 @@ def _fetch_findings_batch(
|
||||
def _enrich_batch_with_resources(
|
||||
findings_batch: list[dict[str, Any]],
|
||||
tenant_id: str,
|
||||
) -> list[Finding]:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Enrich findings with their resource UIDs.
|
||||
|
||||
@@ -338,7 +299,7 @@ def _enrich_batch_with_resources(
|
||||
resource_map = _build_finding_resource_map(finding_ids, tenant_id)
|
||||
|
||||
return [
|
||||
Finding.from_db_record(finding, resource_uid)
|
||||
_to_neo4j_dict(finding, resource_uid)
|
||||
for finding in findings_batch
|
||||
for resource_uid in resource_map.get(finding["id"], [])
|
||||
]
|
||||
|
||||
@@ -1,37 +1,25 @@
|
||||
from enum import Enum
|
||||
|
||||
import neo4j
|
||||
|
||||
from cartography.client.core.tx import run_write_query
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
DEPRECATED_PROVIDER_RESOURCE_LABEL,
|
||||
INTERNET_NODE_LABEL,
|
||||
PROWLER_FINDING_LABEL,
|
||||
PROVIDER_ELEMENT_ID_PROPERTY,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
)
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
class IndexType(Enum):
|
||||
"""Types of indexes that can be created."""
|
||||
|
||||
FINDINGS = "findings"
|
||||
SYNC = "sync"
|
||||
|
||||
|
||||
# Indexes for Prowler findings and resource lookups
|
||||
FINDINGS_INDEX_STATEMENTS = [
|
||||
# Resource indexes for Prowler Finding lookups
|
||||
"CREATE INDEX aws_resource_arn IF NOT EXISTS FOR (n:_AWSResource) ON (n.arn);",
|
||||
"CREATE INDEX aws_resource_id IF NOT EXISTS FOR (n:_AWSResource) ON (n.id);",
|
||||
"CREATE INDEX deprecated_aws_resource_arn IF NOT EXISTS FOR (n:AWSResource) ON (n.arn);",
|
||||
"CREATE INDEX deprecated_aws_resource_id IF NOT EXISTS FOR (n:AWSResource) ON (n.id);",
|
||||
# Prowler Finding indexes
|
||||
f"CREATE INDEX prowler_finding_id IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.id);",
|
||||
f"CREATE INDEX prowler_finding_provider_uid IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.provider_uid);",
|
||||
f"CREATE INDEX prowler_finding_lastupdated IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.lastupdated);",
|
||||
f"CREATE INDEX prowler_finding_status IF NOT EXISTS FOR (n:{PROWLER_FINDING_LABEL}) ON (n.status);",
|
||||
# Internet node index for MERGE lookups
|
||||
@@ -40,33 +28,19 @@ FINDINGS_INDEX_STATEMENTS = [
|
||||
|
||||
# Indexes for provider resource sync operations
|
||||
SYNC_INDEX_STATEMENTS = [
|
||||
f"CREATE INDEX provider_element_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n._provider_element_id);",
|
||||
f"CREATE INDEX provider_resource_provider_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n._provider_id);",
|
||||
f"CREATE INDEX deprecated_provider_element_id IF NOT EXISTS FOR (n:{DEPRECATED_PROVIDER_RESOURCE_LABEL}) ON (n.provider_element_id);",
|
||||
f"CREATE INDEX deprecated_provider_resource_provider_id IF NOT EXISTS FOR (n:{DEPRECATED_PROVIDER_RESOURCE_LABEL}) ON (n.provider_id);",
|
||||
f"CREATE INDEX provider_resource_element_id IF NOT EXISTS FOR (n:{PROVIDER_RESOURCE_LABEL}) ON (n.{PROVIDER_ELEMENT_ID_PROPERTY});",
|
||||
]
|
||||
|
||||
|
||||
def create_indexes(neo4j_session: neo4j.Session, index_type: IndexType) -> None:
|
||||
"""
|
||||
Create indexes for the specified type.
|
||||
|
||||
Args:
|
||||
`neo4j_session`: The Neo4j session to use
|
||||
`index_type`: The type of indexes to create (FINDINGS or SYNC)
|
||||
"""
|
||||
if index_type == IndexType.FINDINGS:
|
||||
logger.info("Creating indexes for Prowler Findings node types")
|
||||
for statement in FINDINGS_INDEX_STATEMENTS:
|
||||
run_write_query(neo4j_session, statement)
|
||||
|
||||
elif index_type == IndexType.SYNC:
|
||||
logger.info("Ensuring ProviderResource indexes exist")
|
||||
for statement in SYNC_INDEX_STATEMENTS:
|
||||
neo4j_session.run(statement)
|
||||
def create_findings_indexes(neo4j_session: neo4j.Session) -> None:
|
||||
"""Create indexes for Prowler findings and resource lookups."""
|
||||
logger.info("Creating indexes for Prowler Findings node types")
|
||||
for statement in FINDINGS_INDEX_STATEMENTS:
|
||||
run_write_query(neo4j_session, statement)
|
||||
|
||||
|
||||
def create_all_indexes(neo4j_session: neo4j.Session) -> None:
|
||||
"""Create all indexes (both findings and sync)."""
|
||||
create_indexes(neo4j_session, IndexType.FINDINGS)
|
||||
create_indexes(neo4j_session, IndexType.SYNC)
|
||||
def create_sync_indexes(neo4j_session: neo4j.Session) -> None:
|
||||
"""Create indexes for provider resource sync operations."""
|
||||
logger.info("Ensuring ProviderResource indexes exist")
|
||||
for statement in SYNC_INDEX_STATEMENTS:
|
||||
neo4j_session.run(statement)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
INTERNET_NODE_LABEL,
|
||||
PROWLER_FINDING_LABEL,
|
||||
PROVIDER_ELEMENT_ID_PROPERTY,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
)
|
||||
|
||||
@@ -26,7 +27,7 @@ ADD_RESOURCE_LABEL_TEMPLATE = """
|
||||
MATCH (account:__ROOT_LABEL__ {id: $provider_uid})-->(r)
|
||||
WHERE NOT r:__ROOT_LABEL__ AND NOT r:__RESOURCE_LABEL__
|
||||
WITH r LIMIT $batch_size
|
||||
SET r:__RESOURCE_LABEL__:__DEPRECATED_RESOURCE_LABEL__
|
||||
SET r:__RESOURCE_LABEL__
|
||||
RETURN COUNT(r) AS labeled_count
|
||||
"""
|
||||
|
||||
@@ -60,7 +61,6 @@ INSERT_FINDING_TEMPLATE = f"""
|
||||
finding.check_title = finding_data.check_title,
|
||||
finding.muted = finding_data.muted,
|
||||
finding.muted_reason = finding_data.muted_reason,
|
||||
finding.provider_uid = $provider_uid,
|
||||
finding.firstseen = timestamp(),
|
||||
finding.lastupdated = $last_updated,
|
||||
finding._module_name = 'cartography:prowler',
|
||||
@@ -72,7 +72,6 @@ INSERT_FINDING_TEMPLATE = f"""
|
||||
|
||||
MERGE (resource)-[rel:HAS_FINDING]->(finding)
|
||||
ON CREATE SET
|
||||
rel.provider_uid = $provider_uid,
|
||||
rel.firstseen = timestamp(),
|
||||
rel.lastupdated = $last_updated,
|
||||
rel._module_name = 'cartography:prowler',
|
||||
@@ -82,7 +81,7 @@ INSERT_FINDING_TEMPLATE = f"""
|
||||
"""
|
||||
|
||||
CLEANUP_FINDINGS_TEMPLATE = f"""
|
||||
MATCH (finding:{PROWLER_FINDING_LABEL} {{provider_uid: $provider_uid}})
|
||||
MATCH (finding:{PROWLER_FINDING_LABEL})
|
||||
WHERE finding.lastupdated < $last_updated
|
||||
|
||||
WITH finding LIMIT $batch_size
|
||||
@@ -149,22 +148,16 @@ RELATIONSHIPS_FETCH_QUERY = """
|
||||
LIMIT $batch_size
|
||||
"""
|
||||
|
||||
NODE_SYNC_TEMPLATE = """
|
||||
NODE_SYNC_TEMPLATE = f"""
|
||||
UNWIND $rows AS row
|
||||
MERGE (n:__NODE_LABELS__ {_provider_element_id: row.provider_element_id})
|
||||
MERGE (n:__NODE_LABELS__ {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.provider_element_id}})
|
||||
SET n += row.props
|
||||
SET n._provider_id = $provider_id
|
||||
SET n.provider_element_id = row.provider_element_id
|
||||
SET n.provider_id = $provider_id
|
||||
""" # The last two lines are deprecated properties
|
||||
"""
|
||||
|
||||
RELATIONSHIP_SYNC_TEMPLATE = f"""
|
||||
UNWIND $rows AS row
|
||||
MATCH (s:{PROVIDER_RESOURCE_LABEL} {{_provider_element_id: row.start_element_id}})
|
||||
MATCH (t:{PROVIDER_RESOURCE_LABEL} {{_provider_element_id: row.end_element_id}})
|
||||
MERGE (s)-[r:__REL_TYPE__ {{_provider_element_id: row.provider_element_id}}]->(t)
|
||||
MATCH (s:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.start_element_id}})
|
||||
MATCH (t:{PROVIDER_RESOURCE_LABEL} {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.end_element_id}})
|
||||
MERGE (s)-[r:__REL_TYPE__ {{{PROVIDER_ELEMENT_ID_PROPERTY}: row.provider_element_id}}]->(t)
|
||||
SET r += row.props
|
||||
SET r._provider_id = $provider_id
|
||||
SET r.provider_element_id = row.provider_element_id
|
||||
SET r.provider_id = $provider_id
|
||||
""" # The last two lines are deprecated properties
|
||||
"""
|
||||
|
||||
@@ -38,12 +38,12 @@ Pipeline steps:
|
||||
Stale findings from previous scans are cleaned up.
|
||||
|
||||
7. Sync the temp database into the tenant database:
|
||||
- Drop the old provider subgraph (matched by _provider_id property).
|
||||
- Drop the old provider subgraph (matched by dynamic _Provider_{uuid} label).
|
||||
graph_data_ready is set to False for all scans of this provider while
|
||||
the swap happens so the API doesn't serve partial data.
|
||||
- Copy nodes and relationships in batches. Every synced node gets a
|
||||
_ProviderResource label and _provider_id / _provider_element_id
|
||||
properties for multi-provider isolation.
|
||||
_ProviderResource label and dynamic _Tenant_{uuid} / _Provider_{uuid}
|
||||
isolation labels, plus a _provider_element_id property for MERGE keys.
|
||||
- Set graph_data_ready back to True.
|
||||
|
||||
8. Drop the temporary database, mark the AttackPathsScan as COMPLETED.
|
||||
@@ -55,7 +55,6 @@ exception propagates to Celery.
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from typing import Any
|
||||
|
||||
from cartography.config import Config as CartographyConfig
|
||||
@@ -63,16 +62,14 @@ from cartography.intel import analysis as cartography_analysis
|
||||
from cartography.intel import create_indexes as cartography_create_indexes
|
||||
from cartography.intel import ontology as cartography_ontology
|
||||
from celery.utils.log import get_task_logger
|
||||
from tasks.jobs.attack_paths import db_utils, findings, indexes, internet, sync, utils
|
||||
from tasks.jobs.attack_paths.config import get_cartography_ingestion_function
|
||||
|
||||
from api.attack_paths import database as graph_database
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import (
|
||||
Provider as ProwlerAPIProvider,
|
||||
StateChoices,
|
||||
)
|
||||
from api.models import Provider as ProwlerAPIProvider
|
||||
from api.models import StateChoices
|
||||
from api.utils import initialize_prowler_provider
|
||||
from tasks.jobs.attack_paths import db_utils, findings, internet, sync, utils
|
||||
from tasks.jobs.attack_paths.config import get_cartography_ingestion_function
|
||||
|
||||
# Without this Celery goes crazy with Cartography logging
|
||||
logging.getLogger("cartography").setLevel(logging.ERROR)
|
||||
@@ -147,6 +144,10 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
attack_paths_scan, task_id, tenant_cartography_config
|
||||
)
|
||||
|
||||
subgraph_dropped = False
|
||||
sync_completed = False
|
||||
provider_gated = False
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"Creating Neo4j database {tmp_cartography_config.neo4j_database} for tenant {prowler_api_provider.tenant_id}"
|
||||
@@ -164,7 +165,7 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
) as tmp_neo4j_session:
|
||||
# Indexes creation
|
||||
cartography_create_indexes.run(tmp_neo4j_session, tmp_cartography_config)
|
||||
findings.create_findings_indexes(tmp_neo4j_session)
|
||||
indexes.create_findings_indexes(tmp_neo4j_session)
|
||||
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 2)
|
||||
|
||||
# The real scan, where iterates over cloud services
|
||||
@@ -220,15 +221,17 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
cartography_create_indexes.run(
|
||||
tenant_neo4j_session, tenant_cartography_config
|
||||
)
|
||||
findings.create_findings_indexes(tenant_neo4j_session)
|
||||
sync.create_sync_indexes(tenant_neo4j_session)
|
||||
indexes.create_findings_indexes(tenant_neo4j_session)
|
||||
indexes.create_sync_indexes(tenant_neo4j_session)
|
||||
|
||||
logger.info(f"Deleting existing provider graph in {tenant_database_name}")
|
||||
db_utils.set_provider_graph_data_ready(attack_paths_scan, False)
|
||||
provider_gated = True
|
||||
graph_database.drop_subgraph(
|
||||
database=tenant_database_name,
|
||||
provider_id=str(prowler_api_provider.id),
|
||||
)
|
||||
subgraph_dropped = True
|
||||
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 98)
|
||||
|
||||
logger.info(
|
||||
@@ -237,8 +240,10 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
sync.sync_graph(
|
||||
source_database=tmp_database_name,
|
||||
target_database=tenant_database_name,
|
||||
tenant_id=str(prowler_api_provider.tenant_id),
|
||||
provider_id=str(prowler_api_provider.id),
|
||||
)
|
||||
sync_completed = True
|
||||
db_utils.set_graph_data_ready(attack_paths_scan, True)
|
||||
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 99)
|
||||
|
||||
@@ -263,23 +268,39 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
logger.exception(exception_message)
|
||||
ingestion_exceptions["global_error"] = exception_message
|
||||
|
||||
# Handling databases changes
|
||||
# Recover graph_data_ready based on how far the swap got.
|
||||
# Partial drop (mid-batch failure) may leave `subgraph_dropped=False`
|
||||
# with data partially deleted, so we prefer that over permanently blocked queries.
|
||||
try:
|
||||
if sync_completed:
|
||||
db_utils.set_graph_data_ready(attack_paths_scan, True)
|
||||
elif provider_gated and not subgraph_dropped:
|
||||
db_utils.set_provider_graph_data_ready(attack_paths_scan, True)
|
||||
|
||||
except Exception:
|
||||
logger.error(
|
||||
f"Failed to recover `graph_data_ready` for provider {attack_paths_scan.provider_id}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Dropping the temporary database if it still exists
|
||||
try:
|
||||
graph_database.drop_database(tmp_cartography_config.neo4j_database)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to drop temporary Neo4j database {tmp_cartography_config.neo4j_database} during cleanup: {e}",
|
||||
f"Failed to drop temporary Neo4j database `{tmp_cartography_config.neo4j_database}` during cleanup: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Set Attack Paths scan state to FAILED
|
||||
try:
|
||||
db_utils.finish_attack_paths_scan(
|
||||
attack_paths_scan, StateChoices.FAILED, ingestion_exceptions
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Could not mark attack paths scan {attack_paths_scan.id} as FAILED (row may have been deleted): {e}",
|
||||
f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` (row may have been deleted): {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -8,16 +8,15 @@ to the tenant database, adding provider isolation labels and properties.
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
import neo4j
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
from api.attack_paths import database as graph_database
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
BATCH_SIZE,
|
||||
DEPRECATED_PROVIDER_RESOURCE_LABEL,
|
||||
PROVIDER_ISOLATION_PROPERTIES,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
SYNC_BATCH_SIZE,
|
||||
get_provider_label,
|
||||
get_tenant_label,
|
||||
)
|
||||
from tasks.jobs.attack_paths.indexes import IndexType, create_indexes
|
||||
from tasks.jobs.attack_paths.queries import (
|
||||
NODE_FETCH_QUERY,
|
||||
NODE_SYNC_TEMPLATE,
|
||||
@@ -26,17 +25,15 @@ from tasks.jobs.attack_paths.queries import (
|
||||
render_cypher_template,
|
||||
)
|
||||
|
||||
from api.attack_paths import database as graph_database
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def create_sync_indexes(neo4j_session) -> None:
|
||||
"""Create indexes for provider resource sync operations."""
|
||||
create_indexes(neo4j_session, IndexType.SYNC)
|
||||
|
||||
|
||||
def sync_graph(
|
||||
source_database: str,
|
||||
target_database: str,
|
||||
tenant_id: str,
|
||||
provider_id: str,
|
||||
) -> dict[str, int]:
|
||||
"""
|
||||
@@ -45,6 +42,7 @@ def sync_graph(
|
||||
Args:
|
||||
`source_database`: The temporary scan database
|
||||
`target_database`: The tenant database
|
||||
`tenant_id`: The tenant ID for isolation
|
||||
`provider_id`: The provider ID for isolation
|
||||
|
||||
Returns:
|
||||
@@ -53,6 +51,7 @@ def sync_graph(
|
||||
nodes_synced = sync_nodes(
|
||||
source_database,
|
||||
target_database,
|
||||
tenant_id,
|
||||
provider_id,
|
||||
)
|
||||
relationships_synced = sync_relationships(
|
||||
@@ -70,67 +69,56 @@ def sync_graph(
|
||||
def sync_nodes(
|
||||
source_database: str,
|
||||
target_database: str,
|
||||
tenant_id: str,
|
||||
provider_id: str,
|
||||
) -> int:
|
||||
"""
|
||||
Sync nodes from source to target database.
|
||||
|
||||
Adds `_ProviderResource` label and `_provider_id` property to all nodes.
|
||||
Adds `_ProviderResource` label and dynamic `_Tenant_{id}` and `_Provider_{id}`
|
||||
isolation labels to all nodes.
|
||||
|
||||
Source and target sessions are opened sequentially per batch to avoid
|
||||
holding two Bolt connections simultaneously for the entire sync duration.
|
||||
"""
|
||||
last_id = -1
|
||||
total_synced = 0
|
||||
|
||||
with (
|
||||
graph_database.get_session(source_database) as source_session,
|
||||
graph_database.get_session(target_database) as target_session,
|
||||
):
|
||||
while True:
|
||||
rows = list(
|
||||
source_session.run(
|
||||
NODE_FETCH_QUERY,
|
||||
{"last_id": last_id, "batch_size": BATCH_SIZE},
|
||||
)
|
||||
while True:
|
||||
grouped: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
||||
batch_count = 0
|
||||
|
||||
with graph_database.get_session(source_database) as source_session:
|
||||
result = source_session.run(
|
||||
NODE_FETCH_QUERY,
|
||||
{"last_id": last_id, "batch_size": SYNC_BATCH_SIZE},
|
||||
)
|
||||
for record in result:
|
||||
batch_count += 1
|
||||
last_id = record["internal_id"]
|
||||
key, value = _node_to_sync_dict(record, provider_id)
|
||||
grouped[key].append(value)
|
||||
|
||||
if not rows:
|
||||
break
|
||||
|
||||
last_id = rows[-1]["internal_id"]
|
||||
|
||||
grouped: dict[tuple[str, ...], list[dict[str, Any]]] = defaultdict(list)
|
||||
for row in rows:
|
||||
labels = tuple(sorted(set(row["labels"] or [])))
|
||||
props = dict(row["props"] or {})
|
||||
_strip_internal_properties(props)
|
||||
provider_element_id = f"{provider_id}:{row['element_id']}"
|
||||
grouped[labels].append(
|
||||
{
|
||||
"provider_element_id": provider_element_id,
|
||||
"props": props,
|
||||
}
|
||||
)
|
||||
if batch_count == 0:
|
||||
break
|
||||
|
||||
with graph_database.get_session(target_database) as target_session:
|
||||
for labels, batch in grouped.items():
|
||||
label_set = set(labels)
|
||||
label_set.add(PROVIDER_RESOURCE_LABEL)
|
||||
label_set.add(DEPRECATED_PROVIDER_RESOURCE_LABEL)
|
||||
label_set.add(get_tenant_label(tenant_id))
|
||||
label_set.add(get_provider_label(provider_id))
|
||||
node_labels = ":".join(f"`{label}`" for label in sorted(label_set))
|
||||
|
||||
query = render_cypher_template(
|
||||
NODE_SYNC_TEMPLATE, {"__NODE_LABELS__": node_labels}
|
||||
)
|
||||
target_session.run(
|
||||
query,
|
||||
{
|
||||
"rows": batch,
|
||||
"provider_id": provider_id,
|
||||
},
|
||||
)
|
||||
target_session.run(query, {"rows": batch})
|
||||
|
||||
total_synced += len(rows)
|
||||
logger.info(
|
||||
f"Synced {total_synced} nodes from {source_database} to {target_database}"
|
||||
)
|
||||
total_synced += batch_count
|
||||
logger.info(
|
||||
f"Synced {total_synced} nodes from {source_database} to {target_database}"
|
||||
)
|
||||
|
||||
return total_synced
|
||||
|
||||
@@ -143,62 +131,75 @@ def sync_relationships(
|
||||
"""
|
||||
Sync relationships from source to target database.
|
||||
|
||||
Adds `_provider_id` property to all relationships.
|
||||
Matches source and target nodes by `_provider_element_id` in the tenant database.
|
||||
|
||||
Source and target sessions are opened sequentially per batch to avoid
|
||||
holding two Bolt connections simultaneously for the entire sync duration.
|
||||
"""
|
||||
last_id = -1
|
||||
total_synced = 0
|
||||
|
||||
with (
|
||||
graph_database.get_session(source_database) as source_session,
|
||||
graph_database.get_session(target_database) as target_session,
|
||||
):
|
||||
while True:
|
||||
rows = list(
|
||||
source_session.run(
|
||||
RELATIONSHIPS_FETCH_QUERY,
|
||||
{"last_id": last_id, "batch_size": BATCH_SIZE},
|
||||
)
|
||||
while True:
|
||||
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
batch_count = 0
|
||||
|
||||
with graph_database.get_session(source_database) as source_session:
|
||||
result = source_session.run(
|
||||
RELATIONSHIPS_FETCH_QUERY,
|
||||
{"last_id": last_id, "batch_size": SYNC_BATCH_SIZE},
|
||||
)
|
||||
for record in result:
|
||||
batch_count += 1
|
||||
last_id = record["internal_id"]
|
||||
key, value = _rel_to_sync_dict(record, provider_id)
|
||||
grouped[key].append(value)
|
||||
|
||||
if not rows:
|
||||
break
|
||||
|
||||
last_id = rows[-1]["internal_id"]
|
||||
|
||||
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for row in rows:
|
||||
props = dict(row["props"] or {})
|
||||
_strip_internal_properties(props)
|
||||
rel_type = row["rel_type"]
|
||||
grouped[rel_type].append(
|
||||
{
|
||||
"start_element_id": f"{provider_id}:{row['start_element_id']}",
|
||||
"end_element_id": f"{provider_id}:{row['end_element_id']}",
|
||||
"provider_element_id": f"{provider_id}:{rel_type}:{row['internal_id']}",
|
||||
"props": props,
|
||||
}
|
||||
)
|
||||
if batch_count == 0:
|
||||
break
|
||||
|
||||
with graph_database.get_session(target_database) as target_session:
|
||||
for rel_type, batch in grouped.items():
|
||||
query = render_cypher_template(
|
||||
RELATIONSHIP_SYNC_TEMPLATE, {"__REL_TYPE__": rel_type}
|
||||
)
|
||||
target_session.run(
|
||||
query,
|
||||
{
|
||||
"rows": batch,
|
||||
"provider_id": provider_id,
|
||||
},
|
||||
)
|
||||
target_session.run(query, {"rows": batch})
|
||||
|
||||
total_synced += len(rows)
|
||||
logger.info(
|
||||
f"Synced {total_synced} relationships from {source_database} to {target_database}"
|
||||
)
|
||||
total_synced += batch_count
|
||||
logger.info(
|
||||
f"Synced {total_synced} relationships from {source_database} to {target_database}"
|
||||
)
|
||||
|
||||
return total_synced
|
||||
|
||||
|
||||
def _node_to_sync_dict(
|
||||
record: neo4j.Record, provider_id: str
|
||||
) -> tuple[tuple[str, ...], dict[str, Any]]:
|
||||
"""Transform a source node record into a (grouping_key, sync_dict) pair."""
|
||||
props = dict(record["props"] or {})
|
||||
_strip_internal_properties(props)
|
||||
labels = tuple(sorted(set(record["labels"] or [])))
|
||||
return labels, {
|
||||
"provider_element_id": f"{provider_id}:{record['element_id']}",
|
||||
"props": props,
|
||||
}
|
||||
|
||||
|
||||
def _rel_to_sync_dict(
|
||||
record: neo4j.Record, provider_id: str
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""Transform a source relationship record into a (grouping_key, sync_dict) pair."""
|
||||
props = dict(record["props"] or {})
|
||||
_strip_internal_properties(props)
|
||||
rel_type = record["rel_type"]
|
||||
return rel_type, {
|
||||
"start_element_id": f"{provider_id}:{record['start_element_id']}",
|
||||
"end_element_id": f"{provider_id}:{record['end_element_id']}",
|
||||
"provider_element_id": f"{provider_id}:{rel_type}:{record['internal_id']}",
|
||||
"props": props,
|
||||
}
|
||||
|
||||
|
||||
def _strip_internal_properties(props: dict[str, Any]) -> None:
|
||||
"""Remove provider isolation properties before the += spread in sync templates."""
|
||||
for key in PROVIDER_ISOLATION_PROPERTIES:
|
||||
|
||||
@@ -677,6 +677,7 @@ def _process_finding_micro_batch(
|
||||
|
||||
# Create finding object (don't save yet)
|
||||
check_metadata = finding.get_metadata()
|
||||
check_metadata["compliance"] = finding.compliance
|
||||
finding_instance = Finding(
|
||||
tenant_id=tenant_id,
|
||||
uid=finding_uid,
|
||||
@@ -1887,7 +1888,8 @@ def aggregate_finding_group_summaries(tenant_id: str, scan_id: str):
|
||||
inserted_at=summary_timestamp,
|
||||
updated_at=updated_at,
|
||||
check_title=metadata.get("checktitle", ""),
|
||||
check_description=metadata.get("Description", ""),
|
||||
check_description=metadata.get("description", "")
|
||||
or metadata.get("Description", ""),
|
||||
severity_order=row["severity_order"] or 1,
|
||||
pass_count=row["pass_count"],
|
||||
fail_count=row["fail_count"],
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.db.models import Count, Q
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import Finding, StatusChoices
|
||||
from api.models import Finding, Scan, StatusChoices
|
||||
from prowler.lib.outputs.finding import Finding as FindingOutput
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
@@ -35,25 +35,26 @@ def _aggregate_requirement_statistics_from_database(
|
||||
}
|
||||
"""
|
||||
requirement_statistics_by_check_id = {}
|
||||
# TODO: take into account that now the relation is 1 finding == 1 resource, review this when the logic changes
|
||||
# TODO: review when finding-resource relation changes from 1:1
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
# Pre-check: skip if the scan's provider is deleted (avoids JOINs in the main query)
|
||||
if Scan.all_objects.filter(id=scan_id, provider__is_deleted=True).exists():
|
||||
return requirement_statistics_by_check_id
|
||||
|
||||
aggregated_statistics_queryset = (
|
||||
Finding.all_objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
muted=False,
|
||||
resources__provider__is_deleted=False,
|
||||
)
|
||||
.values("check_id")
|
||||
.annotate(
|
||||
total_findings=Count(
|
||||
"id",
|
||||
distinct=True,
|
||||
filter=Q(status__in=[StatusChoices.PASS, StatusChoices.FAIL]),
|
||||
),
|
||||
passed_findings=Count(
|
||||
"id",
|
||||
distinct=True,
|
||||
filter=Q(status=StatusChoices.PASS),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -11,8 +11,9 @@ from django_celery_beat.models import PeriodicTask
|
||||
from tasks.jobs.attack_paths import (
|
||||
attack_paths_scan,
|
||||
can_provider_run_attack_paths_scan,
|
||||
db_utils as attack_paths_db_utils,
|
||||
)
|
||||
from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
|
||||
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
|
||||
from tasks.jobs.backfill import (
|
||||
backfill_compliance_summaries,
|
||||
backfill_daily_severity_summaries,
|
||||
@@ -406,6 +407,11 @@ def perform_attack_paths_scan_task(self, tenant_id: str, scan_id: str):
|
||||
)
|
||||
|
||||
|
||||
@shared_task(name="attack-paths-cleanup-stale-scans", queue="attack-paths-scans")
|
||||
def cleanup_stale_attack_paths_scans_task():
|
||||
return cleanup_stale_attack_paths_scans()
|
||||
|
||||
|
||||
@shared_task(name="tenant-deletion", queue="deletion", autoretry_for=(Exception,))
|
||||
def delete_tenant_task(tenant_id: str):
|
||||
return delete_tenant(pk=tenant_id)
|
||||
@@ -760,6 +766,33 @@ def aggregate_finding_group_summaries_task(tenant_id: str, scan_id: str):
|
||||
return aggregate_finding_group_summaries(tenant_id=tenant_id, scan_id=scan_id)
|
||||
|
||||
|
||||
@shared_task(
|
||||
base=RLSTask, name="reaggregate-all-finding-group-summaries", queue="overview"
|
||||
)
|
||||
@set_tenant(keep_tenant=True)
|
||||
def reaggregate_all_finding_group_summaries_task(tenant_id: str):
|
||||
"""Reaggregate finding group summaries for all providers' latest completed scans."""
|
||||
latest_scan_ids = list(
|
||||
Scan.objects.filter(tenant_id=tenant_id, state=StateChoices.COMPLETED)
|
||||
.order_by("provider_id", "-completed_at", "-inserted_at")
|
||||
.distinct("provider_id")
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
if latest_scan_ids:
|
||||
logger.info(
|
||||
"Reaggregating finding group summaries for %d scans: %s",
|
||||
len(latest_scan_ids),
|
||||
latest_scan_ids,
|
||||
)
|
||||
group(
|
||||
aggregate_finding_group_summaries_task.si(
|
||||
tenant_id=tenant_id, scan_id=str(scan_id)
|
||||
)
|
||||
for scan_id in latest_scan_ids
|
||||
).apply_async()
|
||||
return {"scans_reaggregated": len(latest_scan_ids)}
|
||||
|
||||
|
||||
@shared_task(base=RLSTask, name="lighthouse-connection-check")
|
||||
@set_tenant
|
||||
def check_lighthouse_connection_task(lighthouse_config_id: str, tenant_id: str = None):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user