mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-23 06:12:00 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaee2622a4 | ||
|
|
9b184d1d45 | ||
|
|
c1d18040b7 | ||
|
|
e59b391dea | ||
|
|
8bf926d4e7 | ||
|
|
0c9654db4b | ||
|
|
31261d78f3 | ||
|
|
e8779953cd | ||
|
|
7345e051cf | ||
|
|
c2b0135e35 | ||
|
|
1c4d8e3e75 | ||
|
|
b3562a800f | ||
|
|
cea4244db8 | ||
|
|
52f2da90f6 | ||
|
|
d1d6825159 | ||
|
|
548dd0f35a | ||
|
|
c11fdb9d6d | ||
|
|
9f3a0534a8 | ||
|
|
44b7afdb25 | ||
|
|
fd19a9a048 | ||
|
|
1da1da54d1 | ||
|
|
c123dc3788 | ||
|
|
3517cb331a | ||
|
|
fa365eb106 | ||
|
|
4526b91d3b |
@@ -146,7 +146,7 @@ DJANGO_SENTRY_DSN=
|
||||
DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute
|
||||
|
||||
# Sentry for the web app (server + browser). The UI_SENTRY_* values load only
|
||||
# when UI_SENTRY_ENABLED="true"; without it they are ignored (default off, zero
|
||||
# when UI_SENTRY_ENABLE="true"; without it they are ignored (default off, zero
|
||||
# egress). The deprecated NEXT_PUBLIC_SENTRY_DSN still activates Sentry without
|
||||
# the flag. SENTRY_RELEASE (unprefixed) feeds the web app's server/edge SDKs.
|
||||
UI_SENTRY_DSN=
|
||||
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.35.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.33.2
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -5,20 +5,10 @@
|
||||
"version": "v8",
|
||||
"sha": "ed597411d8f924073f98dfc5c65a23a2325f34cd"
|
||||
},
|
||||
"github/gh-aw-actions/setup@v0.81.6": {
|
||||
"repo": "github/gh-aw-actions/setup",
|
||||
"version": "v0.81.6",
|
||||
"sha": "ba6380cc6e5be5d21677bebe04d52fb48e3abec7"
|
||||
},
|
||||
"github/gh-aw/actions/setup@v0.43.23": {
|
||||
"repo": "github/gh-aw/actions/setup",
|
||||
"version": "v0.43.23",
|
||||
"sha": "9382be3ca9ac18917e111a99d4e6bbff58d0dccc"
|
||||
},
|
||||
"step-security/harden-runner@v2.20.0": {
|
||||
"repo": "step-security/harden-runner",
|
||||
"version": "v2.20.0",
|
||||
"sha": "bf7454d06d71f1098171f2acdf0cd4708d7b5920"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ Please add a detailed description of how to review this PR.
|
||||
|
||||
- [ ] This feature/issue is listed in the [open issues](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or roadmap.prowler.com
|
||||
- [ ] Is it assigned to me, if not, request it via the [open issues](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or [Prowler Community Slack](https://goto.prowler.com/slack)
|
||||
- [ ] I have reviewed the [open pull requests](https://github.com/prowler-cloud/prowler/pulls?q=sort%3Aupdated-desc+is%3Apr+is%3Aopen) and confirmed there is no existing PR that implements the same outcome
|
||||
|
||||
</details>
|
||||
|
||||
@@ -28,7 +27,7 @@ Please add a detailed description of how to review this PR.
|
||||
- [ ] Review if the code is being covered by tests.
|
||||
- [ ] Review if code is being documented following this specification https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings
|
||||
- [ ] Review if backport is needed.
|
||||
- [ ] Review if is needed to change the [README.md](https://github.com/prowler-cloud/prowler/blob/master/README.md)
|
||||
- [ ] Review if is needed to change the [Readme.md](https://github.com/prowler-cloud/prowler/blob/master/README.md)
|
||||
- [ ] Ensure a changelog fragment is added under [prowler/changelog.d/](https://github.com/prowler-cloud/prowler/tree/master/prowler/changelog.d), if applicable.
|
||||
|
||||
#### SDK/CLI
|
||||
|
||||
@@ -23,10 +23,6 @@
|
||||
"prConcurrentLimit": 20,
|
||||
"prHourlyLimit": 10,
|
||||
"vulnerabilityAlerts": {
|
||||
"labels": [
|
||||
"dependencies",
|
||||
"security"
|
||||
],
|
||||
"prHourlyLimit": 0,
|
||||
"prConcurrentLimit": 0
|
||||
},
|
||||
@@ -64,13 +60,6 @@
|
||||
],
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"description": "gh-aw compiled lock files - generated by 'gh aw compile', action pins must match the compiler version, never bump directly",
|
||||
"matchFileNames": [
|
||||
".github/workflows/*.lock.yml"
|
||||
],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": "GitHub Actions - single grouped PR, no changelog, scope=ci",
|
||||
"matchManagers": [
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -92,8 +92,6 @@ jobs:
|
||||
_http._tcp.deb.debian.org:443
|
||||
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
|
||||
get.trivy.dev:443
|
||||
raw.githubusercontent.com:443
|
||||
releases.astral.sh:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
patch_version: ${{ steps.detect.outputs.patch_version }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -307,7 +307,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
name: 'Tools: Check Test Init Files'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
check-test-init-files:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for __init__.py files in test directories
|
||||
run: python3 scripts/check_test_init_files.py .
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Block outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
name: 'Docs: Check Provider Cards Snippet'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'docs/user-guide/providers/**/getting-started-*.mdx'
|
||||
- 'docs/scripts/generate_provider_cards.py'
|
||||
- 'docs/snippets/provider-cards.mdx'
|
||||
- 'api/src/backend/api/models.py'
|
||||
- '.github/workflows/docs-check-provider-cards.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
check-provider-cards:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify provider cards snippet is up to date
|
||||
run: |
|
||||
if ! python3 docs/scripts/generate_provider_cards.py; then
|
||||
echo "::error::docs/snippets/provider-cards.mdx is out of sync with the provider getting-started pages or the API ProviderChoices enum."
|
||||
echo "Run 'python3 docs/scripts/generate_provider_cards.py' locally and commit the regenerated snippet."
|
||||
echo "--- diff ---"
|
||||
git diff docs/snippets/provider-cards.mdx
|
||||
exit 1
|
||||
fi
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
# We can't block as Trufflehog needs to verify secrets against vendors
|
||||
egress-policy: audit
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
Generated
+700
-1298
File diff suppressed because one or more lines are too long
@@ -12,8 +12,8 @@ if: contains(toJson(github.event.issue.labels), 'status/needs-triage')
|
||||
|
||||
timeout-minutes: 12
|
||||
|
||||
user-rate-limit:
|
||||
max-runs-per-window: 5
|
||||
rate-limit:
|
||||
max: 5
|
||||
window: 60
|
||||
|
||||
concurrency:
|
||||
@@ -30,12 +30,6 @@ permissions:
|
||||
engine: copilot
|
||||
strict: false
|
||||
|
||||
pre-steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
imports:
|
||||
- ../agents/issue-triage.md
|
||||
|
||||
@@ -114,7 +108,7 @@ Triage the following GitHub issue using the Prowler Issue Triage Agent persona.
|
||||
|
||||
## Sanitized Issue Content
|
||||
|
||||
${{ steps.sanitized.outputs.text }}
|
||||
${{ needs.activation.outputs.text }}
|
||||
|
||||
## Instructions
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Add community label
|
||||
if: steps.check_membership.outputs.is_member == 'false' && github.event.pull_request.user.type != 'Bot'
|
||||
if: steps.check_membership.outputs.is_member == 'false'
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -227,7 +227,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -28,13 +28,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.github.com:443
|
||||
github.com:443
|
||||
raw.githubusercontent.com:443
|
||||
objects.githubusercontent.com:443
|
||||
codeload.github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
@@ -42,7 +41,6 @@ jobs:
|
||||
files.pythonhosted.org:443
|
||||
registry.npmjs.org:443
|
||||
nodejs.org:443
|
||||
releases.astral.sh:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
|
||||
with:
|
||||
aws-region: us-east-1
|
||||
role-to-assume: ${{ secrets.PUBLIC_ECR_PUSH_ROLE_ARN }}
|
||||
role-to-assume: ${{ secrets.PUBLIC_ECR_IAM_ROLE_ARN }}
|
||||
|
||||
- name: Login to Public ECR
|
||||
uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2.1.6
|
||||
@@ -215,7 +215,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -241,7 +241,7 @@ jobs:
|
||||
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
|
||||
with:
|
||||
aws-region: us-east-1
|
||||
role-to-assume: ${{ secrets.PUBLIC_ECR_PUSH_ROLE_ARN }}
|
||||
role-to-assume: ${{ secrets.PUBLIC_ECR_IAM_ROLE_ARN }}
|
||||
|
||||
- name: Login to Public ECR
|
||||
uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2.1.6
|
||||
@@ -330,7 +330,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -94,8 +94,6 @@ jobs:
|
||||
_http._tcp.deb.debian.org:443
|
||||
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
|
||||
get.trivy.dev:443
|
||||
raw.githubusercontent.com:443
|
||||
releases.astral.sh:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -615,30 +615,6 @@ jobs:
|
||||
flags: prowler-py${{ matrix.python-version }}-linode
|
||||
files: ./linode_coverage.xml
|
||||
|
||||
# E2E Networks Provider
|
||||
- name: Check if E2E Networks files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-e2enetworks
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/e2enetworks/**
|
||||
./tests/**/e2enetworks/**
|
||||
./uv.lock
|
||||
|
||||
- name: Run E2E Networks tests
|
||||
if: steps.changed-e2enetworks.outputs.any_changed == 'true'
|
||||
run: uv run pytest -n auto --cov=./prowler/providers/e2enetworks --cov-report=xml:e2enetworks_coverage.xml tests/providers/e2enetworks
|
||||
|
||||
- name: Upload E2E Networks coverage to Codecov
|
||||
if: steps.changed-e2enetworks.outputs.any_changed == 'true'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
flags: prowler-py${{ matrix.python-version }}-e2enetworks
|
||||
files: ./e2enetworks_coverage.xml
|
||||
|
||||
# External Provider (dynamic loading)
|
||||
- name: Check if External Provider files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -316,7 +316,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -144,7 +144,7 @@ repos:
|
||||
|
||||
- id: generate-provider-cards
|
||||
name: "Docs - regenerate provider cards snippet"
|
||||
entry: python3 docs/scripts/generate_provider_cards.py
|
||||
entry: python docs/scripts/generate_provider_cards.py
|
||||
language: system
|
||||
files: { glob: ["docs/user-guide/providers/**/getting-started-*.mdx", "docs/scripts/generate_provider_cards.py", "docs/snippets/provider-cards.mdx", "api/src/backend/api/models.py"] }
|
||||
pass_filenames: false
|
||||
|
||||
+22
-33
@@ -15,32 +15,21 @@
|
||||
# neither vulnerable code path (Archive::Tar parsing or regex compilation of
|
||||
# attacker-controlled input) is reachable from Prowler. No Debian bookworm fix
|
||||
# is available yet.
|
||||
CVE-2026-42496 pkg:perl exp:2026-08-15
|
||||
CVE-2026-42496 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-42496 pkg:libperl5.36 exp:2026-08-15
|
||||
CVE-2026-8376 pkg:perl exp:2026-08-15
|
||||
CVE-2026-8376 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-8376 pkg:libperl5.36 exp:2026-08-15
|
||||
|
||||
# CVE-2026-13221 - Perl regex trie overflow.
|
||||
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
|
||||
# Why ignored: upstream confirms Perl 5.36.0 is not affected; the regression
|
||||
# was introduced after this version. Debian currently marks bookworm as
|
||||
# vulnerable, which causes Trivy to report a false positive.
|
||||
# Ref: https://github.com/Perl/perl5/issues/23388
|
||||
CVE-2026-13221 pkg:perl exp:2026-08-15
|
||||
CVE-2026-13221 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-13221 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-13221 pkg:libperl5.36 exp:2026-08-15
|
||||
CVE-2026-42496 pkg:perl exp:2026-07-15
|
||||
CVE-2026-42496 pkg:perl-base exp:2026-07-15
|
||||
CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-07-15
|
||||
CVE-2026-42496 pkg:libperl5.36 exp:2026-07-15
|
||||
CVE-2026-8376 pkg:perl exp:2026-07-15
|
||||
CVE-2026-8376 pkg:perl-base exp:2026-07-15
|
||||
CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-07-15
|
||||
CVE-2026-8376 pkg:libperl5.36 exp:2026-07-15
|
||||
|
||||
# CVE-2025-7458 — SQLite integer overflow.
|
||||
# Package: libsqlite3-0.
|
||||
# Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The
|
||||
# Prowler SDK does not open user-supplied SQLite databases; SQLite usage is
|
||||
# internal and bounded. No Debian bookworm fix is available.
|
||||
CVE-2025-7458 pkg:libsqlite3-0 exp:2026-08-15
|
||||
CVE-2025-7458 pkg:libsqlite3-0 exp:2026-07-15
|
||||
|
||||
# CVE-2026-43185 — Linux kernel ksmbd signedness bug.
|
||||
# Package: linux-libc-dev.
|
||||
@@ -48,7 +37,7 @@ CVE-2025-7458 pkg:libsqlite3-0 exp:2026-08-15
|
||||
# not a running kernel. Containers execute against the host kernel, so these
|
||||
# headers are inert at runtime. The upstream fix landed in kernel 7.0-rc2 and
|
||||
# has not been backported to Debian's 6.1 LTS line.
|
||||
CVE-2026-43185 pkg:linux-libc-dev exp:2026-08-15
|
||||
CVE-2026-43185 pkg:linux-libc-dev exp:2026-07-15
|
||||
|
||||
# CVE-2023-45853 — zlib MiniZip integer overflow / heap overflow in
|
||||
# zipOpenNewFileInZip4_64.
|
||||
@@ -60,8 +49,8 @@ CVE-2026-43185 pkg:linux-libc-dev exp:2026-08-15
|
||||
# zlib 1.3.1, available in Debian trixie (13); migrating the base image would
|
||||
# clear it fully.
|
||||
# Ref: https://security-tracker.debian.org/tracker/CVE-2023-45853
|
||||
CVE-2023-45853 pkg:zlib1g exp:2026-08-15
|
||||
CVE-2023-45853 pkg:zlib1g-dev exp:2026-08-15
|
||||
CVE-2023-45853 pkg:zlib1g exp:2026-07-15
|
||||
CVE-2023-45853 pkg:zlib1g-dev exp:2026-07-15
|
||||
|
||||
# CVE-2026-55200 — libssh2 out-of-bounds write in ssh2_transport_read() due to
|
||||
# an unchecked packet_length field in transport.c (heap corruption, possible RCE).
|
||||
@@ -74,7 +63,7 @@ CVE-2023-45853 pkg:zlib1g-dev exp:2026-08-15
|
||||
# affected code is unreachable at runtime. Fixed upstream in libssh2 commit
|
||||
# 97acf3df (PR #2052); no Debian bookworm fix is available yet.
|
||||
# Ref: https://security-tracker.debian.org/tracker/CVE-2026-55200
|
||||
CVE-2026-55200 pkg:libssh2-1 exp:2026-08-15
|
||||
CVE-2026-55200 pkg:libssh2-1 exp:2026-07-15
|
||||
|
||||
# --- API container image (api/Dockerfile) ---
|
||||
# The entries below are specific to the Prowler API image, which ships
|
||||
@@ -89,13 +78,13 @@ CVE-2026-55200 pkg:libssh2-1 exp:2026-08-15
|
||||
# at runtime. The vulnerable path requires parsing attacker-controlled XML with
|
||||
# the affected interpreter, which Prowler does not do with the system Python.
|
||||
# Full mitigation also needs libexpat >= 2.8.0; no Debian bookworm fix yet.
|
||||
CVE-2026-7210 pkg:python3.11 exp:2026-08-15
|
||||
CVE-2026-7210 pkg:python3.11-dev exp:2026-08-15
|
||||
CVE-2026-7210 pkg:python3.11-minimal exp:2026-08-15
|
||||
CVE-2026-7210 pkg:libpython3.11 exp:2026-08-15
|
||||
CVE-2026-7210 pkg:libpython3.11-dev exp:2026-08-15
|
||||
CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-08-15
|
||||
CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-08-15
|
||||
CVE-2026-7210 pkg:python3.11 exp:2026-07-15
|
||||
CVE-2026-7210 pkg:python3.11-dev exp:2026-07-15
|
||||
CVE-2026-7210 pkg:python3.11-minimal exp:2026-07-15
|
||||
CVE-2026-7210 pkg:libpython3.11 exp:2026-07-15
|
||||
CVE-2026-7210 pkg:libpython3.11-dev exp:2026-07-15
|
||||
CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-07-15
|
||||
CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-07-15
|
||||
|
||||
# CVE-2026-33278 — Unbound DNSSEC validator use-after-free (DoS, possible RCE).
|
||||
# CVE-2026-42960 — Unbound DNS cache poisoning via promiscuous additional records.
|
||||
@@ -105,5 +94,5 @@ CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-08-15
|
||||
# vulnerabilities require operating a live Unbound recursive DNSSEC validator
|
||||
# that processes attacker-influenced DNS responses. Prowler never starts an
|
||||
# Unbound resolver, so neither code path is reachable. No Debian bookworm fix yet.
|
||||
CVE-2026-33278 pkg:libunbound8 exp:2026-08-15
|
||||
CVE-2026-42960 pkg:libunbound8 exp:2026-08-15
|
||||
CVE-2026-33278 pkg:libunbound8 exp:2026-07-15
|
||||
CVE-2026-42960 pkg:libunbound8 exp:2026-07-15
|
||||
|
||||
@@ -5,8 +5,6 @@ LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
|
||||
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
|
||||
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
|
||||
ENV POWERSHELL_TELEMETRY_OPTOUT=1
|
||||
|
||||
ARG TRIVY_VERSION=0.71.2
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<img align="center" alt="Prowler logo" src="https://github.com/prowler-cloud/prowler/blob/master/docs/img/prowler-logo-white.png#gh-dark-mode-only" width="50%" height="50%">
|
||||
</p>
|
||||
<p align="center">
|
||||
<b><i>Prowler</b> is the Open Cloud Security Platform trusted by thousands to automate security and compliance in any cloud environment. With thousands of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.
|
||||
<b><i>Prowler</b> is the Open Cloud Security Platform trusted by thousands to automate security and compliance in any cloud environment. With hundreds of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.
|
||||
</p>
|
||||
<p align="center">
|
||||
<b>Secure ANY cloud at AI Speed at <a href="https://prowler.com">prowler.com</i></b>
|
||||
@@ -21,7 +21,7 @@
|
||||
<a href="https://pypi.python.org/pypi/prowler/"><img alt="Python Version" src="https://img.shields.io/pypi/pyversions/prowler.svg"></a>
|
||||
<a href="https://pypistats.org/packages/prowler"><img alt="PyPI Downloads" src="https://img.shields.io/pypi/dw/prowler.svg?label=downloads"></a>
|
||||
<a href="https://hub.docker.com/r/toniblyx/prowler"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/toniblyx/prowler"></a>
|
||||
<a href="https://gallery.ecr.aws/prowler-cloud/prowler"><img width="120" height="19" alt="AWS ECR Gallery" src="https://user-images.githubusercontent.com/3985464/151531396-b6535a68-c907-44eb-95a1-a09508178616.png"></a>
|
||||
<a href="https://gallery.ecr.aws/prowler-cloud/prowler"><img width="120" height=19" alt="AWS ECR Gallery" src="https://user-images.githubusercontent.com/3985464/151531396-b6535a68-c907-44eb-95a1-a09508178616.png"></a>
|
||||
<a href="https://codecov.io/gh/prowler-cloud/prowler"><img alt="Codecov coverage" src="https://codecov.io/gh/prowler-cloud/prowler/graph/badge.svg?token=OflBGsdpDl"/></a>
|
||||
<a href="https://insights.linuxfoundation.org/project/prowler-cloud-prowler"><img alt="Linux Foundation insights health score" src="https://insights.linuxfoundation.org/api/badge/health-score?project=prowler-cloud-prowler"/></a>
|
||||
</p>
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
# Description
|
||||
|
||||
**Prowler** is the world’s most widely used _Open-Source Cloud Security Platform_ that automates security and compliance across **any cloud environment**. With thousands of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler is built to _“Secure ANY Cloud at AI Speed”_. Prowler delivers **AI-driven**, **customizable**, and **easy-to-use** assessments, dashboards, reports, and integrations, making cloud security **simple**, **scalable**, and **cost-effective** for organizations of any size.
|
||||
**Prowler** is the world’s most widely used _Open-Source Cloud Security Platform_ that automates security and compliance across **any cloud environment**. With hundreds of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler is built to _“Secure ANY Cloud at AI Speed”_. Prowler delivers **AI-driven**, **customizable**, and **easy-to-use** assessments, dashboards, reports, and integrations, making cloud security **simple**, **scalable**, and **cost-effective** for organizations of any size.
|
||||
|
||||
Prowler includes hundreds of built-in controls to ensure compliance with standards and frameworks, including:
|
||||
|
||||
@@ -54,16 +54,16 @@ Prowler includes hundreds of built-in controls to ensure compliance with standar
|
||||
- **National Security Standards:** ENS (Spanish National Security Scheme) and KISA ISMS-P (Korean)
|
||||
- **Custom Security Frameworks:** Tailored to your needs
|
||||
|
||||
## Prowler Cloud & Prowler Local Server
|
||||
## Prowler App / Prowler Cloud
|
||||
|
||||
[Prowler Cloud](https://cloud.prowler.com/) and Prowler Local Server, its self-hosted open-source version, are web applications that simplify running Prowler across your cloud provider accounts. They provide a user-friendly interface to visualize the results and streamline your security assessments.
|
||||
Prowler App / [Prowler Cloud](https://cloud.prowler.com/) is a web-based application that simplifies running Prowler across your cloud provider accounts. It provides a user-friendly interface to visualize the results and streamline your security assessments.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
|
||||
>For more details, refer to the [Prowler Local Server documentation](https://docs.prowler.com/getting-started/installation/prowler-app)
|
||||
>For more details, refer to the [Prowler App Documentation](https://docs.prowler.com/projects/prowler-open-source/en/latest/#prowler-app-installation)
|
||||
|
||||
## Prowler CLI
|
||||
|
||||
@@ -73,12 +73,12 @@ prowler <provider>
|
||||

|
||||
|
||||
|
||||
## Prowler Local Dashboard
|
||||
## Prowler Dashboard
|
||||
|
||||
```console
|
||||
prowler dashboard
|
||||
```
|
||||

|
||||

|
||||
|
||||
|
||||
## Attack Paths
|
||||
@@ -121,7 +121,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
|
||||
> For the most accurate and up-to-date information about checks, services, frameworks, and categories, visit [**Prowler Hub**](https://hub.prowler.com).
|
||||
|
||||
|
||||
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/user-guide/compliance/tutorials/compliance) | [Categories](https://docs.prowler.com/user-guide/cli/tutorials/misc#categories) | Support | Interface |
|
||||
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) | Support | Interface |
|
||||
|---|---|---|---|---|---|---|
|
||||
| AWS | 615 | 86 | 47 | 19 | Official | UI, API, CLI |
|
||||
| Azure | 190 | 22 | 21 | 16 | Official | UI, API, CLI |
|
||||
@@ -141,7 +141,6 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
|
||||
| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI |
|
||||
| Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI |
|
||||
| Linode [Contact us](https://prowler.com/contact) | 10 | 3 | 1 | 4 | Unofficial | CLI |
|
||||
| E2E Networks [Contact us](https://prowler.com/contact) | 27 | 6 | 0 | 2 | Unofficial | CLI |
|
||||
| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 1 | 1 | Unofficial | CLI |
|
||||
| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 1 | 3 | Unofficial | CLI |
|
||||
| NHN | 6 | 2 | 2 | 0 | Unofficial | CLI |
|
||||
@@ -160,11 +159,11 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
|
||||
|
||||
# 💻 Installation
|
||||
|
||||
## Prowler Local Server
|
||||
## Prowler App
|
||||
|
||||
Prowler Local Server offers flexible installation methods tailored to various environments:
|
||||
Prowler App offers flexible installation methods tailored to various environments:
|
||||
|
||||
> For detailed instructions on using Prowler Local Server, refer to the [usage guide](https://docs.prowler.com/user-guide/tutorials/prowler-app).
|
||||
> For detailed instructions on using Prowler App, refer to the [Prowler App Usage Guide](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app/).
|
||||
|
||||
### Docker Compose
|
||||
|
||||
@@ -197,7 +196,7 @@ docker compose up -d
|
||||
> [!WARNING]
|
||||
> 🔒 For a secure setup, the API auto-generates a unique key pair, `DJANGO_TOKEN_SIGNING_KEY` and `DJANGO_TOKEN_VERIFYING_KEY`, and stores it in `~/.config/prowler-api` (non-container) or the bound Docker volume in `_data/api` (container). Never commit or reuse static/default keys. To rotate keys, delete the stored key files and restart the API.
|
||||
|
||||
Once configured, access Prowler Local Server at http://localhost:3000. Sign up using your email and password to get started.
|
||||
Once configured, access the Prowler App at http://localhost:3000. Sign up using your email and password to get started.
|
||||
|
||||
### Common Issues with Docker Pull Installation
|
||||
|
||||
@@ -269,7 +268,7 @@ pnpm run build
|
||||
pnpm start
|
||||
```
|
||||
|
||||
> Once configured, access Prowler Local Server at http://localhost:3000. Sign up using your email and password to get started.
|
||||
> Once configured, access the Prowler App at http://localhost:3000. Sign up using your email and password to get started.
|
||||
|
||||
#### Pre-commit Hooks Setup
|
||||
|
||||
@@ -287,7 +286,7 @@ Prowler CLI is available as a project in [PyPI](https://pypi.org/project/prowler
|
||||
pip install prowler
|
||||
prowler -v
|
||||
```
|
||||
>For further guidance, refer to [https://docs.prowler.com](https://docs.prowler.com/getting-started/installation/prowler-cli)
|
||||
>For further guidance, refer to [https://docs.prowler.com](https://docs.prowler.com/projects/prowler-open-source/en/latest/#prowler-cli-installation)
|
||||
|
||||
### Containers
|
||||
|
||||
@@ -307,7 +306,7 @@ The container images are available here:
|
||||
- Prowler CLI:
|
||||
- [DockerHub](https://hub.docker.com/r/prowlercloud/prowler/tags)
|
||||
- [AWS Public ECR](https://gallery.ecr.aws/prowler-cloud/prowler)
|
||||
- Prowler Local Server:
|
||||
- Prowler App:
|
||||
- [DockerHub - Prowler UI](https://hub.docker.com/r/prowlercloud/prowler-ui/tags)
|
||||
- [DockerHub - Prowler API](https://hub.docker.com/r/prowlercloud/prowler-api/tags)
|
||||
|
||||
@@ -357,55 +356,17 @@ Full configuration, per-provider authentication, and SARIF examples: [Prowler Gi
|
||||
|
||||
# ✏️ High level architecture
|
||||
|
||||
## Prowler Local Server
|
||||
**Prowler Local Server** is composed of four key components:
|
||||
## Prowler App
|
||||
**Prowler App** is composed of four key components:
|
||||
|
||||
- **Prowler UI**: A web-based interface, built with Next.js, providing a user-friendly experience for executing Prowler scans and visualizing results.
|
||||
- **Prowler API**: A backend service, developed with Django REST Framework, responsible for running Prowler scans and storing the generated results.
|
||||
- **Prowler SDK**: A Python SDK designed to extend the functionality of the Prowler CLI for advanced capabilities.
|
||||
- **Prowler MCP Server**: A Model Context Protocol server that provides AI tools for Lighthouse, the AI-powered security assistant. This is a critical dependency for Lighthouse functionality.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
user([User / Security Team])
|
||||
cli([Prowler CLI])
|
||||

|
||||
|
||||
subgraph APP["Prowler Local Server"]
|
||||
ui["Prowler UI<br/>(Next.js)"]
|
||||
api["Prowler API<br/>(Django REST Framework)"]
|
||||
worker["API Worker<br/>(Celery)"]
|
||||
beat["API Scheduler<br/>(Celery Beat)"]
|
||||
mcp["Prowler MCP Server<br/>(Lighthouse AI tools)"]
|
||||
end
|
||||
|
||||
sdk["Prowler SDK<br/>(Python)"]
|
||||
|
||||
subgraph DATA["Data Layer"]
|
||||
pg[("PostgreSQL")]
|
||||
valkey[("Valkey / Redis")]
|
||||
neo4j[("Neo4j")]
|
||||
end
|
||||
|
||||
providers["Providers"]
|
||||
|
||||
user --> ui
|
||||
user --> cli
|
||||
ui -->|REST| api
|
||||
ui -->|MCP HTTP| mcp
|
||||
mcp -->|REST| api
|
||||
api --> pg
|
||||
api --> valkey
|
||||
beat -->|enqueue jobs| valkey
|
||||
valkey -->|dispatch| worker
|
||||
worker --> pg
|
||||
worker -->|Attack Paths| neo4j
|
||||
worker -->|invokes| sdk
|
||||
cli --> sdk
|
||||
|
||||
sdk --> providers
|
||||
```
|
||||
|
||||
<!-- Diagram source: docs/images/products/prowler-app-architecture.mmd — keep this inline block, the docs page getting-started/products/prowler-app.mdx, and the .mmd file in sync. -->
|
||||
<!-- Diagram source: docs/images/products/prowler-app-architecture.mmd — edit there, re-render at https://mermaid.live, and replace the PNG. -->
|
||||
|
||||
|
||||
## Prowler CLI
|
||||
|
||||
@@ -4,21 +4,6 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [1.35.0] (Prowler v5.34.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `rls_transaction` now falls back directly to the primary DB for connection-level mid-query read replica failures via `execute_wrapper`, reducing non-streaming read crashes during replica recovery [(#10379)](https://github.com/prowler-cloud/prowler/pull/10379)
|
||||
- RBAC permission gates now combine permissions from every role assigned to a user in the active tenant [(#11979)](https://github.com/prowler-cloud/prowler/pull/11979)
|
||||
- `attack-paths-cleanup-stale-scans` now retries worker pings and checks recent scan activity before failing scans and removing temporary databases [(#11986)](https://github.com/prowler-cloud/prowler/pull/11986)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- User role relationship updates are limited to the active tenant to preserve role assignments in other tenants [(#11903)](https://github.com/prowler-cloud/prowler/pull/11903)
|
||||
- `api` container image removes the unused Debian `libxml2` runtime package and scopes the `CVE-2026-13221` Trivy exception to unaffected Perl 5.36 packages [(#11991)](https://github.com/prowler-cloud/prowler/pull/11991)
|
||||
|
||||
---
|
||||
|
||||
## [1.34.2] (Prowler v5.33.2)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
@@ -113,7 +113,6 @@ RUN apt-get purge -y --auto-remove \
|
||||
make \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libxmlsec1-openssl \
|
||||
pkg-config \
|
||||
libtool \
|
||||
libxslt1-dev \
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Database migrations now give up after a few seconds when another query holds the table lock, instead of stalling every request that touches that table
|
||||
+2
-2
@@ -45,7 +45,7 @@ dependencies = [
|
||||
"gunicorn==26.0.0",
|
||||
"uvloop==0.22.1",
|
||||
"lxml==6.1.0",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.33",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.36.0"
|
||||
version = "1.34.2"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
|
||||
@@ -66,7 +66,7 @@ READ_EXCEPTION_CODES = [
|
||||
"Neo.ClientError.Procedure.ProcedureNotFound",
|
||||
]
|
||||
CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement."
|
||||
RETRYABLE_WRITE_ERROR_FRAGMENTS = (
|
||||
RETRYABLE_WRITE_ERROR_PREFIXES = (
|
||||
"Operation failed due to conflicting concurrent operations",
|
||||
"Operation terminated (deadline exceeded)",
|
||||
)
|
||||
@@ -78,8 +78,7 @@ SIGV4_TOKEN_LIFETIME_MINUTES = 4
|
||||
def _is_retryable_write_error(exc: Exception) -> bool:
|
||||
if not isinstance(exc, neo4j.exceptions.Neo4jError):
|
||||
return False
|
||||
message = exc.message or ""
|
||||
return any(fragment in message for fragment in RETRYABLE_WRITE_ERROR_FRAGMENTS)
|
||||
return bool(exc.message and exc.message.startswith(RETRYABLE_WRITE_ERROR_PREFIXES))
|
||||
|
||||
|
||||
class NeptuneSink(SinkDatabase):
|
||||
|
||||
@@ -152,10 +152,10 @@ def execute_custom_query(
|
||||
scan: AttackPathsScan,
|
||||
) -> 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
|
||||
# 4. `USING QUERY:TIMEOUTMILLISECONDS` on Neptune - server-side runaway cutoff
|
||||
# 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
|
||||
# 4. `USING QUERY:TIMEOUTMILLISECONDS` on Neptune — server-side runaway cutoff
|
||||
#
|
||||
# Layer 2 is best-effort (regex can't fully parse Cypher);
|
||||
# layer 3 is the safety net that guarantees provider isolation.
|
||||
|
||||
+50
-273
@@ -2,7 +2,7 @@ import re
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import ExitStack, contextmanager, nullcontext
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from api.db_router import (
|
||||
@@ -48,140 +48,6 @@ REPLICA_MAX_ATTEMPTS = env.int("POSTGRES_REPLICA_MAX_ATTEMPTS", default=3)
|
||||
REPLICA_RETRY_BASE_DELAY = env.float("POSTGRES_REPLICA_RETRY_BASE_DELAY", default=0.5)
|
||||
|
||||
SET_CONFIG_QUERY = "SELECT set_config(%s, %s::text, TRUE);"
|
||||
SET_TRANSACTION_READ_ONLY_QUERY = "SET TRANSACTION READ ONLY;"
|
||||
|
||||
REPLICA_CONNECTION_SQLSTATE_PREFIXES = ("08",)
|
||||
REPLICA_CONNECTION_SQLSTATES = {"57P01", "57P02", "57P03"}
|
||||
REPLICA_NON_FAILOVER_SQLSTATES = {"57014", "40001", "40P01"}
|
||||
REPLICA_CONNECTION_ERROR_MESSAGES = (
|
||||
"ssl syscall",
|
||||
"eof detected",
|
||||
"server closed the connection",
|
||||
"connection already closed",
|
||||
"connection not open",
|
||||
"could not connect to server",
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"connection timed out",
|
||||
"lost synchronization",
|
||||
"terminating connection",
|
||||
"database system is starting up",
|
||||
"database system is shutting down",
|
||||
"database system is in recovery mode",
|
||||
)
|
||||
REPLICA_NON_FAILOVER_ERROR_MESSAGES = (
|
||||
"canceling statement due to user request",
|
||||
"deadlock detected",
|
||||
"could not serialize access",
|
||||
)
|
||||
|
||||
|
||||
def _iter_exception_chain(error: BaseException):
|
||||
seen = set()
|
||||
pending = [error]
|
||||
while pending:
|
||||
current = pending.pop(0)
|
||||
if current is None or id(current) in seen:
|
||||
continue
|
||||
seen.add(id(current))
|
||||
yield current
|
||||
|
||||
cause = getattr(current, "__cause__", None)
|
||||
context = getattr(current, "__context__", None)
|
||||
if cause is not None:
|
||||
pending.append(cause)
|
||||
if context is not None:
|
||||
pending.append(context)
|
||||
for arg in getattr(current, "args", ()):
|
||||
if isinstance(arg, BaseException):
|
||||
pending.append(arg)
|
||||
|
||||
|
||||
def _get_exception_sqlstate(error: BaseException) -> str | None:
|
||||
for attr in ("pgcode", "sqlstate"):
|
||||
sqlstate = getattr(error, attr, None)
|
||||
if sqlstate:
|
||||
return sqlstate
|
||||
|
||||
diag = getattr(error, "diag", None)
|
||||
if diag is not None:
|
||||
sqlstate = getattr(diag, "sqlstate", None)
|
||||
if sqlstate:
|
||||
return sqlstate
|
||||
return None
|
||||
|
||||
|
||||
def _is_replica_connection_failure(error: BaseException) -> bool:
|
||||
"""
|
||||
Return True only for replica failures where retrying on primary is safe.
|
||||
|
||||
Query cancellations, serialization failures, and deadlocks should surface to
|
||||
callers because replaying them can hide real query or concurrency problems.
|
||||
"""
|
||||
messages = []
|
||||
sqlstates = set()
|
||||
|
||||
for chained_error in _iter_exception_chain(error):
|
||||
sqlstate = _get_exception_sqlstate(chained_error)
|
||||
if sqlstate:
|
||||
sqlstates.add(sqlstate)
|
||||
messages.append(str(chained_error).lower())
|
||||
|
||||
if sqlstates & REPLICA_NON_FAILOVER_SQLSTATES:
|
||||
return False
|
||||
if any(
|
||||
sqlstate.startswith(REPLICA_CONNECTION_SQLSTATE_PREFIXES)
|
||||
or sqlstate in REPLICA_CONNECTION_SQLSTATES
|
||||
for sqlstate in sqlstates
|
||||
):
|
||||
return True
|
||||
|
||||
message = " ".join(messages)
|
||||
if any(marker in message for marker in REPLICA_NON_FAILOVER_ERROR_MESSAGES):
|
||||
return False
|
||||
|
||||
return any(marker in message for marker in REPLICA_CONNECTION_ERROR_MESSAGES)
|
||||
|
||||
|
||||
def _strip_leading_sql_comments(sql: str) -> str:
|
||||
if not isinstance(sql, str):
|
||||
return ""
|
||||
|
||||
sql_text = sql.lstrip()
|
||||
while True:
|
||||
if sql_text.startswith("--"):
|
||||
newline_index = sql_text.find("\n")
|
||||
if newline_index == -1:
|
||||
return ""
|
||||
sql_text = sql_text[newline_index + 1 :].lstrip()
|
||||
continue
|
||||
|
||||
if sql_text.startswith("/*"):
|
||||
comment_end_index = sql_text.find("*/", 2)
|
||||
if comment_end_index == -1:
|
||||
return ""
|
||||
sql_text = sql_text[comment_end_index + 2 :].lstrip()
|
||||
continue
|
||||
|
||||
return sql_text
|
||||
|
||||
|
||||
def _is_safe_primary_replay(sql: str, many: bool) -> bool:
|
||||
if many:
|
||||
return False
|
||||
|
||||
sql_text = _strip_leading_sql_comments(sql)
|
||||
if not re.match(r"(?is)^SELECT\b", sql_text):
|
||||
return False
|
||||
|
||||
return not any(
|
||||
re.search(pattern, sql_text, re.IGNORECASE | re.DOTALL)
|
||||
for pattern in (
|
||||
r"\bINTO\b",
|
||||
r"\bFOR\s+(?:NO\s+KEY\s+)?UPDATE\b",
|
||||
r"\bFOR\s+(?:KEY\s+)?SHARE\b",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -211,36 +77,14 @@ def rls_transaction(
|
||||
retry_on_replica: bool = True,
|
||||
):
|
||||
"""
|
||||
Context manager that opens an RLS-scoped database transaction.
|
||||
|
||||
Sets a Postgres configuration variable (``set_config``) so that Row-Level
|
||||
Security policies can filter by tenant. When *using* points to a read
|
||||
replica and *retry_on_replica* is True, replica failures are handled in two
|
||||
places:
|
||||
|
||||
1. **Pre-yield** (connection-setup failures): the function retries
|
||||
up to ``REPLICA_MAX_ATTEMPTS`` times on the replica, then falls
|
||||
back to the primary DB.
|
||||
2. **Post-yield** (mid-query failures): an ``execute_wrapper``
|
||||
intercepts connection-level ``OperationalError`` during
|
||||
``cursor.execute()`` calls and falls back directly to the primary DB
|
||||
for single ``SELECT`` statements. The primary fallback transaction is
|
||||
read-only, and unsafe statements keep raising the original error.
|
||||
The wrapper swaps the inner cursor so ``fetchall()`` / ``fetchone()``
|
||||
read from the new connection transparently.
|
||||
|
||||
Limitation: server-side cursors (``.iterator()``) fetch rows via
|
||||
``fetchmany()``, which the wrapper does not intercept. Call sites
|
||||
that iterate large result sets with ``.iterator()`` on the replica
|
||||
should add their own retry logic.
|
||||
Creates a new database transaction setting the given configuration value for Postgres RLS. It validates the
|
||||
if the value is a valid UUID.
|
||||
|
||||
Args:
|
||||
value: Database configuration parameter value (must be a valid UUID).
|
||||
parameter: Database configuration parameter name.
|
||||
using: Optional database alias. Defaults to the active read
|
||||
alias or Django's default connection.
|
||||
retry_on_replica: Whether replica setup failures can retry and
|
||||
connection-level mid-query failures can fall back to primary.
|
||||
value (str): Database configuration parameter value.
|
||||
parameter (str): Database configuration parameter name, by default is 'api.tenant_id'.
|
||||
using (str | None): Optional database alias to run the transaction against. Defaults to the
|
||||
active read alias (if any) or Django's default connection.
|
||||
"""
|
||||
requested_alias = using or get_read_db_alias()
|
||||
db_alias = requested_alias or DEFAULT_DB_ALIAS
|
||||
@@ -248,121 +92,54 @@ def rls_transaction(
|
||||
db_alias = DEFAULT_DB_ALIAS
|
||||
|
||||
alias = db_alias
|
||||
is_replica = bool(READ_REPLICA_ALIAS and alias == READ_REPLICA_ALIAS)
|
||||
can_failover = is_replica and retry_on_replica
|
||||
replica_alias = alias # captured before the loop mutates alias
|
||||
max_attempts = (REPLICA_MAX_ATTEMPTS + 1) if can_failover else 1
|
||||
is_replica = READ_REPLICA_ALIAS and alias == READ_REPLICA_ALIAS
|
||||
max_attempts = REPLICA_MAX_ATTEMPTS if is_replica and retry_on_replica else 1
|
||||
|
||||
# State shared between the generator and the _query_failover closure.
|
||||
# The fallback transaction.atomic() is registered into fallback_stack
|
||||
# via enter_context so its __exit__ runs when the outer with-ExitStack
|
||||
# block exits, with the right exc_info. No manual __enter__/__exit__.
|
||||
_fallback = {"succeeded": False, "token": None, "caller_exited_cleanly": False}
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
router_token = None
|
||||
yielded_cursor = False
|
||||
|
||||
with ExitStack() as fallback_stack:
|
||||
# On final attempt, fallback to primary
|
||||
if attempt == max_attempts and is_replica:
|
||||
logger.warning(
|
||||
f"RLS transaction failed after {attempt - 1} attempts on replica, "
|
||||
f"falling back to primary DB"
|
||||
)
|
||||
alias = DEFAULT_DB_ALIAS
|
||||
|
||||
def _query_failover(execute, sql, params, many, context):
|
||||
"""execute_wrapper: replay failed replica queries on the primary DB."""
|
||||
try:
|
||||
return execute(sql, params, many, context)
|
||||
except OperationalError as err:
|
||||
if not _is_replica_connection_failure(err):
|
||||
raise
|
||||
if not _is_safe_primary_replay(sql, many):
|
||||
raise
|
||||
conn = connections[alias]
|
||||
try:
|
||||
if alias != DEFAULT_DB_ALIAS:
|
||||
router_token = set_read_db_alias(alias)
|
||||
|
||||
try:
|
||||
connections[replica_alias].close()
|
||||
except Exception:
|
||||
pass # Best-effort; connection may already be dead
|
||||
with transaction.atomic(using=alias):
|
||||
with conn.cursor() as cursor:
|
||||
try:
|
||||
# just in case the value is a UUID object
|
||||
uuid.UUID(str(value))
|
||||
except ValueError:
|
||||
raise ValidationError("Must be a valid UUID")
|
||||
cursor.execute(SET_CONFIG_QUERY, [parameter, value])
|
||||
yielded_cursor = True
|
||||
yield cursor
|
||||
return
|
||||
except OperationalError as e:
|
||||
if yielded_cursor:
|
||||
raise
|
||||
# If on primary or max attempts reached, raise
|
||||
if not is_replica or attempt == max_attempts:
|
||||
raise
|
||||
|
||||
logger.warning(
|
||||
"Mid-query replica connection failure, falling back to primary DB"
|
||||
)
|
||||
primary = connections[DEFAULT_DB_ALIAS]
|
||||
primary.ensure_connection()
|
||||
fallback_stack.enter_context(transaction.atomic(using=DEFAULT_DB_ALIAS))
|
||||
|
||||
fallback_cursor = primary.cursor()
|
||||
fallback_stack.callback(fallback_cursor.close)
|
||||
fallback_cursor.execute(SET_TRANSACTION_READ_ONLY_QUERY)
|
||||
fallback_cursor.execute(SET_CONFIG_QUERY, [parameter, value])
|
||||
_fallback["token"] = set_read_db_alias(DEFAULT_DB_ALIAS)
|
||||
|
||||
fallback_cursor.execute(sql, params)
|
||||
|
||||
context["cursor"].db = primary
|
||||
context["cursor"].cursor = fallback_cursor.cursor
|
||||
_fallback["succeeded"] = True
|
||||
return None
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
router_token = None
|
||||
yielded_cursor = False
|
||||
|
||||
# On final attempt, fall back to primary
|
||||
if attempt == max_attempts and can_failover:
|
||||
if attempt > 1:
|
||||
logger.warning(
|
||||
f"RLS transaction failed after {attempt - 1} attempts on replica, "
|
||||
f"falling back to primary DB"
|
||||
)
|
||||
alias = DEFAULT_DB_ALIAS
|
||||
|
||||
conn = connections[alias]
|
||||
try:
|
||||
if alias != DEFAULT_DB_ALIAS:
|
||||
router_token = set_read_db_alias(alias)
|
||||
|
||||
with transaction.atomic(using=alias):
|
||||
with conn.cursor() as cursor:
|
||||
try:
|
||||
uuid.UUID(str(value))
|
||||
except ValueError:
|
||||
raise ValidationError("Must be a valid UUID")
|
||||
cursor.execute(SET_CONFIG_QUERY, [parameter, value])
|
||||
|
||||
wrapper_cm = (
|
||||
conn.execute_wrapper(_query_failover)
|
||||
if can_failover and alias == replica_alias
|
||||
else nullcontext()
|
||||
)
|
||||
with wrapper_cm:
|
||||
yielded_cursor = True
|
||||
yield cursor
|
||||
_fallback["caller_exited_cleanly"] = True
|
||||
return
|
||||
except OperationalError as e:
|
||||
if yielded_cursor:
|
||||
if _fallback["succeeded"] and _fallback["caller_exited_cleanly"]:
|
||||
# Caller's queries succeeded on primary via failover.
|
||||
# This error is transaction.atomic() cleanup on the
|
||||
# dead replica connection, suppress it.
|
||||
return
|
||||
raise
|
||||
|
||||
if not can_failover or attempt == max_attempts:
|
||||
raise
|
||||
|
||||
try:
|
||||
connections[alias].close()
|
||||
except Exception:
|
||||
pass # Best-effort; connection may already be dead
|
||||
|
||||
# Retry with exponential backoff
|
||||
delay = REPLICA_RETRY_BASE_DELAY * (2 ** (attempt - 1))
|
||||
logger.info(
|
||||
f"RLS transaction failed on replica (attempt {attempt}/{max_attempts}), "
|
||||
f"retrying in {delay}s. Error: {e}"
|
||||
)
|
||||
time.sleep(delay)
|
||||
finally:
|
||||
if _fallback["token"] is not None:
|
||||
reset_read_db_alias(_fallback["token"])
|
||||
_fallback["token"] = None
|
||||
|
||||
if router_token is not None:
|
||||
reset_read_db_alias(router_token)
|
||||
# Retry with exponential backoff
|
||||
delay = REPLICA_RETRY_BASE_DELAY * (2 ** (attempt - 1))
|
||||
logger.info(
|
||||
f"RLS transaction failed on replica (attempt {attempt}/{max_attempts}), "
|
||||
f"retrying in {delay}s. Error: {e}"
|
||||
)
|
||||
time.sleep(delay)
|
||||
finally:
|
||||
if router_token is not None:
|
||||
reset_read_db_alias(router_token)
|
||||
|
||||
|
||||
class CustomUserManager(BaseUserManager):
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
from config.env import env
|
||||
from django.core.management.commands.migrate import Command as MigrateCommand
|
||||
from django.db import connections
|
||||
|
||||
# Any value Postgres accepts for lock_timeout ("5s", "500ms", ...). "0" disables
|
||||
# it, the escape hatch for a release whose DDL is expected to wait.
|
||||
MIGRATION_LOCK_TIMEOUT = env.str("DJANGO_MIGRATION_LOCK_TIMEOUT", default="5s")
|
||||
|
||||
SET_LOCK_TIMEOUT_QUERY = "SELECT set_config('lock_timeout', %s, FALSE);"
|
||||
|
||||
|
||||
class Command(MigrateCommand):
|
||||
help = (
|
||||
f"{MigrateCommand.help} Applies lock_timeout to the migration session so DDL "
|
||||
"blocked on a lock fails instead of queueing every later reader behind it."
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
connection = connections[options["database"]]
|
||||
|
||||
# is_local=FALSE keeps the setting alive across each migration's own
|
||||
# transaction. Unlike the RLS tenant variable this is safe to leave on the
|
||||
# session: migrate owns its connection for the life of the process and
|
||||
# never returns it to a pool.
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(SET_LOCK_TIMEOUT_QUERY, [MIGRATION_LOCK_TIMEOUT])
|
||||
|
||||
return super().handle(*args, **options)
|
||||
@@ -34,7 +34,7 @@ class HasPermissions(BasePermission):
|
||||
if not tenant_id:
|
||||
return False
|
||||
|
||||
user_roles = list(
|
||||
user_roles = (
|
||||
User.objects.using(MainRouter.admin_db)
|
||||
.get(id=request.user.id)
|
||||
.roles.using(MainRouter.admin_db)
|
||||
@@ -43,10 +43,11 @@ class HasPermissions(BasePermission):
|
||||
if not user_roles:
|
||||
return False
|
||||
|
||||
return all(
|
||||
any(getattr(role, permission.value, False) for role in user_roles)
|
||||
for permission in required_permissions
|
||||
)
|
||||
for perm in required_permissions:
|
||||
if not getattr(user_roles[0], perm.value, False):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_role(user: User, tenant_id: str) -> Role:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.36.0
|
||||
version: 1.34.2
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -14,19 +14,6 @@ from rest_framework_simplejwt.token_blacklist.models import (
|
||||
OutstandingToken,
|
||||
)
|
||||
|
||||
PASSWORD_CHANGE_PASSWORD = "InitialSecret123@"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def password_change_user(tenants_fixture):
|
||||
user = User.objects.create_user(
|
||||
name="password_change_user",
|
||||
email=f"password-change-{uuid4()}@prowler.com",
|
||||
password=PASSWORD_CHANGE_PASSWORD,
|
||||
)
|
||||
Membership.objects.create(user=user, tenant=tenants_fixture[0])
|
||||
return user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_basic_authentication():
|
||||
@@ -122,16 +109,16 @@ def test_refresh_token(create_test_user, tenants_fixture):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_password_change_invalidates_existing_tokens(password_change_user):
|
||||
def test_password_change_invalidates_existing_tokens(create_test_user, tenants_fixture):
|
||||
client = APIClient()
|
||||
new_password = "ChangedSecret123@"
|
||||
|
||||
access_token, refresh_token = get_api_tokens(
|
||||
client, password_change_user.email, PASSWORD_CHANGE_PASSWORD
|
||||
client, create_test_user.email, TEST_PASSWORD
|
||||
)
|
||||
auth_headers = get_authorization_header(access_token)
|
||||
outstanding_token_ids = list(
|
||||
OutstandingToken.objects.filter(user=password_change_user).values_list(
|
||||
OutstandingToken.objects.filter(user=create_test_user).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
@@ -143,12 +130,12 @@ def test_password_change_invalidates_existing_tokens(password_change_user):
|
||||
password_change_payload = {
|
||||
"data": {
|
||||
"type": "users",
|
||||
"id": str(password_change_user.id),
|
||||
"id": str(create_test_user.id),
|
||||
"attributes": {"password": new_password},
|
||||
}
|
||||
}
|
||||
password_change_response = client.patch(
|
||||
reverse("user-detail", kwargs={"pk": password_change_user.id}),
|
||||
reverse("user-detail", kwargs={"pk": create_test_user.id}),
|
||||
data=json.dumps(password_change_payload),
|
||||
headers=auth_headers,
|
||||
content_type="application/vnd.api+json",
|
||||
@@ -173,9 +160,7 @@ def test_password_change_invalidates_existing_tokens(password_change_user):
|
||||
)
|
||||
assert old_refresh_response.status_code == 400
|
||||
|
||||
new_access_token, _ = get_api_tokens(
|
||||
client, password_change_user.email, new_password
|
||||
)
|
||||
new_access_token, _ = get_api_tokens(client, create_test_user.email, new_password)
|
||||
new_access_response = client.get(
|
||||
reverse("user-me"), headers=get_authorization_header(new_access_token)
|
||||
)
|
||||
@@ -184,13 +169,13 @@ def test_password_change_invalidates_existing_tokens(password_change_user):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_password_change_invalidates_rotated_refresh_token(
|
||||
password_change_user,
|
||||
create_test_user, tenants_fixture
|
||||
):
|
||||
client = APIClient()
|
||||
new_password = "ChangedSecret123@"
|
||||
|
||||
access_token, refresh_token = get_api_tokens(
|
||||
client, password_change_user.email, PASSWORD_CHANGE_PASSWORD
|
||||
client, create_test_user.email, TEST_PASSWORD
|
||||
)
|
||||
rotated_refresh_response = client.post(
|
||||
reverse("token-refresh"),
|
||||
@@ -210,12 +195,12 @@ def test_password_change_invalidates_rotated_refresh_token(
|
||||
password_change_payload = {
|
||||
"data": {
|
||||
"type": "users",
|
||||
"id": str(password_change_user.id),
|
||||
"id": str(create_test_user.id),
|
||||
"attributes": {"password": new_password},
|
||||
}
|
||||
}
|
||||
password_change_response = client.patch(
|
||||
reverse("user-detail", kwargs={"pk": password_change_user.id}),
|
||||
reverse("user-detail", kwargs={"pk": create_test_user.id}),
|
||||
data=json.dumps(password_change_payload),
|
||||
headers=get_authorization_header(access_token),
|
||||
content_type="application/vnd.api+json",
|
||||
@@ -319,8 +304,9 @@ def test_user_me_when_inviting_users(create_test_user, tenants_fixture, roles_fi
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestTokenSwitchTenant:
|
||||
def test_switch_tenant_with_valid_token(self, tenants_fixture, aws_provider):
|
||||
def test_switch_tenant_with_valid_token(self, tenants_fixture, providers_fixture):
|
||||
client = APIClient()
|
||||
aws_provider = providers_fixture[0]
|
||||
assert aws_provider
|
||||
|
||||
test_user = "test_email@prowler.com"
|
||||
@@ -1529,13 +1515,14 @@ class TestAPIKeyMultiTenantWorkflows:
|
||||
assert me_response2.json()["data"]["id"] == str(user.id)
|
||||
|
||||
def test_api_key_cannot_access_different_tenant_resources(
|
||||
self, tenants_fixture, aws_provider
|
||||
self, tenants_fixture, providers_fixture
|
||||
):
|
||||
"""API key from one tenant cannot access resources from another tenant.
|
||||
|
||||
Verifies RLS enforcement after authentication ensures tenant isolation.
|
||||
"""
|
||||
client = APIClient()
|
||||
aws_provider = providers_fixture[0]
|
||||
assert aws_provider
|
||||
|
||||
user1 = User.objects.create_user(
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
"""Tests for rls_transaction retry and fallback logic."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from api.db_utils import POSTGRES_TENANT_VAR, rls_transaction
|
||||
from conftest import TEST_REPLICA_ALIAS
|
||||
from django.db import DEFAULT_DB_ALIAS, OperationalError, connections
|
||||
from psycopg2 import OperationalError as Psycopg2OperationalError
|
||||
from api.db_utils import rls_transaction
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from rest_framework_json_api.serializers import ValidationError
|
||||
|
||||
|
||||
@@ -40,35 +36,3 @@ class TestRLSTransaction:
|
||||
cursor.execute("SELECT current_setting(%s, true)", [custom_param])
|
||||
result = cursor.fetchone()
|
||||
assert result == (str(tenant.id),)
|
||||
|
||||
@pytest.mark.requires_test_replica_alias
|
||||
@pytest.mark.django_db(
|
||||
transaction=True, databases=[DEFAULT_DB_ALIAS, TEST_REPLICA_ALIAS]
|
||||
)
|
||||
def test_mid_query_replica_connection_loss_falls_back_to_primary(self, tenant):
|
||||
"""Real Django connection state: closed replica atomic falls back to primary."""
|
||||
replica = connections[TEST_REPLICA_ALIAS]
|
||||
sql = "SELECT current_setting(%s, true), %s"
|
||||
params = [POSTGRES_TENANT_VAR, 42]
|
||||
failed_once = {"value": False}
|
||||
|
||||
def close_replica_and_raise(execute, sql_arg, params_arg, many, context):
|
||||
if not failed_once["value"] and sql_arg == sql:
|
||||
failed_once["value"] = True
|
||||
replica.close()
|
||||
try:
|
||||
raise Psycopg2OperationalError("SSL SYSCALL error: EOF detected")
|
||||
except Psycopg2OperationalError as psycopg_error:
|
||||
raise OperationalError(
|
||||
"SSL SYSCALL error: EOF detected"
|
||||
) from psycopg_error
|
||||
return execute(sql_arg, params_arg, many, context)
|
||||
|
||||
with patch("api.db_utils.READ_REPLICA_ALIAS", TEST_REPLICA_ALIAS):
|
||||
with rls_transaction(str(tenant.id), using=TEST_REPLICA_ALIAS) as cursor:
|
||||
with replica.execute_wrapper(close_replica_and_raise):
|
||||
cursor.execute(sql, params)
|
||||
result = cursor.fetchone()
|
||||
|
||||
assert failed_once["value"]
|
||||
assert result == (str(tenant.id), 42)
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from api.db_utils import (
|
||||
POSTGRES_TENANT_VAR,
|
||||
SET_CONFIG_QUERY,
|
||||
SET_TRANSACTION_READ_ONLY_QUERY,
|
||||
PostgresEnumMigration,
|
||||
_is_replica_connection_failure,
|
||||
_is_safe_primary_replay,
|
||||
_should_create_index_on_partition,
|
||||
batch_delete,
|
||||
create_objects_in_batches,
|
||||
@@ -397,23 +392,10 @@ class TestRlsTransaction:
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=None):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
mock_replica_conn = MagicMock()
|
||||
mock_replica_cursor = MagicMock()
|
||||
mock_replica_conn.cursor.return_value.__enter__.return_value = (
|
||||
mock_replica_cursor
|
||||
)
|
||||
mock_primary_conn = MagicMock()
|
||||
mock_primary_cursor = MagicMock()
|
||||
mock_primary_conn.cursor.return_value.__enter__.return_value = (
|
||||
mock_primary_cursor
|
||||
)
|
||||
|
||||
def connections_getitem(alias):
|
||||
if alias == "replica":
|
||||
return mock_replica_conn
|
||||
return mock_primary_conn
|
||||
|
||||
mock_connections.__getitem__.side_effect = connections_getitem
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
mock_connections.__getitem__.return_value = mock_conn
|
||||
mock_connections.__contains__.return_value = True
|
||||
|
||||
with patch("api.db_utils.transaction.atomic"):
|
||||
@@ -543,7 +525,7 @@ class TestRlsTransaction:
|
||||
def atomic_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 4:
|
||||
if call_count < 3:
|
||||
raise OperationalError("Connection error")
|
||||
return MagicMock(
|
||||
__enter__=MagicMock(return_value=None),
|
||||
@@ -562,11 +544,10 @@ class TestRlsTransaction:
|
||||
with rls_transaction(tenant_id):
|
||||
pass
|
||||
|
||||
assert mock_sleep.call_count == 3
|
||||
assert mock_sleep.call_count == 2
|
||||
mock_sleep.assert_any_call(0.5)
|
||||
mock_sleep.assert_any_call(1.0)
|
||||
mock_sleep.assert_any_call(2.0)
|
||||
assert mock_logger.info.call_count == 3
|
||||
assert mock_logger.info.call_count == 2
|
||||
|
||||
def test_rls_transaction_operational_error_inside_context_no_retry(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
@@ -597,12 +578,11 @@ class TestRlsTransaction:
|
||||
raise OperationalError("Conflict with recovery")
|
||||
|
||||
mock_sleep.assert_not_called()
|
||||
mock_conn.close.assert_not_called()
|
||||
|
||||
def test_rls_transaction_max_attempts_for_replica(
|
||||
def test_rls_transaction_max_three_attempts_for_replica(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
):
|
||||
"""Test REPLICA_MAX_ATTEMPTS replica tries + 1 primary fallback."""
|
||||
"""Test maximum 3 attempts for replica database."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
@@ -626,11 +606,7 @@ class TestRlsTransaction:
|
||||
with rls_transaction(tenant_id):
|
||||
pass
|
||||
|
||||
assert mock_atomic.call_args_list[-1] == call(
|
||||
using=DEFAULT_DB_ALIAS
|
||||
)
|
||||
# 3 replica + 1 primary = 4 total
|
||||
assert mock_atomic.call_count == 4
|
||||
assert mock_atomic.call_count == 3
|
||||
|
||||
def test_rls_transaction_replica_no_retry_when_disabled(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
@@ -641,23 +617,10 @@ class TestRlsTransaction:
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
mock_replica_conn = MagicMock()
|
||||
mock_replica_cursor = MagicMock()
|
||||
mock_replica_conn.cursor.return_value.__enter__.return_value = (
|
||||
mock_replica_cursor
|
||||
)
|
||||
mock_primary_conn = MagicMock()
|
||||
mock_primary_cursor = MagicMock()
|
||||
mock_primary_conn.cursor.return_value.__enter__.return_value = (
|
||||
mock_primary_cursor
|
||||
)
|
||||
|
||||
def connections_getitem(alias):
|
||||
if alias == "replica":
|
||||
return mock_replica_conn
|
||||
return mock_primary_conn
|
||||
|
||||
mock_connections.__getitem__.side_effect = connections_getitem
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
mock_connections.__getitem__.return_value = mock_conn
|
||||
mock_connections.__contains__.return_value = True
|
||||
|
||||
with patch("api.db_utils.transaction.atomic") as mock_atomic:
|
||||
@@ -719,7 +682,7 @@ class TestRlsTransaction:
|
||||
def atomic_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 4:
|
||||
if call_count < 3:
|
||||
raise OperationalError("Replica error")
|
||||
return MagicMock(
|
||||
__enter__=MagicMock(return_value=None),
|
||||
@@ -728,7 +691,7 @@ class TestRlsTransaction:
|
||||
|
||||
with patch(
|
||||
"api.db_utils.transaction.atomic", side_effect=atomic_side_effect
|
||||
) as mock_atomic:
|
||||
):
|
||||
with patch("api.db_utils.time.sleep"):
|
||||
with patch(
|
||||
"api.db_utils.set_read_db_alias", return_value="token"
|
||||
@@ -738,9 +701,6 @@ class TestRlsTransaction:
|
||||
with rls_transaction(tenant_id):
|
||||
pass
|
||||
|
||||
assert mock_atomic.call_args_list[-1] == call(
|
||||
using=DEFAULT_DB_ALIAS
|
||||
)
|
||||
mock_logger.warning.assert_called_once()
|
||||
warning_msg = mock_logger.warning.call_args[0][0]
|
||||
assert "falling back to primary DB" in warning_msg
|
||||
@@ -765,7 +725,7 @@ class TestRlsTransaction:
|
||||
def atomic_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 4:
|
||||
if call_count < 3:
|
||||
raise OperationalError("Replica error")
|
||||
return MagicMock(
|
||||
__enter__=MagicMock(return_value=None),
|
||||
@@ -784,7 +744,7 @@ class TestRlsTransaction:
|
||||
with rls_transaction(tenant_id):
|
||||
pass
|
||||
|
||||
assert mock_logger.info.call_count == 3
|
||||
assert mock_logger.info.call_count == 2
|
||||
assert mock_logger.warning.call_count == 1
|
||||
|
||||
def test_rls_transaction_operational_error_raised_immediately_on_primary(
|
||||
@@ -950,520 +910,6 @@ class TestRlsTransaction:
|
||||
result = cursor.fetchone()
|
||||
assert result[0] == 1
|
||||
|
||||
# --- Mid-query failover tests ---
|
||||
|
||||
class _FakeDatabaseError(Exception):
|
||||
def __init__(self, message, pgcode=None):
|
||||
super().__init__(message)
|
||||
self.pgcode = pgcode
|
||||
|
||||
def _install_execute_wrapper(self, connection):
|
||||
connection.execute_wrappers = []
|
||||
|
||||
@contextmanager
|
||||
def _execute_wrapper(fn):
|
||||
connection.execute_wrappers.append(fn)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
connection.execute_wrappers.remove(fn)
|
||||
|
||||
connection.execute_wrapper = _execute_wrapper
|
||||
|
||||
def _mock_replica_and_primary_connections(self, mock_connections):
|
||||
mock_replica_conn = MagicMock()
|
||||
self._install_execute_wrapper(mock_replica_conn)
|
||||
mock_replica_cursor = MagicMock()
|
||||
mock_replica_conn.cursor.return_value.__enter__.return_value = (
|
||||
mock_replica_cursor
|
||||
)
|
||||
|
||||
mock_primary_conn = MagicMock()
|
||||
mock_primary_cursor = MagicMock()
|
||||
mock_primary_raw_cursor = MagicMock()
|
||||
mock_primary_cursor.cursor = mock_primary_raw_cursor
|
||||
mock_primary_conn.cursor.return_value = mock_primary_cursor
|
||||
|
||||
def connections_getitem(alias):
|
||||
if alias == "replica":
|
||||
return mock_replica_conn
|
||||
return mock_primary_conn
|
||||
|
||||
mock_connections.__getitem__.side_effect = connections_getitem
|
||||
mock_connections.__contains__.return_value = True
|
||||
|
||||
return mock_replica_conn, mock_primary_conn, mock_primary_cursor
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
_FakeDatabaseError("connection lost", pgcode="08006"),
|
||||
_FakeDatabaseError("terminating connection", pgcode="57P01"),
|
||||
OperationalError("SSL SYSCALL error: EOF detected"),
|
||||
OperationalError("server closed the connection unexpectedly"),
|
||||
OperationalError("database system is starting up"),
|
||||
],
|
||||
)
|
||||
def test_replica_connection_failure_detection_allows_failover(self, error):
|
||||
assert _is_replica_connection_failure(error)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
_FakeDatabaseError("canceling statement", pgcode="57014"),
|
||||
_FakeDatabaseError("could not serialize access", pgcode="40001"),
|
||||
_FakeDatabaseError("deadlock detected", pgcode="40P01"),
|
||||
OperationalError("deadlock detected"),
|
||||
],
|
||||
)
|
||||
def test_replica_connection_failure_detection_rejects_query_errors(self, error):
|
||||
assert not _is_replica_connection_failure(error)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sql", "many", "expected"),
|
||||
[
|
||||
("SELECT 1", False, True),
|
||||
(" -- leading comment\nSELECT 1", False, True),
|
||||
("/* leading comment */ SELECT 1", False, True),
|
||||
("SELECT 1", True, False),
|
||||
("SELECTING 1", False, False),
|
||||
("INSERT INTO fake_table (name) VALUES (%s)", False, False),
|
||||
("WITH rows AS (SELECT 1) SELECT * FROM rows", False, False),
|
||||
("SELECT * INTO fake_table_copy FROM fake_table", False, False),
|
||||
("SELECT * FROM fake_table FOR UPDATE", False, False),
|
||||
("SELECT * FROM fake_table FOR SHARE", False, False),
|
||||
],
|
||||
)
|
||||
def test_primary_replay_safety_detection(self, sql, many, expected):
|
||||
assert _is_safe_primary_replay(sql, many) is expected
|
||||
|
||||
def test_mid_query_failure_falls_directly_back_to_primary(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
):
|
||||
"""Mid-query replica connection loss is replayed once on primary."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
(
|
||||
mock_replica_conn,
|
||||
mock_primary_conn,
|
||||
mock_primary_cursor,
|
||||
) = self._mock_replica_and_primary_connections(mock_connections)
|
||||
|
||||
outer_atomic = MagicMock()
|
||||
outer_atomic.__enter__ = MagicMock(return_value=None)
|
||||
outer_atomic.__exit__ = MagicMock(return_value=False)
|
||||
fallback_atomic = MagicMock()
|
||||
fallback_atomic.__enter__ = MagicMock(return_value=None)
|
||||
fallback_atomic.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"api.db_utils.transaction.atomic",
|
||||
side_effect=[outer_atomic, fallback_atomic],
|
||||
) as mock_atomic:
|
||||
with patch("api.db_utils.time.sleep") as mock_sleep:
|
||||
with patch(
|
||||
"api.db_utils.set_read_db_alias",
|
||||
side_effect=["replica-token", "primary-token"],
|
||||
) as mock_set_alias:
|
||||
with patch(
|
||||
"api.db_utils.reset_read_db_alias"
|
||||
) as mock_reset_alias:
|
||||
with rls_transaction(tenant_id):
|
||||
wrapper = mock_replica_conn.execute_wrappers[0]
|
||||
context_cursor = MagicMock()
|
||||
mock_execute = MagicMock(
|
||||
side_effect=OperationalError(
|
||||
"SSL SYSCALL error: EOF detected"
|
||||
)
|
||||
)
|
||||
|
||||
wrapper(
|
||||
mock_execute,
|
||||
"SELECT %s",
|
||||
["value"],
|
||||
False,
|
||||
{"cursor": context_cursor},
|
||||
)
|
||||
|
||||
mock_sleep.assert_not_called()
|
||||
(
|
||||
mock_replica_conn.ensure_connection.assert_not_called()
|
||||
)
|
||||
mock_replica_conn.close.assert_called_once()
|
||||
(
|
||||
mock_primary_conn.ensure_connection.assert_called_once()
|
||||
)
|
||||
mock_primary_conn.cursor.assert_called_once_with()
|
||||
mock_primary_cursor.execute.assert_has_calls(
|
||||
[
|
||||
call(SET_TRANSACTION_READ_ONLY_QUERY),
|
||||
call(
|
||||
SET_CONFIG_QUERY,
|
||||
[POSTGRES_TENANT_VAR, tenant_id],
|
||||
),
|
||||
call("SELECT %s", ["value"]),
|
||||
]
|
||||
)
|
||||
assert context_cursor.db == mock_primary_conn
|
||||
assert (
|
||||
context_cursor.cursor
|
||||
== mock_primary_cursor.cursor
|
||||
)
|
||||
|
||||
mock_set_alias.assert_has_calls(
|
||||
[
|
||||
call(enable_read_replica),
|
||||
call(DEFAULT_DB_ALIAS),
|
||||
]
|
||||
)
|
||||
mock_reset_alias.assert_has_calls(
|
||||
[call("primary-token"), call("replica-token")]
|
||||
)
|
||||
assert mock_atomic.call_args_list == [
|
||||
call(using=enable_read_replica),
|
||||
call(using=DEFAULT_DB_ALIAS),
|
||||
]
|
||||
assert mock_replica_conn.execute_wrappers == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sql", "params", "many"),
|
||||
[
|
||||
("INSERT INTO fake_table (name) VALUES (%s)", [("one",), ("two",)], True),
|
||||
("INSERT INTO fake_table (name) VALUES (%s)", ["one"], False),
|
||||
("UPDATE fake_table SET name = %s", ["one"], False),
|
||||
("DELETE FROM fake_table WHERE id = %s", [1], False),
|
||||
(
|
||||
"WITH deleted AS (DELETE FROM fake_table RETURNING *) "
|
||||
"SELECT * FROM deleted",
|
||||
None,
|
||||
False,
|
||||
),
|
||||
("SELECT * INTO fake_table_copy FROM fake_table", None, False),
|
||||
],
|
||||
)
|
||||
def test_mid_query_fallback_rejects_unsafe_replay(
|
||||
self, tenants_fixture, enable_read_replica, sql, params, many
|
||||
):
|
||||
"""Only single SELECT statements are replayed on primary."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
(
|
||||
mock_replica_conn,
|
||||
mock_primary_conn,
|
||||
mock_primary_cursor,
|
||||
) = self._mock_replica_and_primary_connections(mock_connections)
|
||||
|
||||
with patch("api.db_utils.transaction.atomic") as mock_atomic:
|
||||
mock_atomic.return_value.__enter__ = MagicMock(return_value=None)
|
||||
mock_atomic.return_value.__exit__ = MagicMock(return_value=False)
|
||||
with patch(
|
||||
"api.db_utils.set_read_db_alias",
|
||||
side_effect=["replica-token", "primary-token"],
|
||||
):
|
||||
with patch("api.db_utils.reset_read_db_alias"):
|
||||
with rls_transaction(tenant_id):
|
||||
wrapper = mock_replica_conn.execute_wrappers[0]
|
||||
mock_execute = MagicMock(
|
||||
side_effect=OperationalError(
|
||||
"server closed the connection"
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(OperationalError):
|
||||
wrapper(
|
||||
mock_execute,
|
||||
sql,
|
||||
params,
|
||||
many,
|
||||
{"cursor": MagicMock()},
|
||||
)
|
||||
|
||||
mock_primary_conn.ensure_connection.assert_not_called()
|
||||
mock_primary_conn.cursor.assert_not_called()
|
||||
mock_primary_cursor.execute.assert_not_called()
|
||||
mock_primary_cursor.executemany.assert_not_called()
|
||||
|
||||
def test_mid_query_non_connection_error_does_not_fall_back(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
):
|
||||
"""Query/concurrency errors are not replayed on primary."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
(
|
||||
mock_replica_conn,
|
||||
mock_primary_conn,
|
||||
_mock_primary_cursor,
|
||||
) = self._mock_replica_and_primary_connections(mock_connections)
|
||||
|
||||
with patch("api.db_utils.transaction.atomic") as mock_atomic:
|
||||
mock_atomic.return_value.__enter__ = MagicMock(return_value=None)
|
||||
mock_atomic.return_value.__exit__ = MagicMock(return_value=False)
|
||||
with patch(
|
||||
"api.db_utils.set_read_db_alias", return_value="replica-token"
|
||||
):
|
||||
with patch("api.db_utils.reset_read_db_alias"):
|
||||
with rls_transaction(tenant_id):
|
||||
wrapper = mock_replica_conn.execute_wrappers[0]
|
||||
mock_execute = MagicMock(
|
||||
side_effect=OperationalError("deadlock detected")
|
||||
)
|
||||
|
||||
with pytest.raises(OperationalError):
|
||||
wrapper(
|
||||
mock_execute,
|
||||
"SELECT 1",
|
||||
None,
|
||||
False,
|
||||
{"cursor": MagicMock()},
|
||||
)
|
||||
|
||||
mock_replica_conn.close.assert_not_called()
|
||||
(
|
||||
mock_primary_conn.ensure_connection.assert_not_called()
|
||||
)
|
||||
|
||||
def test_mid_query_primary_replay_failure_propagates(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
):
|
||||
"""Primary fallback errors propagate as Django OperationalError."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
(
|
||||
mock_replica_conn,
|
||||
_mock_primary_conn,
|
||||
mock_primary_cursor,
|
||||
) = self._mock_replica_and_primary_connections(mock_connections)
|
||||
mock_primary_cursor.execute.side_effect = [
|
||||
None,
|
||||
None,
|
||||
OperationalError("primary down"),
|
||||
]
|
||||
|
||||
with patch("api.db_utils.transaction.atomic") as mock_atomic:
|
||||
mock_atomic.return_value.__enter__ = MagicMock(return_value=None)
|
||||
mock_atomic.return_value.__exit__ = MagicMock(return_value=False)
|
||||
with patch(
|
||||
"api.db_utils.set_read_db_alias",
|
||||
side_effect=["replica-token", "primary-token"],
|
||||
):
|
||||
with patch("api.db_utils.reset_read_db_alias"):
|
||||
with pytest.raises(OperationalError, match="primary down"):
|
||||
with rls_transaction(tenant_id):
|
||||
wrapper = mock_replica_conn.execute_wrappers[0]
|
||||
mock_execute = MagicMock(
|
||||
side_effect=OperationalError(
|
||||
"server closed the connection"
|
||||
)
|
||||
)
|
||||
wrapper(
|
||||
mock_execute,
|
||||
"SELECT 1",
|
||||
None,
|
||||
False,
|
||||
{"cursor": MagicMock()},
|
||||
)
|
||||
|
||||
mock_primary_cursor.close.assert_called_once()
|
||||
|
||||
def test_mid_query_fallback_suppresses_cleanup_error(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
):
|
||||
"""After successful primary fallback, replica cleanup error is suppressed."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
(
|
||||
mock_replica_conn,
|
||||
_mock_primary_conn,
|
||||
mock_primary_cursor,
|
||||
) = self._mock_replica_and_primary_connections(mock_connections)
|
||||
|
||||
# Replica's atomic.__exit__ raises on dead replica cleanup;
|
||||
# primary's atomic.__exit__ returns False (healthy commit).
|
||||
mock_outer_atomic = MagicMock()
|
||||
mock_outer_atomic.__enter__ = MagicMock(return_value=None)
|
||||
mock_outer_atomic.__exit__ = MagicMock(
|
||||
side_effect=OperationalError("cleanup failed on dead replica")
|
||||
)
|
||||
|
||||
mock_fallback_atomic = MagicMock()
|
||||
mock_fallback_atomic.__enter__ = MagicMock(return_value=None)
|
||||
mock_fallback_atomic.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
atomic_call_count = 0
|
||||
|
||||
def atomic_side_effect(*args, **kwargs):
|
||||
nonlocal atomic_call_count
|
||||
atomic_call_count += 1
|
||||
if atomic_call_count == 1:
|
||||
return mock_outer_atomic
|
||||
return mock_fallback_atomic
|
||||
|
||||
with patch(
|
||||
"api.db_utils.transaction.atomic",
|
||||
side_effect=atomic_side_effect,
|
||||
):
|
||||
with patch(
|
||||
"api.db_utils.set_read_db_alias",
|
||||
side_effect=["replica-token", "primary-token"],
|
||||
):
|
||||
with patch("api.db_utils.reset_read_db_alias"):
|
||||
with rls_transaction(tenant_id):
|
||||
wrapper = mock_replica_conn.execute_wrappers[0]
|
||||
mock_execute = MagicMock(
|
||||
side_effect=OperationalError(
|
||||
"server closed the connection"
|
||||
)
|
||||
)
|
||||
mock_context = {"cursor": MagicMock()}
|
||||
wrapper(
|
||||
mock_execute,
|
||||
"SELECT 1",
|
||||
None,
|
||||
False,
|
||||
mock_context,
|
||||
)
|
||||
|
||||
mock_primary_cursor.execute.assert_has_calls(
|
||||
[
|
||||
call(SET_TRANSACTION_READ_ONLY_QUERY),
|
||||
call(
|
||||
SET_CONFIG_QUERY,
|
||||
[POSTGRES_TENANT_VAR, tenant_id],
|
||||
),
|
||||
call("SELECT 1", None),
|
||||
]
|
||||
)
|
||||
|
||||
def test_wrapper_not_installed_on_primary(self, tenants_fixture):
|
||||
"""execute_wrapper is not installed when targeting primary DB."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=None):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute_wrappers = []
|
||||
mock_cursor = MagicMock()
|
||||
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
mock_connections.__getitem__.return_value = mock_conn
|
||||
mock_connections.__contains__.return_value = True
|
||||
|
||||
with patch("api.db_utils.transaction.atomic") as mock_atomic:
|
||||
mock_atomic.return_value.__enter__ = MagicMock(return_value=None)
|
||||
mock_atomic.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
# No wrapper installed on primary
|
||||
assert len(mock_conn.execute_wrappers) == 0
|
||||
|
||||
def test_stale_connection_closed_on_pre_yield_retry(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
):
|
||||
"""Stale connection is closed before each pre-yield retry."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute_wrappers = []
|
||||
mock_cursor = MagicMock()
|
||||
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
mock_connections.__getitem__.return_value = mock_conn
|
||||
mock_connections.__contains__.return_value = True
|
||||
|
||||
call_count = 0
|
||||
|
||||
def atomic_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
raise OperationalError("Connection error")
|
||||
return MagicMock(
|
||||
__enter__=MagicMock(return_value=None),
|
||||
__exit__=MagicMock(return_value=False),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"api.db_utils.transaction.atomic", side_effect=atomic_side_effect
|
||||
):
|
||||
with patch("api.db_utils.time.sleep"):
|
||||
with patch(
|
||||
"api.db_utils.set_read_db_alias", return_value="token"
|
||||
):
|
||||
with patch("api.db_utils.reset_read_db_alias"):
|
||||
with rls_transaction(tenant_id):
|
||||
pass
|
||||
|
||||
# close() called for each failed pre-yield attempt
|
||||
assert mock_conn.close.call_count == 2
|
||||
|
||||
def test_caller_error_propagates_after_successful_failover(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
):
|
||||
"""OperationalError raised by caller after failover is NOT suppressed."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
(
|
||||
mock_replica_conn,
|
||||
_mock_primary_conn,
|
||||
_mock_primary_cursor,
|
||||
) = self._mock_replica_and_primary_connections(mock_connections)
|
||||
|
||||
# Transaction cleanup succeeds so the caller error should surface.
|
||||
mock_atomic_cm = MagicMock()
|
||||
mock_atomic_cm.__enter__ = MagicMock(return_value=None)
|
||||
mock_atomic_cm.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"api.db_utils.transaction.atomic", return_value=mock_atomic_cm
|
||||
):
|
||||
with patch("api.db_utils.time.sleep"):
|
||||
with patch(
|
||||
"api.db_utils.set_read_db_alias", return_value="token"
|
||||
):
|
||||
with patch("api.db_utils.reset_read_db_alias"):
|
||||
with pytest.raises(
|
||||
OperationalError, match="caller error"
|
||||
):
|
||||
with rls_transaction(tenant_id):
|
||||
# Trigger failover (succeeds on primary)
|
||||
wrapper = mock_replica_conn.execute_wrappers[0]
|
||||
mock_execute = MagicMock(
|
||||
side_effect=OperationalError(
|
||||
"server closed the connection"
|
||||
)
|
||||
)
|
||||
mock_context = {"cursor": MagicMock()}
|
||||
wrapper(
|
||||
mock_execute,
|
||||
"SELECT 1",
|
||||
None,
|
||||
False,
|
||||
mock_context,
|
||||
)
|
||||
# Caller errors after successful failover
|
||||
# should still propagate.
|
||||
raise OperationalError("caller error")
|
||||
|
||||
|
||||
class TestPostgresEnumMigration:
|
||||
"""
|
||||
|
||||
@@ -40,10 +40,10 @@ class TestSetTenantDecorator:
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestHandleProviderDeletionDecorator:
|
||||
def test_success_no_exception(self, tenants_fixture, aws_provider):
|
||||
def test_success_no_exception(self, tenants_fixture, providers_fixture):
|
||||
"""Decorated function runs normally when no exception is raised."""
|
||||
tenant = tenants_fixture[0]
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
|
||||
@handle_provider_deletion
|
||||
def task_func(**kwargs):
|
||||
@@ -127,11 +127,11 @@ class TestHandleProviderDeletionDecorator:
|
||||
@patch("api.decorators.rls_transaction")
|
||||
@patch("api.decorators.Provider.objects.filter")
|
||||
def test_provider_exists_reraises_original(
|
||||
self, mock_filter, mock_rls, tenants_fixture, aws_provider
|
||||
self, mock_filter, mock_rls, tenants_fixture, providers_fixture
|
||||
):
|
||||
"""Re-raises original exception when provider still exists."""
|
||||
tenant = tenants_fixture[0]
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
|
||||
mock_rls.return_value.__enter__ = lambda s: None
|
||||
mock_rls.return_value.__exit__ = lambda s, *args: None
|
||||
@@ -187,11 +187,11 @@ class TestHandleProviderDeletionDecorator:
|
||||
@patch("api.decorators.rls_transaction")
|
||||
@patch("api.decorators.Provider.objects.filter")
|
||||
def test_database_error_provider_exists_reraises(
|
||||
self, mock_filter, mock_rls, tenants_fixture, aws_provider
|
||||
self, mock_filter, mock_rls, tenants_fixture, providers_fixture
|
||||
):
|
||||
"""Re-raises original DatabaseError when provider still exists."""
|
||||
tenant = tenants_fixture[0]
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
|
||||
mock_rls.return_value.__enter__ = lambda s: None
|
||||
mock_rls.return_value.__exit__ = lambda s, *args: None
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import psycopg2
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.core.management.commands.migrate import Command as DjangoMigrateCommand
|
||||
from django.db import DEFAULT_DB_ALIAS, OperationalError, connections
|
||||
from django.db.migrations import Migration
|
||||
from django.db.migrations.operations.special import RunSQL
|
||||
from django.db.migrations.state import ProjectState
|
||||
|
||||
|
||||
def _show_lock_timeout(alias: str = DEFAULT_DB_ALIAS) -> str:
|
||||
with connections[alias].cursor() as cursor:
|
||||
cursor.execute("SHOW lock_timeout;")
|
||||
return cursor.fetchone()[0]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestMigrateLockTimeout:
|
||||
def test_api_migrate_command_shadows_django_builtin(self):
|
||||
from django.core.management import get_commands
|
||||
|
||||
assert get_commands()["migrate"] == "api"
|
||||
|
||||
def test_lock_timeout_is_set_before_django_migrates(self):
|
||||
observed = {}
|
||||
|
||||
def fake_handle(_self, *args, **options):
|
||||
observed["lock_timeout"] = _show_lock_timeout()
|
||||
|
||||
with patch.object(DjangoMigrateCommand, "handle", fake_handle):
|
||||
call_command("migrate")
|
||||
|
||||
assert observed["lock_timeout"] == "5s"
|
||||
|
||||
def test_lock_timeout_value_is_configurable(self):
|
||||
observed = {}
|
||||
|
||||
def fake_handle(_self, *args, **options):
|
||||
observed["lock_timeout"] = _show_lock_timeout()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.management.commands.migrate.MIGRATION_LOCK_TIMEOUT",
|
||||
"250ms",
|
||||
),
|
||||
patch.object(DjangoMigrateCommand, "handle", fake_handle),
|
||||
):
|
||||
call_command("migrate")
|
||||
|
||||
assert observed["lock_timeout"] == "250ms"
|
||||
|
||||
def test_lock_timeout_zero_disables_the_timeout(self):
|
||||
observed = {}
|
||||
|
||||
def fake_handle(_self, *args, **options):
|
||||
observed["lock_timeout"] = _show_lock_timeout()
|
||||
|
||||
with (
|
||||
patch("api.management.commands.migrate.MIGRATION_LOCK_TIMEOUT", "0"),
|
||||
patch.object(DjangoMigrateCommand, "handle", fake_handle),
|
||||
):
|
||||
call_command("migrate")
|
||||
|
||||
assert observed["lock_timeout"] == "0"
|
||||
|
||||
def test_lock_timeout_applies_to_the_requested_database(self):
|
||||
with patch.object(DjangoMigrateCommand, "handle", Mock(return_value=None)):
|
||||
call_command("migrate", database=DEFAULT_DB_ALIAS)
|
||||
|
||||
assert _show_lock_timeout() == "5s"
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
class TestMigrateLockTimeoutFailureMode:
|
||||
"""
|
||||
Exercises the real rollback semantics against Postgres: a migration blocked on
|
||||
a lock must fail fast and leave nothing half-applied.
|
||||
"""
|
||||
|
||||
table = "lock_timeout_probe"
|
||||
|
||||
@pytest.fixture
|
||||
def blocked_table(self, settings):
|
||||
with connections[DEFAULT_DB_ALIAS].cursor() as cursor:
|
||||
cursor.execute(f"DROP TABLE IF EXISTS {self.table};")
|
||||
cursor.execute(f"CREATE TABLE {self.table} (id integer);")
|
||||
|
||||
db = settings.DATABASES[DEFAULT_DB_ALIAS]
|
||||
blocker = psycopg2.connect(
|
||||
dbname=db["NAME"],
|
||||
user=db["USER"],
|
||||
password=db["PASSWORD"],
|
||||
host=db["HOST"],
|
||||
port=db["PORT"],
|
||||
)
|
||||
with blocker.cursor() as cursor:
|
||||
cursor.execute(f"LOCK TABLE {self.table} IN ACCESS EXCLUSIVE MODE;")
|
||||
|
||||
yield
|
||||
|
||||
blocker.rollback()
|
||||
blocker.close()
|
||||
with connections[DEFAULT_DB_ALIAS].cursor() as cursor:
|
||||
cursor.execute(f"DROP TABLE IF EXISTS {self.table};")
|
||||
|
||||
def _column_exists(self, column: str) -> bool:
|
||||
with connections[DEFAULT_DB_ALIAS].cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = %s AND column_name = %s;",
|
||||
[self.table, column],
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
def _apply(self, atomic: bool):
|
||||
connection = connections[DEFAULT_DB_ALIAS]
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT set_config('lock_timeout', '250ms', FALSE);")
|
||||
|
||||
migration = type(
|
||||
"ProbeMigration",
|
||||
(Migration,),
|
||||
{
|
||||
"atomic": atomic,
|
||||
"operations": [
|
||||
RunSQL(f"CREATE TABLE {self.table}_first (id integer);"),
|
||||
RunSQL(f"ALTER TABLE {self.table} ADD COLUMN blocked integer;"),
|
||||
],
|
||||
},
|
||||
)("probe", "api")
|
||||
|
||||
try:
|
||||
with connection.schema_editor(atomic=migration.atomic) as schema_editor:
|
||||
migration.apply(ProjectState(), schema_editor, collect_sql=False)
|
||||
finally:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("SELECT set_config('lock_timeout', '0', FALSE);")
|
||||
|
||||
def _first_table_exists(self) -> bool:
|
||||
with connections[DEFAULT_DB_ALIAS].cursor() as cursor:
|
||||
cursor.execute("SELECT to_regclass(%s);", [f"{self.table}_first"])
|
||||
return cursor.fetchone()[0] is not None
|
||||
|
||||
def _drop_first_table(self):
|
||||
with connections[DEFAULT_DB_ALIAS].cursor() as cursor:
|
||||
cursor.execute(f"DROP TABLE IF EXISTS {self.table}_first;")
|
||||
|
||||
def test_atomic_migration_rolls_back_entirely(self, blocked_table):
|
||||
# Fixture holds the lock the migration blocks on; pytest injects it by
|
||||
# parameter name, so we reference it explicitly to keep static
|
||||
# analysers from flagging it as unused.
|
||||
del blocked_table
|
||||
try:
|
||||
with pytest.raises(OperationalError, match="lock timeout"):
|
||||
self._apply(atomic=True)
|
||||
|
||||
assert not self._column_exists("blocked")
|
||||
# The whole migration is one transaction, so the operation that ran
|
||||
# before the blocked one is rolled back too: nothing half-applied.
|
||||
assert not self._first_table_exists()
|
||||
finally:
|
||||
self._drop_first_table()
|
||||
|
||||
def test_non_atomic_migration_keeps_earlier_operations(self, blocked_table):
|
||||
del blocked_table
|
||||
try:
|
||||
with pytest.raises(OperationalError, match="lock timeout"):
|
||||
self._apply(atomic=False)
|
||||
|
||||
assert not self._column_exists("blocked")
|
||||
# atomic = False has no surrounding transaction, so earlier operations
|
||||
# survive while the migration stays unrecorded. Re-running it replays
|
||||
# them. This is inherent to atomic = False, not to lock_timeout, but
|
||||
# lock_timeout makes it reachable more often.
|
||||
assert self._first_table_exists()
|
||||
finally:
|
||||
self._drop_first_table()
|
||||
@@ -19,8 +19,8 @@ from django.db import IntegrityError
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestResourceModel:
|
||||
def test_setting_tags(self, aws_provider):
|
||||
provider = aws_provider
|
||||
def test_setting_tags(self, providers_fixture):
|
||||
provider, *_ = providers_fixture
|
||||
tenant_id = provider.tenant_id
|
||||
|
||||
resource = Resource.objects.create(
|
||||
@@ -111,9 +111,9 @@ class TestResourceModel:
|
||||
# @pytest.mark.django_db
|
||||
# class TestFindingModel:
|
||||
# def test_add_finding_with_long_uid(
|
||||
# self, aws_provider, scans_fixture, resources_fixture
|
||||
# self, providers_fixture, scans_fixture, resources_fixture
|
||||
# ):
|
||||
# provider = aws_provider
|
||||
# provider, *_ = providers_fixture
|
||||
# tenant_id = provider.tenant_id
|
||||
|
||||
# long_uid = "1" * 500
|
||||
@@ -372,8 +372,8 @@ class TestSAMLConfigurationModel:
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProviderComplianceScoreModel:
|
||||
def test_create_provider_compliance_score(self, aws_provider, scans_fixture):
|
||||
provider = aws_provider
|
||||
def test_create_provider_compliance_score(self, providers_fixture, scans_fixture):
|
||||
provider = providers_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
scan.completed_at = datetime.now(UTC)
|
||||
scan.save()
|
||||
@@ -393,9 +393,9 @@ class TestProviderComplianceScoreModel:
|
||||
assert score.requirement_status == StatusChoices.PASS
|
||||
|
||||
def test_unique_constraint_per_provider_compliance_requirement(
|
||||
self, aws_provider, scans_fixture
|
||||
self, providers_fixture, scans_fixture
|
||||
):
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
scan.completed_at = datetime.now(UTC)
|
||||
scan.save()
|
||||
@@ -422,9 +422,9 @@ class TestProviderComplianceScoreModel:
|
||||
)
|
||||
|
||||
def test_different_providers_same_requirement_allowed(
|
||||
self, aws_provider_pair, scans_fixture
|
||||
self, providers_fixture, scans_fixture
|
||||
):
|
||||
provider1, provider2 = aws_provider_pair
|
||||
provider1, provider2, *_ = providers_fixture
|
||||
scan1 = scans_fixture[0]
|
||||
scan1.completed_at = datetime.now(UTC)
|
||||
scan1.save()
|
||||
|
||||
@@ -11,7 +11,6 @@ from api.models import (
|
||||
User,
|
||||
UserRoleRelationship,
|
||||
)
|
||||
from api.rbac.permissions import HasPermissions, Permissions
|
||||
from api.v1.serializers import TokenSerializer
|
||||
from conftest import TEST_PASSWORD, TODAY
|
||||
from django.urls import reverse
|
||||
@@ -435,11 +434,11 @@ class TestUserViewSet:
|
||||
@pytest.mark.django_db
|
||||
class TestProviderViewSet:
|
||||
def test_list_providers_with_all_permissions(
|
||||
self, authenticated_client_rbac, aws_provider
|
||||
self, authenticated_client_rbac, providers_fixture
|
||||
):
|
||||
response = authenticated_client_rbac.get(reverse("provider-list"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == 1
|
||||
assert len(response.json()["data"]) == len(providers_fixture)
|
||||
|
||||
def test_list_providers_with_no_permissions(
|
||||
self, authenticated_client_no_permissions_rbac
|
||||
@@ -451,9 +450,9 @@ class TestProviderViewSet:
|
||||
assert len(response.json()["data"]) == 0
|
||||
|
||||
def test_retrieve_provider_with_all_permissions(
|
||||
self, authenticated_client_rbac, aws_provider
|
||||
self, authenticated_client_rbac, providers_fixture
|
||||
):
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
response = authenticated_client_rbac.get(
|
||||
reverse("provider-detail", kwargs={"pk": provider.id})
|
||||
)
|
||||
@@ -461,9 +460,9 @@ class TestProviderViewSet:
|
||||
assert response.json()["data"]["attributes"]["alias"] == provider.alias
|
||||
|
||||
def test_retrieve_provider_with_no_permissions(
|
||||
self, authenticated_client_no_permissions_rbac, aws_provider
|
||||
self, authenticated_client_no_permissions_rbac, providers_fixture
|
||||
):
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
response = authenticated_client_no_permissions_rbac.get(
|
||||
reverse("provider-detail", kwargs={"pk": provider.id})
|
||||
)
|
||||
@@ -487,9 +486,9 @@ class TestProviderViewSet:
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
def test_partial_update_provider_with_all_permissions(
|
||||
self, authenticated_client_rbac, aws_provider
|
||||
self, authenticated_client_rbac, providers_fixture
|
||||
):
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
@@ -506,9 +505,9 @@ class TestProviderViewSet:
|
||||
assert response.json()["data"]["attributes"]["alias"] == "updated_alias"
|
||||
|
||||
def test_partial_update_provider_with_no_permissions(
|
||||
self, authenticated_client_no_permissions_rbac, aws_provider
|
||||
self, authenticated_client_no_permissions_rbac, providers_fixture
|
||||
):
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
update_payload = {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
@@ -529,7 +528,7 @@ class TestProviderViewSet:
|
||||
mock_delete_task,
|
||||
mock_task_get,
|
||||
authenticated_client_rbac,
|
||||
aws_provider,
|
||||
providers_fixture,
|
||||
tasks_fixture,
|
||||
):
|
||||
prowler_task = tasks_fixture[0]
|
||||
@@ -538,7 +537,7 @@ class TestProviderViewSet:
|
||||
mock_delete_task.return_value = task_mock
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
provider1 = aws_provider
|
||||
provider1, *_ = providers_fixture
|
||||
response = authenticated_client_rbac.delete(
|
||||
reverse("provider-detail", kwargs={"pk": provider1.id})
|
||||
)
|
||||
@@ -550,9 +549,9 @@ class TestProviderViewSet:
|
||||
assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}"
|
||||
|
||||
def test_delete_provider_with_no_permissions(
|
||||
self, authenticated_client_no_permissions_rbac, aws_provider
|
||||
self, authenticated_client_no_permissions_rbac, providers_fixture
|
||||
):
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
response = authenticated_client_no_permissions_rbac.delete(
|
||||
reverse("provider-detail", kwargs={"pk": provider.id})
|
||||
)
|
||||
@@ -565,7 +564,7 @@ class TestProviderViewSet:
|
||||
mock_provider_connection,
|
||||
mock_task_get,
|
||||
authenticated_client_rbac,
|
||||
aws_provider,
|
||||
providers_fixture,
|
||||
tasks_fixture,
|
||||
):
|
||||
prowler_task = tasks_fixture[0]
|
||||
@@ -575,7 +574,7 @@ class TestProviderViewSet:
|
||||
mock_provider_connection.return_value = task_mock
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
provider1 = aws_provider
|
||||
provider1, *_ = providers_fixture
|
||||
assert provider1.connected is None
|
||||
assert provider1.connection_last_checked_at is None
|
||||
|
||||
@@ -590,9 +589,9 @@ class TestProviderViewSet:
|
||||
assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}"
|
||||
|
||||
def test_connection_with_no_permissions(
|
||||
self, authenticated_client_no_permissions_rbac, aws_provider
|
||||
self, authenticated_client_no_permissions_rbac, providers_fixture
|
||||
):
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
response = authenticated_client_no_permissions_rbac.post(
|
||||
reverse("provider-connection", kwargs={"pk": provider.id})
|
||||
)
|
||||
@@ -605,10 +604,10 @@ class TestLimitedVisibility:
|
||||
TEST_PASSWORD = "Thisisapassword123@"
|
||||
|
||||
@pytest.fixture
|
||||
def limited_admin_user(self, django_db_blocker, tenants_fixture, aws_provider):
|
||||
def limited_admin_user(self, django_db_blocker, tenants_fixture, providers_fixture):
|
||||
with django_db_blocker.unblock():
|
||||
tenant = tenants_fixture[0]
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
user = User.objects.create_user(
|
||||
name="testing",
|
||||
email=self.TEST_EMAIL,
|
||||
@@ -655,17 +654,25 @@ class TestLimitedVisibility:
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client_rbac_limited(
|
||||
self,
|
||||
limited_admin_user,
|
||||
tenants_fixture,
|
||||
authenticated_client_for_tenant_factory,
|
||||
self, limited_admin_user, tenants_fixture, client
|
||||
):
|
||||
return authenticated_client_for_tenant_factory(
|
||||
limited_admin_user, tenants_fixture[0]
|
||||
client.user = limited_admin_user
|
||||
tenant_id = tenants_fixture[0].id
|
||||
serializer = TokenSerializer(
|
||||
data={
|
||||
"type": "tokens",
|
||||
"email": self.TEST_EMAIL,
|
||||
"password": self.TEST_PASSWORD,
|
||||
"tenant_id": tenant_id,
|
||||
}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
access_token = serializer.validated_data["access"]
|
||||
client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
|
||||
return client
|
||||
|
||||
def test_integrations(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture
|
||||
self, authenticated_client_rbac_limited, integrations_fixture, providers_fixture
|
||||
):
|
||||
# Integration 2 is related to provider1 and provider 2
|
||||
# This user cannot see provider 2
|
||||
@@ -685,7 +692,7 @@ class TestLimitedVisibility:
|
||||
def test_overviews_providers(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
provider_factory,
|
||||
providers_fixture,
|
||||
):
|
||||
# By default, the associated provider is the one which has the overview data
|
||||
response = authenticated_client_rbac_limited.get(reverse("overview-providers"))
|
||||
@@ -695,7 +702,7 @@ class TestLimitedVisibility:
|
||||
|
||||
# Changing the provider visibility, no data should be returned
|
||||
# Only the associated provider to that group is changed
|
||||
new_provider = provider_factory()
|
||||
new_provider = providers_fixture[1]
|
||||
ProviderGroupMembership.objects.all().update(provider=new_provider)
|
||||
|
||||
response = authenticated_client_rbac_limited.get(reverse("overview-providers"))
|
||||
@@ -715,7 +722,7 @@ class TestLimitedVisibility:
|
||||
self,
|
||||
endpoint_name,
|
||||
authenticated_client_rbac_limited,
|
||||
provider_factory,
|
||||
providers_fixture,
|
||||
):
|
||||
# By default, the associated provider is the one which has the overview data
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
@@ -728,7 +735,7 @@ class TestLimitedVisibility:
|
||||
|
||||
# Changing the provider visibility, no data should be returned
|
||||
# Only the associated provider to that group is changed
|
||||
new_provider = provider_factory()
|
||||
new_provider = providers_fixture[1]
|
||||
ProviderGroupMembership.objects.all().update(provider=new_provider)
|
||||
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
@@ -743,7 +750,7 @@ class TestLimitedVisibility:
|
||||
def test_overviews_services(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
provider_factory,
|
||||
providers_fixture,
|
||||
):
|
||||
# By default, the associated provider is the one which has the overview data
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
@@ -755,7 +762,7 @@ class TestLimitedVisibility:
|
||||
|
||||
# Changing the provider visibility, no data should be returned
|
||||
# Only the associated provider to that group is changed
|
||||
new_provider = provider_factory()
|
||||
new_provider = providers_fixture[1]
|
||||
ProviderGroupMembership.objects.all().update(provider=new_provider)
|
||||
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
@@ -817,48 +824,6 @@ class TestRolePermissions:
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestHasPermissions:
|
||||
def test_permissions_are_combined_across_roles(
|
||||
self, create_test_user_rbac_no_roles
|
||||
):
|
||||
user = create_test_user_rbac_no_roles
|
||||
tenant = Membership.objects.get(user=user).tenant
|
||||
manage_users_role = Role.objects.create(
|
||||
name="manage_users_only",
|
||||
tenant=tenant,
|
||||
manage_users=True,
|
||||
)
|
||||
UserRoleRelationship.objects.create(
|
||||
user=user,
|
||||
role=manage_users_role,
|
||||
tenant=tenant,
|
||||
)
|
||||
request = Mock(user=user, tenant_id=tenant.id)
|
||||
view = Mock(
|
||||
required_permissions=[
|
||||
Permissions.MANAGE_USERS,
|
||||
Permissions.MANAGE_ACCOUNT,
|
||||
]
|
||||
)
|
||||
permission = HasPermissions()
|
||||
|
||||
assert not permission.has_permission(request, view)
|
||||
|
||||
manage_account_role = Role.objects.create(
|
||||
name="manage_account_only",
|
||||
tenant=tenant,
|
||||
manage_account=True,
|
||||
)
|
||||
UserRoleRelationship.objects.create(
|
||||
user=user,
|
||||
role=manage_account_role,
|
||||
tenant=tenant,
|
||||
)
|
||||
|
||||
assert permission.has_permission(request, view)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUserRoleLinkPermissions:
|
||||
def test_link_user_roles_with_manage_account_only_allowed(
|
||||
|
||||
@@ -1,33 +1,9 @@
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from config.settings import sentry as sentry_settings
|
||||
from config.settings.sentry import before_send
|
||||
|
||||
|
||||
def test_initialize_sentry_skips_without_dsn():
|
||||
with (
|
||||
patch.object(sentry_settings.env, "str", return_value=""),
|
||||
patch.object(sentry_settings.sentry_sdk, "init") as mock_init,
|
||||
):
|
||||
sentry_settings.initialize_sentry()
|
||||
|
||||
mock_init.assert_not_called()
|
||||
|
||||
|
||||
def test_initialize_sentry_uses_configured_dsn():
|
||||
sentry_dsn = "https://fake-public-key@sentry.example.invalid/1"
|
||||
|
||||
with (
|
||||
patch.object(sentry_settings.env, "str", return_value=sentry_dsn),
|
||||
patch.object(sentry_settings.sentry_sdk, "init") as mock_init,
|
||||
):
|
||||
sentry_settings.initialize_sentry()
|
||||
|
||||
assert mock_init.call_args.kwargs["dsn"] == sentry_dsn
|
||||
assert mock_init.call_args.kwargs["before_send"] is sentry_settings.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(
|
||||
|
||||
@@ -332,10 +332,9 @@ class TestNeptuneRetryPolicy:
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
[
|
||||
"Unexpected server exception 'Operation failed due to conflicting "
|
||||
"concurrent operations (please retry), 0 transactions are currently "
|
||||
"rolling back.'",
|
||||
"Unexpected server exception 'Operation terminated (deadline exceeded)'",
|
||||
"Operation failed due to conflicting concurrent operations "
|
||||
+ "(please retry), 0 transactions are currently rolling back.",
|
||||
"Operation terminated (deadline exceeded)",
|
||||
],
|
||||
)
|
||||
def test_observed_transient_write_errors_are_retryable(self, message):
|
||||
@@ -346,9 +345,7 @@ class TestNeptuneRetryPolicy:
|
||||
|
||||
def test_unrelated_database_error_is_not_retryable(self):
|
||||
error = MagicMock(spec=neo4j.exceptions.Neo4jError)
|
||||
error.message = (
|
||||
"Unexpected server exception 'Operation terminated (out of memory)'"
|
||||
)
|
||||
error.message = "Operation terminated (out of memory)"
|
||||
|
||||
assert _is_retryable_write_error(error) is False
|
||||
|
||||
|
||||
@@ -188,10 +188,10 @@ class TestProwlerProviderConnectionTest:
|
||||
@pytest.mark.django_db
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_prowler_provider_connection_test_without_secret(
|
||||
self, mock_return_prowler_provider, aws_provider
|
||||
self, mock_return_prowler_provider, providers_fixture
|
||||
):
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
connection = prowler_provider_connection_test(aws_provider)
|
||||
connection = prowler_provider_connection_test(providers_fixture[0])
|
||||
|
||||
assert connection.is_connected is False
|
||||
assert isinstance(connection.error, Provider.secret.RelatedObjectDoesNotExist)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -481,8 +481,8 @@ class UserRoleRelationshipSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
role_ids = [item["id"] for item in validated_data["roles"]]
|
||||
roles = Role.objects.filter(id__in=role_ids)
|
||||
tenant_id = self.context.get("tenant_id")
|
||||
roles = Role.objects.filter(id__in=role_ids, tenant_id=tenant_id)
|
||||
|
||||
new_relationships = [
|
||||
UserRoleRelationship(
|
||||
@@ -496,8 +496,8 @@ class UserRoleRelationshipSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
role_ids = [item["id"] for item in validated_data["roles"]]
|
||||
roles = Role.objects.filter(id__in=role_ids)
|
||||
tenant_id = self.context.get("tenant_id")
|
||||
roles = Role.objects.filter(id__in=role_ids, tenant_id=tenant_id)
|
||||
|
||||
# Safeguard: A tenant must always have at least one user with MANAGE_ACCOUNT.
|
||||
# If the target roles do NOT include MANAGE_ACCOUNT, and the current user is
|
||||
@@ -527,7 +527,7 @@ class UserRoleRelationshipSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
}
|
||||
)
|
||||
|
||||
UserRoleRelationship.objects.filter(user=instance, tenant_id=tenant_id).delete()
|
||||
instance.roles.clear()
|
||||
new_relationships = [
|
||||
UserRoleRelationship(user=instance, role=r, tenant_id=tenant_id)
|
||||
for r in roles
|
||||
|
||||
@@ -308,9 +308,6 @@ CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
|
||||
# Attack Paths
|
||||
ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES = env.int(
|
||||
"ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES", 30
|
||||
)
|
||||
ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int(
|
||||
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880
|
||||
) # 48h
|
||||
|
||||
@@ -115,27 +115,19 @@ def before_send(event, hint):
|
||||
return event
|
||||
|
||||
|
||||
def initialize_sentry():
|
||||
sentry_dsn = env.str("DJANGO_SENTRY_DSN", "")
|
||||
if not sentry_dsn:
|
||||
return
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=sentry_dsn,
|
||||
# Add data like request headers and IP for users,
|
||||
# see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
|
||||
before_send=before_send,
|
||||
send_default_pii=True,
|
||||
traces_sample_rate=env.float("DJANGO_SENTRY_TRACES_SAMPLE_RATE", default=0.02),
|
||||
_experiments={
|
||||
# Set continuous_profiling_auto_start to True
|
||||
# to automatically start the profiler on when
|
||||
# possible.
|
||||
"continuous_profiling_auto_start": True,
|
||||
},
|
||||
attach_stacktrace=True,
|
||||
ignore_errors=IGNORED_EXCEPTIONS,
|
||||
)
|
||||
|
||||
|
||||
initialize_sentry()
|
||||
sentry_sdk.init(
|
||||
dsn=env.str("DJANGO_SENTRY_DSN", ""),
|
||||
# Add data like request headers and IP for users,
|
||||
# see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
|
||||
before_send=before_send,
|
||||
send_default_pii=True,
|
||||
traces_sample_rate=env.float("DJANGO_SENTRY_TRACES_SAMPLE_RATE", default=0.02),
|
||||
_experiments={
|
||||
# Set continuous_profiling_auto_start to True
|
||||
# to automatically start the profiler on when
|
||||
# possible.
|
||||
"continuous_profiling_auto_start": True,
|
||||
},
|
||||
attach_stacktrace=True,
|
||||
ignore_errors=IGNORED_EXCEPTIONS,
|
||||
)
|
||||
|
||||
+199
-278
@@ -2,7 +2,6 @@ import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from allauth.socialaccount.models import SocialLogin
|
||||
@@ -51,14 +50,12 @@ from api.v1.serializers import TokenSerializer
|
||||
from django.conf import settings
|
||||
from django.db import connection as django_connection
|
||||
from django.db import connections as django_connections
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from django_celery_results.models import TaskResult
|
||||
from prowler.lib.check.models import Severity
|
||||
from prowler.lib.outputs.finding import Status
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework_simplejwt.tokens import AccessToken
|
||||
from tasks.jobs.backfill import (
|
||||
aggregate_scan_category_summaries,
|
||||
aggregate_scan_resource_group_summaries,
|
||||
@@ -70,7 +67,6 @@ API_JSON_CONTENT_TYPE = "application/vnd.api+json"
|
||||
NO_TENANT_HTTP_STATUS = status.HTTP_401_UNAUTHORIZED
|
||||
TEST_USER = "dev@prowler.com"
|
||||
TEST_PASSWORD = "testing_psswd"
|
||||
TEST_REPLICA_ALIAS = "test_replica"
|
||||
|
||||
|
||||
def _install_compliance_catalog_test_cache() -> None:
|
||||
@@ -232,15 +228,14 @@ 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():
|
||||
user = User.objects.filter(pk=_session_test_user.pk).first()
|
||||
if user is None:
|
||||
user = User.objects.create_user(
|
||||
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 user
|
||||
return _session_test_user
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@@ -363,42 +358,22 @@ def create_test_user_rbac_manage_account(django_db_setup, django_db_blocker):
|
||||
return user
|
||||
|
||||
|
||||
def first_membership_tenant(user):
|
||||
return user.memberships.order_by("date_joined").first().tenant
|
||||
|
||||
|
||||
def access_token_for_tenant(user, tenant):
|
||||
access_token = AccessToken.for_user(user)
|
||||
access_token["tenant_id"] = str(tenant.id)
|
||||
access_token.payload["nbf"] = access_token["iat"]
|
||||
return str(access_token)
|
||||
|
||||
|
||||
def authenticate_client_for_tenant(client, user, tenant):
|
||||
client.user = user
|
||||
client.defaults["HTTP_AUTHORIZATION"] = (
|
||||
f"Bearer {access_token_for_tenant(user, tenant)}"
|
||||
)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client_for_tenant_factory():
|
||||
def create_authenticated_client(user, tenant):
|
||||
return authenticate_client_for_tenant(Client(), user, tenant)
|
||||
|
||||
return create_authenticated_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client_rbac_manage_account(
|
||||
create_test_user_rbac_manage_account, client
|
||||
create_test_user_rbac_manage_account, tenants_fixture, client
|
||||
):
|
||||
return authenticate_client_for_tenant(
|
||||
client,
|
||||
create_test_user_rbac_manage_account,
|
||||
first_membership_tenant(create_test_user_rbac_manage_account),
|
||||
client.user = create_test_user_rbac_manage_account
|
||||
serializer = TokenSerializer(
|
||||
data={
|
||||
"type": "tokens",
|
||||
"email": "rbac_manage_account@rbac.com",
|
||||
"password": TEST_PASSWORD,
|
||||
}
|
||||
)
|
||||
serializer.is_valid()
|
||||
access_token = serializer.validated_data["access"]
|
||||
client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@@ -435,43 +410,86 @@ def create_test_user_rbac_manage_users_only(django_db_setup, django_db_blocker):
|
||||
def authenticated_client_rbac_manage_users_only(
|
||||
create_test_user_rbac_manage_users_only, client
|
||||
):
|
||||
return authenticate_client_for_tenant(
|
||||
client,
|
||||
create_test_user_rbac_manage_users_only,
|
||||
first_membership_tenant(create_test_user_rbac_manage_users_only),
|
||||
client.user = create_test_user_rbac_manage_users_only
|
||||
serializer = TokenSerializer(
|
||||
data={
|
||||
"type": "tokens",
|
||||
"email": "rbac_manage_users_only@rbac.com",
|
||||
"password": TEST_PASSWORD,
|
||||
}
|
||||
)
|
||||
serializer.is_valid()
|
||||
access_token = serializer.validated_data["access"]
|
||||
client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client_rbac(create_test_user_rbac, tenants_fixture, client):
|
||||
return authenticate_client_for_tenant(
|
||||
client, create_test_user_rbac, tenants_fixture[0]
|
||||
client.user = create_test_user_rbac
|
||||
tenant_id = tenants_fixture[0].id
|
||||
serializer = TokenSerializer(
|
||||
data={
|
||||
"type": "tokens",
|
||||
"email": "rbac@rbac.com",
|
||||
"password": TEST_PASSWORD,
|
||||
"tenant_id": tenant_id,
|
||||
}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
access_token = serializer.validated_data["access"]
|
||||
client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client_rbac_noroles(
|
||||
create_test_user_rbac_no_roles, tenants_fixture, client
|
||||
):
|
||||
return authenticate_client_for_tenant(
|
||||
client, create_test_user_rbac_no_roles, tenants_fixture[0]
|
||||
client.user = create_test_user_rbac_no_roles
|
||||
serializer = TokenSerializer(
|
||||
data={
|
||||
"type": "tokens",
|
||||
"email": "rbac_noroles@rbac.com",
|
||||
"password": TEST_PASSWORD,
|
||||
}
|
||||
)
|
||||
serializer.is_valid()
|
||||
access_token = serializer.validated_data["access"]
|
||||
client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client_no_permissions_rbac(
|
||||
create_test_user_rbac_limited, tenants_fixture, client
|
||||
):
|
||||
return authenticate_client_for_tenant(
|
||||
client, create_test_user_rbac_limited, tenants_fixture[0]
|
||||
client.user = create_test_user_rbac_limited
|
||||
serializer = TokenSerializer(
|
||||
data={
|
||||
"type": "tokens",
|
||||
"email": "rbac_limited@rbac.com",
|
||||
"password": TEST_PASSWORD,
|
||||
}
|
||||
)
|
||||
serializer.is_valid()
|
||||
access_token = serializer.validated_data["access"]
|
||||
client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client(
|
||||
create_test_user, tenants_fixture, set_user_admin_roles_fixture, client
|
||||
):
|
||||
return authenticate_client_for_tenant(client, create_test_user, tenants_fixture[0])
|
||||
client.user = create_test_user
|
||||
serializer = TokenSerializer(
|
||||
data={"type": "tokens", "email": TEST_USER, "password": TEST_PASSWORD}
|
||||
)
|
||||
serializer.is_valid()
|
||||
access_token = serializer.validated_data["access"]
|
||||
client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -572,191 +590,109 @@ def users_fixture(django_user_model):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider_factory(tenants_fixture):
|
||||
tenant = tenants_fixture[0]
|
||||
counters = {}
|
||||
|
||||
def next_counter(provider):
|
||||
counters[provider] = counters.get(provider, 0) + 1
|
||||
return counters[provider]
|
||||
|
||||
def defaults_for(provider, sequence):
|
||||
return {
|
||||
Provider.ProviderChoices.AWS.value: {
|
||||
"uid": f"{123456789011 + sequence:012d}",
|
||||
"alias": f"aws_testing_{sequence}",
|
||||
},
|
||||
Provider.ProviderChoices.AZURE.value: {
|
||||
"uid": str(uuid4()),
|
||||
"alias": f"azure_testing_{sequence}",
|
||||
"scanner_args": {"key1": "value1", "key2": {"key21": "value21"}},
|
||||
},
|
||||
Provider.ProviderChoices.GCP.value: {
|
||||
"uid": f"a12322-test{sequence:05d}",
|
||||
"alias": f"gcp_testing_{sequence}",
|
||||
},
|
||||
Provider.ProviderChoices.KUBERNETES.value: {
|
||||
"uid": f"kubernetes-test-{sequence}",
|
||||
"alias": f"k8s_testing_{sequence}",
|
||||
},
|
||||
Provider.ProviderChoices.M365.value: {
|
||||
"uid": f"m365-{sequence}.test.com",
|
||||
"alias": f"m365_testing_{sequence}",
|
||||
},
|
||||
Provider.ProviderChoices.GITHUB.value: {
|
||||
"uid": f"github-test-{sequence}",
|
||||
"alias": f"github_testing_{sequence}",
|
||||
},
|
||||
Provider.ProviderChoices.MONGODBATLAS.value: {
|
||||
"uid": f"64b1d3c0e4b03b{sequence:010x}",
|
||||
"alias": f"mongodbatlas_testing_{sequence}",
|
||||
},
|
||||
Provider.ProviderChoices.IAC.value: {
|
||||
"uid": f"https://github.com/prowler-cloud/test-{sequence}.git",
|
||||
"alias": f"iac_testing_{sequence}",
|
||||
},
|
||||
Provider.ProviderChoices.ORACLECLOUD.value: {
|
||||
"uid": f"ocid1.tenancy.oc1..aaaaaaaa{sequence:024d}",
|
||||
"alias": f"oci_testing_{sequence}",
|
||||
},
|
||||
Provider.ProviderChoices.ALIBABACLOUD.value: {
|
||||
"uid": f"{1234567890123455 + sequence:016d}",
|
||||
"alias": f"alibabacloud_testing_{sequence}",
|
||||
},
|
||||
Provider.ProviderChoices.CLOUDFLARE.value: {
|
||||
"uid": f"{0x1000000000000000000000000000000 + sequence:032x}",
|
||||
"alias": f"cloudflare_testing_{sequence}",
|
||||
},
|
||||
Provider.ProviderChoices.OPENSTACK.value: {
|
||||
"uid": f"openstack-project-{sequence}",
|
||||
"alias": f"openstack_testing_{sequence}",
|
||||
},
|
||||
Provider.ProviderChoices.IMAGE.value: {
|
||||
"uid": f"registry.example.com/prowler/test:{sequence}",
|
||||
"alias": f"image_testing_{sequence}",
|
||||
},
|
||||
Provider.ProviderChoices.GOOGLEWORKSPACE.value: {
|
||||
"uid": f"C{12345677 + sequence}",
|
||||
"alias": f"googleworkspace_testing_{sequence}",
|
||||
},
|
||||
Provider.ProviderChoices.VERCEL.value: {
|
||||
"uid": f"team_{sequence:016x}",
|
||||
"alias": f"vercel_testing_{sequence}",
|
||||
},
|
||||
Provider.ProviderChoices.OKTA.value: {
|
||||
"uid": f"acme-{sequence}.okta.com",
|
||||
"alias": f"okta_testing_{sequence}",
|
||||
},
|
||||
}[provider]
|
||||
|
||||
def create_provider(provider=Provider.ProviderChoices.AWS.value, **overrides):
|
||||
provider_value = getattr(provider, "value", provider)
|
||||
selected_tenant = overrides.pop("tenant", tenant)
|
||||
sequence = next_counter(provider_value)
|
||||
attributes = {
|
||||
"provider": provider_value,
|
||||
"tenant_id": selected_tenant.id,
|
||||
**defaults_for(provider_value, sequence),
|
||||
}
|
||||
attributes.update(overrides)
|
||||
return Provider.objects.create(**attributes)
|
||||
|
||||
return create_provider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def aws_provider(provider_factory):
|
||||
return provider_factory(Provider.ProviderChoices.AWS.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def aws_provider_pair(aws_provider, provider_factory):
|
||||
return (
|
||||
aws_provider,
|
||||
provider_factory(Provider.ProviderChoices.AWS.value),
|
||||
def providers_fixture(tenants_fixture):
|
||||
tenant, *_ = tenants_fixture
|
||||
provider1 = Provider.objects.create(
|
||||
provider="aws",
|
||||
uid="123456789012",
|
||||
alias="aws_testing_1",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
provider2 = Provider.objects.create(
|
||||
provider="aws",
|
||||
uid="123456789013",
|
||||
alias="aws_testing_2",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
provider3 = Provider.objects.create(
|
||||
provider="gcp",
|
||||
uid="a12322-test321",
|
||||
alias="gcp_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
provider4 = Provider.objects.create(
|
||||
provider="kubernetes",
|
||||
uid="kubernetes-test-12345",
|
||||
alias="k8s_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
provider5 = Provider.objects.create(
|
||||
provider="azure",
|
||||
uid="37b065f8-26b0-4218-a665-0b23d07b27d9",
|
||||
alias="azure_testing",
|
||||
tenant_id=tenant.id,
|
||||
scanner_args={"key1": "value1", "key2": {"key21": "value21"}},
|
||||
)
|
||||
provider6 = Provider.objects.create(
|
||||
provider="m365",
|
||||
uid="m365.test.com",
|
||||
alias="m365_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
provider7 = Provider.objects.create(
|
||||
provider="oraclecloud",
|
||||
uid="ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
alias="oci_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
provider8 = Provider.objects.create(
|
||||
provider="mongodbatlas",
|
||||
uid="64b1d3c0e4b03b1234567890",
|
||||
alias="mongodbatlas_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
provider9 = Provider.objects.create(
|
||||
provider="alibabacloud",
|
||||
uid="1234567890123456",
|
||||
alias="alibabacloud_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
provider10 = Provider.objects.create(
|
||||
provider="cloudflare",
|
||||
uid="a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
|
||||
alias="cloudflare_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
provider11 = Provider.objects.create(
|
||||
provider="openstack",
|
||||
uid="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
alias="openstack_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
provider12 = Provider.objects.create(
|
||||
provider="googleworkspace",
|
||||
uid="C12345678",
|
||||
alias="googleworkspace_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
provider13 = Provider.objects.create(
|
||||
provider="vercel",
|
||||
uid="team_abcdef1234567890ab",
|
||||
alias="vercel_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
provider14 = Provider.objects.create(
|
||||
provider="okta",
|
||||
uid="acme.okta.com",
|
||||
alias="okta_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def azure_provider(provider_factory):
|
||||
return provider_factory(Provider.ProviderChoices.AZURE.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gcp_provider(provider_factory):
|
||||
return provider_factory(Provider.ProviderChoices.GCP.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kubernetes_provider(provider_factory):
|
||||
return provider_factory(Provider.ProviderChoices.KUBERNETES.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def m365_provider(provider_factory):
|
||||
return provider_factory(Provider.ProviderChoices.M365.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def github_provider(provider_factory):
|
||||
return provider_factory(Provider.ProviderChoices.GITHUB.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mongodbatlas_provider(provider_factory):
|
||||
return provider_factory(Provider.ProviderChoices.MONGODBATLAS.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def iac_provider(provider_factory):
|
||||
return provider_factory(Provider.ProviderChoices.IAC.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oraclecloud_provider(provider_factory):
|
||||
return provider_factory(Provider.ProviderChoices.ORACLECLOUD.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def alibabacloud_provider(provider_factory):
|
||||
return provider_factory(Provider.ProviderChoices.ALIBABACLOUD.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cloudflare_provider(provider_factory):
|
||||
return provider_factory(Provider.ProviderChoices.CLOUDFLARE.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def openstack_provider(provider_factory):
|
||||
return provider_factory(Provider.ProviderChoices.OPENSTACK.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def image_provider(provider_factory):
|
||||
return provider_factory(Provider.ProviderChoices.IMAGE.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def googleworkspace_provider(provider_factory):
|
||||
return provider_factory(Provider.ProviderChoices.GOOGLEWORKSPACE.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vercel_provider(provider_factory):
|
||||
return provider_factory(Provider.ProviderChoices.VERCEL.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def okta_provider(provider_factory):
|
||||
return provider_factory(Provider.ProviderChoices.OKTA.value)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def all_provider_types_fixture(provider_factory):
|
||||
return tuple(
|
||||
provider_factory(provider_choice.value)
|
||||
for provider_choice in Provider.ProviderChoices
|
||||
return (
|
||||
provider1,
|
||||
provider2,
|
||||
provider3,
|
||||
provider4,
|
||||
provider5,
|
||||
provider6,
|
||||
provider7,
|
||||
provider8,
|
||||
provider9,
|
||||
provider10,
|
||||
provider11,
|
||||
provider12,
|
||||
provider13,
|
||||
provider14,
|
||||
)
|
||||
|
||||
|
||||
@@ -861,7 +797,7 @@ def roles_fixture(tenants_fixture):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider_secret_fixture(all_provider_types_fixture):
|
||||
def provider_secret_fixture(providers_fixture):
|
||||
return tuple(
|
||||
ProviderSecret.objects.create(
|
||||
tenant_id=provider.tenant_id,
|
||||
@@ -870,14 +806,14 @@ def provider_secret_fixture(all_provider_types_fixture):
|
||||
secret={"key": "value"},
|
||||
name=provider.alias,
|
||||
)
|
||||
for provider in all_provider_types_fixture
|
||||
for provider in providers_fixture
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scans_fixture(tenants_fixture, aws_provider_pair):
|
||||
def scans_fixture(tenants_fixture, providers_fixture):
|
||||
tenant, *_ = tenants_fixture
|
||||
provider, provider2 = aws_provider_pair
|
||||
provider, provider2, *_ = providers_fixture
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
@@ -940,8 +876,8 @@ def tasks_fixture(tenants_fixture):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resources_fixture(aws_provider_pair):
|
||||
provider, provider2 = aws_provider_pair
|
||||
def resources_fixture(providers_fixture):
|
||||
provider, *_ = providers_fixture
|
||||
|
||||
tags = [
|
||||
ResourceTag.objects.create(
|
||||
@@ -982,8 +918,8 @@ def resources_fixture(aws_provider_pair):
|
||||
resource2.upsert_or_delete_tags(tags)
|
||||
|
||||
resource3 = Resource.objects.create(
|
||||
tenant_id=provider2.tenant_id,
|
||||
provider=provider2,
|
||||
tenant_id=providers_fixture[1].tenant_id,
|
||||
provider=providers_fixture[1],
|
||||
uid="arn:aws:ec2:us-east-1:123456789012:bucket/i-1234567890abcdef2",
|
||||
name="My Bucket 3",
|
||||
region="us-east-1",
|
||||
@@ -1331,9 +1267,9 @@ def get_api_tokens(
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scan_summaries_fixture(tenants_fixture, aws_provider):
|
||||
def scan_summaries_fixture(tenants_fixture, providers_fixture):
|
||||
tenant = tenants_fixture[0]
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
scan = Scan.objects.create(
|
||||
name="overview scan",
|
||||
provider=provider,
|
||||
@@ -1410,8 +1346,8 @@ def scan_summaries_fixture(tenants_fixture, aws_provider):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def integrations_fixture(aws_provider_pair):
|
||||
provider1, provider2 = aws_provider_pair
|
||||
def integrations_fixture(providers_fixture):
|
||||
provider1, provider2, *_ = providers_fixture
|
||||
tenant_id = provider1.tenant_id
|
||||
integration1 = Integration.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
@@ -1472,9 +1408,9 @@ def lighthouse_config_fixture(authenticated_client, tenants_fixture):
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def latest_scan_finding(authenticated_client, aws_provider, resources_fixture):
|
||||
provider = aws_provider
|
||||
tenant_id = str(aws_provider.tenant_id)
|
||||
def latest_scan_finding(authenticated_client, providers_fixture, resources_fixture):
|
||||
provider = providers_fixture[0]
|
||||
tenant_id = str(providers_fixture[0].tenant_id)
|
||||
resource = resources_fixture[0]
|
||||
scan = Scan.objects.create(
|
||||
name="latest completed scan",
|
||||
@@ -1585,10 +1521,10 @@ def findings_with_multiple_categories(scans_fixture, resources_fixture):
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def latest_scan_finding_with_categories(
|
||||
authenticated_client, aws_provider, resources_fixture
|
||||
authenticated_client, providers_fixture, resources_fixture
|
||||
):
|
||||
provider = aws_provider
|
||||
tenant_id = str(aws_provider.tenant_id)
|
||||
provider = providers_fixture[0]
|
||||
tenant_id = str(providers_fixture[0].tenant_id)
|
||||
resource = resources_fixture[0]
|
||||
scan = Scan.objects.create(
|
||||
name="latest completed scan with categories",
|
||||
@@ -1622,9 +1558,9 @@ def latest_scan_finding_with_categories(
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def latest_scan_resource(authenticated_client, aws_provider):
|
||||
provider = aws_provider
|
||||
tenant_id = str(aws_provider.tenant_id)
|
||||
def latest_scan_resource(authenticated_client, providers_fixture):
|
||||
provider = providers_fixture[0]
|
||||
tenant_id = str(providers_fixture[0].tenant_id)
|
||||
scan = Scan.objects.create(
|
||||
name="latest completed scan for resource",
|
||||
provider=provider,
|
||||
@@ -2089,11 +2025,11 @@ def get_authorization_header(access_token: str) -> dict:
|
||||
|
||||
@pytest.fixture
|
||||
def provider_compliance_scores_fixture(
|
||||
tenants_fixture, aws_provider_pair, scans_fixture
|
||||
tenants_fixture, providers_fixture, scans_fixture
|
||||
):
|
||||
"""Create ProviderComplianceScore entries for compliance watchlist tests."""
|
||||
tenant = tenants_fixture[0]
|
||||
provider1, provider2 = aws_provider_pair
|
||||
provider1, provider2, *_ = providers_fixture
|
||||
scan1, _, scan3 = scans_fixture
|
||||
|
||||
scan1.completed_at = datetime.now(UTC) - timedelta(hours=1)
|
||||
@@ -2190,7 +2126,9 @@ def tenant_compliance_summary_fixture(tenants_fixture):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def finding_groups_fixture(tenants_fixture, scans_fixture, resources_fixture):
|
||||
def finding_groups_fixture(
|
||||
tenants_fixture, providers_fixture, scans_fixture, resources_fixture
|
||||
):
|
||||
"""
|
||||
Create a comprehensive set of findings for testing Finding Groups aggregation.
|
||||
|
||||
@@ -2209,6 +2147,7 @@ def finding_groups_fixture(tenants_fixture, scans_fixture, resources_fixture):
|
||||
- Finding counts (pass, fail, muted, new, changed)
|
||||
"""
|
||||
tenant = tenants_fixture[0]
|
||||
provider1, provider2, *_ = providers_fixture
|
||||
scan1, scan2, *_ = scans_fixture
|
||||
resource1, resource2, *_ = resources_fixture
|
||||
|
||||
@@ -2459,7 +2398,7 @@ def finding_groups_fixture(tenants_fixture, scans_fixture, resources_fixture):
|
||||
|
||||
@pytest.fixture
|
||||
def finding_groups_title_variants_fixture(
|
||||
tenants_fixture, scans_fixture, resources_fixture
|
||||
tenants_fixture, providers_fixture, scans_fixture, resources_fixture
|
||||
):
|
||||
"""
|
||||
Two providers report the same check_id with different checktitle values.
|
||||
@@ -2470,6 +2409,7 @@ def finding_groups_title_variants_fixture(
|
||||
of which title variant matches the search term.
|
||||
"""
|
||||
tenant = tenants_fixture[0]
|
||||
provider1, provider2, *_ = providers_fixture
|
||||
scan1, scan2, *_ = scans_fixture
|
||||
resource1, resource2, *_ = resources_fixture
|
||||
|
||||
@@ -2543,27 +2483,8 @@ 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)
|
||||
|
||||
if any(item.get_closest_marker("requires_test_replica_alias") for item in items):
|
||||
default_database = settings.DATABASES["default"]
|
||||
if TEST_REPLICA_ALIAS not in settings.DATABASES:
|
||||
settings.DATABASES[TEST_REPLICA_ALIAS] = {
|
||||
**default_database,
|
||||
"TEST": {
|
||||
**default_database.get("TEST", {}),
|
||||
"MIRROR": "default",
|
||||
},
|
||||
}
|
||||
django_connections.databases[TEST_REPLICA_ALIAS] = settings.DATABASES[
|
||||
TEST_REPLICA_ALIAS
|
||||
]
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"requires_test_replica_alias: creates a test-only replica alias mirrored "
|
||||
"to default",
|
||||
)
|
||||
# Apply the mock before the test session starts. This is necessary to avoid admin error when running the
|
||||
# 0004_rbac_missing_admin_roles migration
|
||||
patch("api.db_router.MainRouter.admin_db", new="default").start()
|
||||
|
||||
@@ -1,50 +1,40 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from functools import partial
|
||||
|
||||
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
|
||||
from celery import current_app, states
|
||||
from celery import states
|
||||
from celery.utils.log import get_task_logger
|
||||
from config.django.base import (
|
||||
ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES,
|
||||
ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES,
|
||||
)
|
||||
from django.db import DatabaseError
|
||||
from django.db.transaction import on_commit
|
||||
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 tasks.jobs.orphan_recovery import is_worker_alive as _is_worker_alive
|
||||
from tasks.jobs.orphan_recovery import revoke_task as _revoke_task
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
WORKER_PING_BASE_TIMEOUT_SECONDS = 5
|
||||
WORKER_PING_MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
def cleanup_stale_attack_paths_scans() -> dict:
|
||||
"""
|
||||
Mark stale `AttackPathsScan` rows as `FAILED`.
|
||||
|
||||
Covers two stuck-state scenarios:
|
||||
1. `EXECUTING` scans whose workers are unresponsive and whose rows have
|
||||
stopped receiving progress updates, or that exceeded the stale threshold.
|
||||
2. `SCHEDULED` scans that never made it to a worker - parent scan
|
||||
1. `EXECUTING` scans whose workers are dead, or that have exceeded the
|
||||
stale threshold while alive.
|
||||
2. `SCHEDULED` scans that never made it to a worker — parent scan
|
||||
crashed before dispatch, broker lost the message, etc. Detected by
|
||||
age plus the parent `Scan` no longer being in flight.
|
||||
"""
|
||||
threshold = timedelta(minutes=ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES)
|
||||
now = datetime.now(tz=UTC)
|
||||
stale_cutoff = now - timedelta(minutes=ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES)
|
||||
inactivity_cutoff = now - timedelta(
|
||||
minutes=ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES
|
||||
)
|
||||
cutoff = now - threshold
|
||||
|
||||
cleaned_up: list[str] = []
|
||||
cleaned_up.extend(_cleanup_stale_executing_scans(stale_cutoff, inactivity_cutoff))
|
||||
cleaned_up.extend(_cleanup_stale_scheduled_scans(stale_cutoff))
|
||||
cleaned_up.extend(_cleanup_stale_executing_scans(cutoff))
|
||||
cleaned_up.extend(_cleanup_stale_scheduled_scans(cutoff))
|
||||
|
||||
logger.info(
|
||||
f"Stale `AttackPathsScan` cleanup: {len(cleaned_up)} scan(s) cleaned up"
|
||||
@@ -52,57 +42,13 @@ def cleanup_stale_attack_paths_scans() -> dict:
|
||||
return {"cleaned_up_count": len(cleaned_up), "scan_ids": cleaned_up}
|
||||
|
||||
|
||||
def _ping_workers(workers: set[str]) -> tuple[set[str], set[str] | None]:
|
||||
"""Ping worker destinations in parallel and retry only missing workers.
|
||||
|
||||
The second tuple item is `None` when the final ping attempt raises. In that
|
||||
case the pending workers have unknown liveness and their scans must be kept.
|
||||
"""
|
||||
pending = set(workers)
|
||||
responsive: set[str] = set()
|
||||
|
||||
for attempt in range(WORKER_PING_MAX_ATTEMPTS):
|
||||
if not pending:
|
||||
return responsive, set()
|
||||
|
||||
timeout = WORKER_PING_BASE_TIMEOUT_SECONDS * 2**attempt
|
||||
try:
|
||||
response = current_app.control.inspect(
|
||||
destination=sorted(pending), timeout=timeout
|
||||
).ping()
|
||||
except Exception:
|
||||
attempts_remaining = WORKER_PING_MAX_ATTEMPTS - attempt - 1
|
||||
if attempts_remaining:
|
||||
logger.warning(
|
||||
f"Attack Paths worker ping attempt {attempt + 1} failed; "
|
||||
f"retrying pending workers with {attempts_remaining} "
|
||||
"attempt(s) remaining",
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
logger.exception(
|
||||
"Attack Paths worker ping attempts exhausted; preserving scans "
|
||||
"for workers with unknown liveness"
|
||||
)
|
||||
return responsive, None
|
||||
|
||||
responded = pending.intersection((response or {}).keys())
|
||||
responsive.update(responded)
|
||||
pending.difference_update(responded)
|
||||
|
||||
return responsive, pending
|
||||
|
||||
|
||||
def _cleanup_stale_executing_scans(
|
||||
stale_cutoff: datetime, inactivity_cutoff: datetime
|
||||
) -> list[str]:
|
||||
def _cleanup_stale_executing_scans(cutoff: datetime) -> list[str]:
|
||||
"""
|
||||
Two-pass detection for `EXECUTING` scans:
|
||||
1. Ping all recorded workers in parallel with bounded retries.
|
||||
- Responsive + past stale threshold: cleanup.
|
||||
- Unresponsive + past inactivity threshold: cleanup.
|
||||
- Unknown after a final ping exception: preserve.
|
||||
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.
|
||||
"""
|
||||
executing_scans = list(
|
||||
@@ -111,13 +57,14 @@ def _cleanup_stale_executing_scans(
|
||||
.select_related("task__task_runner_task")
|
||||
)
|
||||
|
||||
# Cache worker liveness so each worker is pinged at most once
|
||||
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
|
||||
}
|
||||
responsive_workers, unresponsive_workers = _ping_workers(workers)
|
||||
worker_alive = {w: _is_worker_alive(w) for w in workers}
|
||||
|
||||
cleaned_up: list[str] = []
|
||||
|
||||
@@ -128,50 +75,27 @@ def _cleanup_stale_executing_scans(
|
||||
worker = task_result.worker if task_result else None
|
||||
|
||||
if worker:
|
||||
if worker in responsive_workers:
|
||||
if scan.started_at is None or scan.started_at >= stale_cutoff:
|
||||
alive = worker_alive.get(worker, True)
|
||||
|
||||
if alive:
|
||||
if scan.started_at and scan.started_at >= cutoff:
|
||||
continue
|
||||
|
||||
reason = "Scan exceeded stale threshold - cleaned up by periodic task"
|
||||
recheck_activity_cutoff = None
|
||||
elif unresponsive_workers is None or worker not in unresponsive_workers:
|
||||
logger.info(
|
||||
f"Preserving scan {scan.id}: worker {worker} liveness is "
|
||||
f"unknown (progress={scan.progress}, updated_at={scan.updated_at})"
|
||||
)
|
||||
continue
|
||||
# Alive but stale — revoke before cleanup
|
||||
_revoke_task(task_result)
|
||||
reason = "Scan exceeded stale threshold — cleaned up by periodic task"
|
||||
else:
|
||||
if scan.updated_at >= inactivity_cutoff:
|
||||
logger.info(
|
||||
f"Preserving scan {scan.id}: worker {worker} is unresponsive "
|
||||
f"but activity is recent (progress={scan.progress}, "
|
||||
f"updated_at={scan.updated_at})"
|
||||
)
|
||||
continue
|
||||
|
||||
reason = (
|
||||
"Worker unresponsive and scan inactive for "
|
||||
f"{ATTACK_PATHS_SCAN_INACTIVITY_THRESHOLD_MINUTES} minutes - "
|
||||
"cleaned up by periodic task"
|
||||
)
|
||||
recheck_activity_cutoff = inactivity_cutoff
|
||||
reason = "Worker dead — cleaned up by periodic task"
|
||||
else:
|
||||
# No worker recorded, time-based heuristic only
|
||||
if scan.started_at is None or scan.started_at >= stale_cutoff:
|
||||
if scan.started_at and scan.started_at >= cutoff:
|
||||
continue
|
||||
reason = (
|
||||
"No worker recorded, scan exceeded stale threshold - "
|
||||
"No worker recorded, scan exceeded stale threshold — "
|
||||
"cleaned up by periodic task"
|
||||
)
|
||||
recheck_activity_cutoff = None
|
||||
|
||||
if _cleanup_scan(
|
||||
scan,
|
||||
task_result,
|
||||
reason,
|
||||
revoke=worker is not None,
|
||||
inactivity_cutoff=recheck_activity_cutoff,
|
||||
):
|
||||
if _cleanup_scan(scan, task_result, reason):
|
||||
cleaned_up.append(str(scan.id))
|
||||
|
||||
return cleaned_up
|
||||
@@ -188,9 +112,10 @@ def _cleanup_stale_scheduled_scans(cutoff: datetime) -> list[str]:
|
||||
avoids cleaning up rows whose parent Prowler scan is legitimately still
|
||||
running.
|
||||
|
||||
For each match: lock and recheck the row, mark the scan and `TaskResult` as
|
||||
failed, then revoke the queued task after the transaction commits. The temp
|
||||
Neo4j database is never created while `SCHEDULED`, so no drop is needed.
|
||||
For each match: revoke the queued task (best-effort; harmless if already
|
||||
consumed), atomically flip to `FAILED`, and mark the `TaskResult`. The
|
||||
temp Neo4j database is never created while `SCHEDULED`, so no drop is
|
||||
needed.
|
||||
"""
|
||||
scheduled_scans = list(
|
||||
AttackPathsScan.all_objects.using(MainRouter.admin_db)
|
||||
@@ -216,54 +141,42 @@ def _cleanup_stale_scheduled_scans(cutoff: datetime) -> list[str]:
|
||||
task_result = (
|
||||
getattr(scan.task, "task_runner_task", None) if scan.task else None
|
||||
)
|
||||
reason = "Scan never started - cleaned up by periodic task"
|
||||
if task_result:
|
||||
_revoke_task(task_result, terminate=False)
|
||||
|
||||
reason = "Scan never started — cleaned up by periodic task"
|
||||
if _cleanup_scheduled_scan(scan, task_result, reason):
|
||||
cleaned_up.append(str(scan.id))
|
||||
|
||||
return cleaned_up
|
||||
|
||||
|
||||
def _cleanup_scan(
|
||||
scan,
|
||||
task_result,
|
||||
reason: str,
|
||||
*,
|
||||
revoke: bool = False,
|
||||
inactivity_cutoff: datetime | None = None,
|
||||
) -> bool:
|
||||
def _cleanup_scan(scan, task_result, reason: str) -> bool:
|
||||
"""
|
||||
Clean up a single stale `AttackPathsScan`:
|
||||
lock and recheck, mark `FAILED`, revoke after commit, drop the temp DB, and
|
||||
recover graph readiness.
|
||||
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)
|
||||
|
||||
try:
|
||||
fresh_scan = _finalize_failed_scan(
|
||||
scan,
|
||||
StateChoices.EXECUTING,
|
||||
reason,
|
||||
task_result=task_result,
|
||||
revoke=revoke,
|
||||
inactivity_cutoff=inactivity_cutoff,
|
||||
)
|
||||
except DatabaseError:
|
||||
logger.exception(
|
||||
f"Failed to mark stale Attack Paths scan {scan_id_str} as failed"
|
||||
)
|
||||
return False
|
||||
|
||||
if fresh_scan is None:
|
||||
return False
|
||||
|
||||
# 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}")
|
||||
|
||||
fresh_scan = _finalize_failed_scan(scan, StateChoices.EXECUTING, reason)
|
||||
if fresh_scan is None:
|
||||
return False
|
||||
|
||||
# Mark `TaskResult` as `FAILURE` (not RLS-protected, outside lock)
|
||||
if task_result:
|
||||
task_result.status = states.FAILURE
|
||||
task_result.date_done = datetime.now(tz=UTC)
|
||||
task_result.save(update_fields=["status", "date_done"])
|
||||
|
||||
recover_graph_data_ready(fresh_scan)
|
||||
|
||||
logger.info(f"Cleaned up stale scan {scan_id_str}: {reason}")
|
||||
@@ -274,49 +187,31 @@ def _cleanup_scheduled_scan(scan, task_result, reason: str) -> bool:
|
||||
"""
|
||||
Clean up a `SCHEDULED` scan that never reached a worker.
|
||||
|
||||
Skips the temp Neo4j drop - the database is only created once the worker
|
||||
Skips the temp Neo4j drop — the database is only created once the worker
|
||||
enters `EXECUTING`, so dropping it here just produces noisy log output.
|
||||
|
||||
Returns `True` if the scan was actually cleaned up, `False` if skipped.
|
||||
"""
|
||||
scan_id_str = str(scan.id)
|
||||
|
||||
try:
|
||||
fresh_scan = _finalize_failed_scan(
|
||||
scan,
|
||||
StateChoices.SCHEDULED,
|
||||
reason,
|
||||
task_result=task_result,
|
||||
revoke=task_result is not None,
|
||||
terminate=False,
|
||||
)
|
||||
except DatabaseError:
|
||||
logger.exception(
|
||||
f"Failed to mark scheduled Attack Paths scan {scan_id_str} as failed"
|
||||
)
|
||||
return False
|
||||
|
||||
fresh_scan = _finalize_failed_scan(scan, StateChoices.SCHEDULED, reason)
|
||||
if fresh_scan is None:
|
||||
return False
|
||||
|
||||
if task_result:
|
||||
task_result.status = states.FAILURE
|
||||
task_result.date_done = datetime.now(tz=UTC)
|
||||
task_result.save(update_fields=["status", "date_done"])
|
||||
|
||||
logger.info(f"Cleaned up scheduled scan {scan_id_str}: {reason}")
|
||||
return True
|
||||
|
||||
|
||||
def _finalize_failed_scan(
|
||||
scan,
|
||||
expected_state: str,
|
||||
reason: str,
|
||||
*,
|
||||
task_result=None,
|
||||
revoke: bool = False,
|
||||
terminate: bool = True,
|
||||
inactivity_cutoff: datetime | None = None,
|
||||
):
|
||||
def _finalize_failed_scan(scan, expected_state: str, reason: str):
|
||||
"""
|
||||
Atomically lock the row, verify it's still eligible, and mark it `FAILED`.
|
||||
If requested, register revocation after commit. Returns the locked row on
|
||||
success, `None` if the row is gone or has already moved on.
|
||||
Atomically lock the row, verify it's still in `expected_state`, and
|
||||
mark it `FAILED`. Returns the locked row on success, `None` if the
|
||||
row is gone or has already moved on.
|
||||
"""
|
||||
scan_id_str = str(scan.id)
|
||||
with rls_transaction(str(scan.tenant_id)):
|
||||
@@ -330,23 +225,6 @@ def _finalize_failed_scan(
|
||||
logger.info(f"Scan {scan_id_str} is now {fresh_scan.state}, skipping")
|
||||
return None
|
||||
|
||||
if inactivity_cutoff is not None and fresh_scan.updated_at >= inactivity_cutoff:
|
||||
logger.info(
|
||||
f"Scan {scan_id_str} received activity during worker checks, skipping"
|
||||
)
|
||||
return None
|
||||
|
||||
mark_scan_finished(fresh_scan, StateChoices.FAILED, {"global_error": reason})
|
||||
|
||||
if task_result:
|
||||
task_result.status = states.FAILURE
|
||||
task_result.date_done = datetime.now(tz=UTC)
|
||||
task_result.save(update_fields=["status", "date_done"])
|
||||
|
||||
if revoke and task_result:
|
||||
on_commit(
|
||||
partial(_revoke_task, task_result, terminate=terminate),
|
||||
using=fresh_scan._state.db,
|
||||
)
|
||||
|
||||
return fresh_scan
|
||||
|
||||
@@ -126,17 +126,14 @@ def starting_attack_paths_scan(
|
||||
if locked.state != StateChoices.SCHEDULED:
|
||||
return False
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
locked.state = StateChoices.EXECUTING
|
||||
locked.started_at = now
|
||||
locked.updated_at = now
|
||||
locked.started_at = datetime.now(tz=UTC)
|
||||
locked.update_tag = cartography_config.update_tag
|
||||
locked.save(update_fields=["state", "started_at", "updated_at", "update_tag"])
|
||||
locked.save(update_fields=["state", "started_at", "update_tag"])
|
||||
|
||||
# Keep the in-memory object the caller is holding in sync.
|
||||
attack_paths_scan.state = locked.state
|
||||
attack_paths_scan.started_at = locked.started_at
|
||||
attack_paths_scan.updated_at = locked.updated_at
|
||||
attack_paths_scan.update_tag = locked.update_tag
|
||||
return True
|
||||
|
||||
@@ -184,8 +181,7 @@ def update_attack_paths_scan_progress(
|
||||
) -> None:
|
||||
with rls_transaction(attack_paths_scan.tenant_id):
|
||||
attack_paths_scan.progress = progress
|
||||
attack_paths_scan.updated_at = datetime.now(tz=UTC)
|
||||
attack_paths_scan.save(update_fields=["progress", "updated_at"])
|
||||
attack_paths_scan.save(update_fields=["progress"])
|
||||
|
||||
|
||||
def set_graph_data_ready(
|
||||
|
||||
@@ -172,9 +172,11 @@ def reconcile_orphans(
|
||||
window_hours: int = 6,
|
||||
dry_run: bool = False,
|
||||
) -> dict:
|
||||
"""Run the orphan task sweep under a single-flight advisory lock.
|
||||
"""Run the full orphan sweep under a single-flight advisory lock.
|
||||
|
||||
Returns a recovery summary. A no-op is reported when the lock is not acquired.
|
||||
Recovers any orphaned in-flight task and delegates attack-paths scans that
|
||||
never reached a worker to their existing stale-cleanup. Returns a summary;
|
||||
a no-op (lock not won) is reported too.
|
||||
"""
|
||||
with advisory_lock() as acquired:
|
||||
if not acquired:
|
||||
@@ -198,6 +200,11 @@ def reconcile_orphans(
|
||||
logger.info("Orphan task recovery disabled by feature flag")
|
||||
result = {"recovered": [], "failed": [], "skipped": [], "enabled": False}
|
||||
|
||||
if not dry_run:
|
||||
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
|
||||
|
||||
result["attack_paths"] = cleanup_stale_attack_paths_scans()
|
||||
|
||||
return {"acquired": True, **result}
|
||||
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import io
|
||||
import struct
|
||||
import zlib
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
|
||||
def _png_chunk(chunk_type: bytes, data: bytes) -> bytes:
|
||||
checksum = zlib.crc32(chunk_type + data) & 0xFFFFFFFF
|
||||
return (
|
||||
struct.pack(">I", len(data)) + chunk_type + data + struct.pack(">I", checksum)
|
||||
)
|
||||
|
||||
|
||||
def _build_tiny_png() -> bytes:
|
||||
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
|
||||
# Filter byte 0 plus one white RGB pixel.
|
||||
idat = zlib.compress(b"\x00\xff\xff\xff")
|
||||
return (
|
||||
PNG_SIGNATURE
|
||||
+ _png_chunk(b"IHDR", ihdr)
|
||||
+ _png_chunk(b"IDAT", idat)
|
||||
+ _png_chunk(b"IEND", b"")
|
||||
)
|
||||
|
||||
|
||||
_TINY_PNG_BYTES = _build_tiny_png()
|
||||
|
||||
|
||||
def fake_png_buffer() -> io.BytesIO:
|
||||
return io.BytesIO(_TINY_PNG_BYTES)
|
||||
|
||||
|
||||
def patch_chart_helpers(
|
||||
monkeypatch: Any, module: ModuleType, names: tuple[str, ...]
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
calls: dict[str, list[dict[str, Any]]] = {name: [] for name in names}
|
||||
|
||||
def _build_fake_chart(name: str):
|
||||
def _fake_chart(*args: Any, **kwargs: Any) -> io.BytesIO:
|
||||
calls[name].append({"args": args, "kwargs": kwargs})
|
||||
return fake_png_buffer()
|
||||
|
||||
return _fake_chart
|
||||
|
||||
for name in names:
|
||||
monkeypatch.setattr(module, name, _build_fake_chart(name))
|
||||
|
||||
return calls
|
||||
|
||||
|
||||
def patch_report_gc(monkeypatch: Any) -> None:
|
||||
from tasks.jobs import report as report_module
|
||||
from tasks.jobs.reports import base as base_report_module
|
||||
from tasks.jobs.reports import threatscore as threatscore_report_module
|
||||
|
||||
gc_stub = SimpleNamespace(collect=lambda: 0)
|
||||
monkeypatch.setattr(report_module, "gc", gc_stub)
|
||||
monkeypatch.setattr(base_report_module, "gc", gc_stub)
|
||||
monkeypatch.setattr(threatscore_report_module, "gc", gc_stub)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,9 +39,9 @@ def resource_scan_summary_data(scans_fixture):
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def get_not_completed_scans(aws_provider):
|
||||
provider_id = aws_provider.id
|
||||
tenant_id = aws_provider.tenant_id
|
||||
def get_not_completed_scans(providers_fixture):
|
||||
provider_id = providers_fixture[0].id
|
||||
tenant_id = providers_fixture[0].tenant_id
|
||||
scan_1 = Scan.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
|
||||
@@ -10,8 +10,8 @@ from tasks.beat import schedule_provider_scan
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestScheduleProviderScan:
|
||||
def test_schedule_provider_scan_success(self, aws_provider):
|
||||
provider_instance = aws_provider
|
||||
def test_schedule_provider_scan_success(self, providers_fixture):
|
||||
provider_instance, *_ = providers_fixture
|
||||
|
||||
with patch(
|
||||
"tasks.tasks.perform_scheduled_scan_task.apply_async"
|
||||
@@ -41,8 +41,8 @@ class TestScheduleProviderScan:
|
||||
"provider_id": str(provider_instance.id),
|
||||
}
|
||||
|
||||
def test_schedule_provider_scan_already_exists(self, aws_provider):
|
||||
provider_instance = aws_provider
|
||||
def test_schedule_provider_scan_already_exists(self, providers_fixture):
|
||||
provider_instance, *_ = providers_fixture
|
||||
|
||||
# First, schedule the scan
|
||||
with patch("tasks.tasks.perform_scheduled_scan_task.apply_async"):
|
||||
@@ -56,8 +56,8 @@ class TestScheduleProviderScan:
|
||||
exc_info.value
|
||||
)
|
||||
|
||||
def test_remove_periodic_task(self, aws_provider):
|
||||
provider_instance = aws_provider
|
||||
def test_remove_periodic_task(self, providers_fixture):
|
||||
provider_instance = providers_fixture[0]
|
||||
|
||||
assert Scan.objects.count() == 0
|
||||
with patch("tasks.tasks.perform_scheduled_scan_task.apply_async"):
|
||||
|
||||
@@ -9,7 +9,7 @@ from tasks.jobs.deletion import delete_provider, delete_tenant
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestDeleteProvider:
|
||||
def test_delete_provider_success(self, aws_provider):
|
||||
def test_delete_provider_success(self, providers_fixture):
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.get_database_name",
|
||||
@@ -19,7 +19,7 @@ class TestDeleteProvider:
|
||||
"tasks.jobs.deletion.graph_database.drop_subgraph"
|
||||
) as mock_drop_subgraph,
|
||||
):
|
||||
instance = aws_provider
|
||||
instance = providers_fixture[0]
|
||||
tenant_id = str(instance.tenant_id)
|
||||
result = delete_provider(tenant_id, instance.id)
|
||||
|
||||
@@ -53,9 +53,9 @@ class TestDeleteProvider:
|
||||
mock_drop_subgraph.assert_not_called()
|
||||
|
||||
def test_delete_provider_drops_temp_attack_paths_databases(
|
||||
self, aws_provider, create_attack_paths_scan
|
||||
self, providers_fixture, create_attack_paths_scan
|
||||
):
|
||||
instance = aws_provider
|
||||
instance = providers_fixture[0]
|
||||
tenant_id = str(instance.tenant_id)
|
||||
|
||||
aps1 = create_attack_paths_scan(instance)
|
||||
@@ -84,9 +84,9 @@ class TestDeleteProvider:
|
||||
mock_drop_database.assert_has_calls(expected_tmp_calls, any_order=True)
|
||||
|
||||
def test_delete_provider_drops_graph_data_from_all_recorded_sinks(
|
||||
self, aws_provider, create_attack_paths_scan
|
||||
self, providers_fixture, create_attack_paths_scan
|
||||
):
|
||||
instance = aws_provider
|
||||
instance = providers_fixture[0]
|
||||
tenant_id = str(instance.tenant_id)
|
||||
create_attack_paths_scan(instance, sink_backend="neo4j")
|
||||
create_attack_paths_scan(instance, sink_backend="neptune")
|
||||
@@ -124,9 +124,9 @@ class TestDeleteProvider:
|
||||
)
|
||||
|
||||
def test_delete_provider_continues_when_temp_db_drop_fails(
|
||||
self, aws_provider, create_attack_paths_scan
|
||||
self, providers_fixture, create_attack_paths_scan
|
||||
):
|
||||
instance = aws_provider
|
||||
instance = providers_fixture[0]
|
||||
tenant_id = str(instance.tenant_id)
|
||||
|
||||
create_attack_paths_scan(instance)
|
||||
@@ -151,10 +151,10 @@ class TestDeleteProvider:
|
||||
|
||||
def test_delete_provider_recalculates_tenant_compliance_summary(
|
||||
self,
|
||||
aws_provider_pair,
|
||||
providers_fixture,
|
||||
provider_compliance_scores_fixture,
|
||||
):
|
||||
instance = aws_provider_pair[0]
|
||||
instance = providers_fixture[0]
|
||||
tenant_id = instance.tenant_id
|
||||
|
||||
TenantComplianceSummary.objects.create(
|
||||
@@ -199,7 +199,7 @@ class TestDeleteProvider:
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestDeleteTenant:
|
||||
def test_delete_tenant_success(self, tenants_fixture, aws_provider):
|
||||
def test_delete_tenant_success(self, tenants_fixture, providers_fixture):
|
||||
"""
|
||||
Test successful deletion of a tenant and its related data.
|
||||
"""
|
||||
|
||||
@@ -7,7 +7,6 @@ from celery import states
|
||||
from django.test import override_settings
|
||||
from django_celery_results.models import TaskResult
|
||||
from tasks.jobs.orphan_recovery import (
|
||||
_SKIP_RECOVERY,
|
||||
_decode_celery_field,
|
||||
_reconcile_task_results,
|
||||
_recovery_attempt_count,
|
||||
@@ -15,7 +14,6 @@ from tasks.jobs.orphan_recovery import (
|
||||
is_worker_alive,
|
||||
reconcile_orphans,
|
||||
reenqueueable_tasks,
|
||||
revoke_task,
|
||||
)
|
||||
|
||||
|
||||
@@ -182,18 +180,10 @@ class TestReconcileTaskResults:
|
||||
assert tr.task_id in result["failed"]
|
||||
mock_count.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"task_name",
|
||||
[
|
||||
"scan-perform",
|
||||
"attack-paths-scan-perform",
|
||||
"attack-paths-cleanup-stale-scans",
|
||||
],
|
||||
)
|
||||
def test_scan_task_is_skipped_entirely(self, tenants_fixture, task_name):
|
||||
def test_scan_task_is_skipped_entirely(self, tenants_fixture):
|
||||
"""Scan tasks are excluded from recovery: the watchdog never touches them."""
|
||||
tr = _orphan_result(
|
||||
name=task_name,
|
||||
name="scan-perform",
|
||||
kwargs={
|
||||
"tenant_id": str(tenants_fixture[0].id),
|
||||
"scan_id": str(uuid4()),
|
||||
@@ -349,15 +339,6 @@ class TestOrphanRecoveryHelpers:
|
||||
):
|
||||
assert is_worker_alive("w@h") is False
|
||||
|
||||
def test_revoke_task_terminates_with_sigterm_by_default(self):
|
||||
task_result = MagicMock(task_id="task-id")
|
||||
with patch(
|
||||
"tasks.jobs.orphan_recovery.current_app.control.revoke"
|
||||
) as mock_revoke:
|
||||
revoke_task(task_result)
|
||||
|
||||
mock_revoke.assert_called_once_with("task-id", terminate=True, signal="SIGTERM")
|
||||
|
||||
def test_recovery_attempt_count_increments(self):
|
||||
# Unique signature so the Valkey counter starts fresh for this test.
|
||||
kwargs_repr = repr({"probe": str(uuid4())})
|
||||
@@ -369,12 +350,6 @@ class TestOrphanRecoveryHelpers:
|
||||
|
||||
|
||||
class TestRecoveryFeatureFlags:
|
||||
def test_attack_paths_tasks_are_excluded_from_generic_recovery(self):
|
||||
assert {
|
||||
"attack-paths-scan-perform",
|
||||
"attack-paths-cleanup-stale-scans",
|
||||
} <= _SKIP_RECOVERY
|
||||
|
||||
def test_all_groups_enabled_by_default(self):
|
||||
tasks = reenqueueable_tasks()
|
||||
assert "scan-summary" in tasks
|
||||
@@ -399,23 +374,33 @@ class TestRecoveryFeatureFlags:
|
||||
class TestRecoveryMasterFlag:
|
||||
@override_settings(TASK_RECOVERY_ENABLED=False)
|
||||
def test_master_flag_disables_task_recovery(self):
|
||||
with patch(
|
||||
"tasks.jobs.orphan_recovery._reconcile_task_results"
|
||||
) as mock_reconcile:
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.orphan_recovery._reconcile_task_results"
|
||||
) as mock_reconcile,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans",
|
||||
return_value={},
|
||||
),
|
||||
):
|
||||
result = reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False)
|
||||
|
||||
mock_reconcile.assert_not_called()
|
||||
assert result["acquired"] is True
|
||||
assert result["enabled"] is False
|
||||
assert "attack_paths" not in result
|
||||
|
||||
@override_settings(TASK_RECOVERY_ENABLED=True)
|
||||
def test_master_flag_enabled_runs_task_recovery(self):
|
||||
with patch(
|
||||
"tasks.jobs.orphan_recovery._reconcile_task_results",
|
||||
return_value={"recovered": [], "failed": [], "skipped": []},
|
||||
) as mock_reconcile:
|
||||
result = reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False)
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.orphan_recovery._reconcile_task_results",
|
||||
return_value={"recovered": [], "failed": [], "skipped": []},
|
||||
) as mock_reconcile,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans",
|
||||
return_value={},
|
||||
),
|
||||
):
|
||||
reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False)
|
||||
|
||||
mock_reconcile.assert_called_once()
|
||||
assert "attack_paths" not in result
|
||||
|
||||
@@ -45,48 +45,14 @@ from tasks.jobs.reports import (
|
||||
get_color_for_risk_level,
|
||||
get_color_for_weight,
|
||||
)
|
||||
from tasks.jobs.reports import cis as cis_report_module
|
||||
from tasks.jobs.reports import csa as csa_report_module
|
||||
from tasks.jobs.reports import ens as ens_report_module
|
||||
from tasks.jobs.reports import nis2 as nis2_report_module
|
||||
from tasks.jobs.reports import threatscore as threatscore_report_module
|
||||
from tasks.jobs.threatscore_utils import (
|
||||
_aggregate_requirement_statistics_from_database,
|
||||
_load_findings_for_requirement_checks,
|
||||
)
|
||||
from tasks.tests.report_test_helpers import patch_chart_helpers, patch_report_gc
|
||||
|
||||
matplotlib.use("Agg") # Use non-interactive backend for tests
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patch_report_rendering(monkeypatch):
|
||||
patch_report_gc(monkeypatch)
|
||||
patch_chart_helpers(
|
||||
monkeypatch,
|
||||
cis_report_module,
|
||||
(
|
||||
"create_pie_chart",
|
||||
"create_horizontal_bar_chart",
|
||||
"create_stacked_bar_chart",
|
||||
),
|
||||
)
|
||||
patch_chart_helpers(
|
||||
monkeypatch, csa_report_module, ("create_horizontal_bar_chart",)
|
||||
)
|
||||
patch_chart_helpers(
|
||||
monkeypatch,
|
||||
ens_report_module,
|
||||
("create_horizontal_bar_chart", "create_radar_chart"),
|
||||
)
|
||||
patch_chart_helpers(
|
||||
monkeypatch, nis2_report_module, ("create_horizontal_bar_chart",)
|
||||
)
|
||||
patch_chart_helpers(
|
||||
monkeypatch, threatscore_report_module, ("create_vertical_bar_chart",)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAggregateRequirementStatistics:
|
||||
"""Test suite for _aggregate_requirement_statistics_from_database function."""
|
||||
@@ -389,7 +355,7 @@ class TestPDFStylesCreation:
|
||||
class TestLoadFindingsForChecks:
|
||||
"""Test suite for _load_findings_for_requirement_checks function."""
|
||||
|
||||
def test_empty_check_ids_returns_empty(self, tenants_fixture):
|
||||
def test_empty_check_ids_returns_empty(self, tenants_fixture, providers_fixture):
|
||||
"""Test that empty check_ids list returns empty dict."""
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
@@ -1075,7 +1041,6 @@ class TestStaleCleanupProtectionHelpers:
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("patch_report_rendering")
|
||||
class TestGenerateThreatscoreReportFunction:
|
||||
"""Test suite for generate_threatscore_report function."""
|
||||
|
||||
@@ -1085,12 +1050,12 @@ class TestGenerateThreatscoreReportFunction:
|
||||
mock_build_provider_metadata,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
aws_provider,
|
||||
providers_fixture,
|
||||
):
|
||||
"""Test that exceptions during report generation are properly handled."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
|
||||
mock_build_provider_metadata.side_effect = Exception("Test exception")
|
||||
|
||||
@@ -1107,7 +1072,6 @@ class TestGenerateThreatscoreReportFunction:
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("patch_report_rendering")
|
||||
class TestGenerateComplianceReportsOptimized:
|
||||
"""Test suite for generate_compliance_reports function."""
|
||||
|
||||
@@ -1123,12 +1087,12 @@ class TestGenerateComplianceReportsOptimized:
|
||||
mock_upload,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
aws_provider,
|
||||
providers_fixture,
|
||||
):
|
||||
"""Test that function returns early when scan has no findings."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
|
||||
result = generate_compliance_reports(
|
||||
tenant_id=str(tenant.id),
|
||||
@@ -1180,14 +1144,14 @@ class TestGenerateComplianceReportsOptimized:
|
||||
mock_upload,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
aws_provider,
|
||||
providers_fixture,
|
||||
):
|
||||
"""Scan with no findings and ``generate_cis=True`` must yield a flat
|
||||
``{"upload": False, "path": ""}`` entry, consistent with the other
|
||||
frameworks (no nested dict, no sentinel keys)."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
|
||||
result = generate_compliance_reports(
|
||||
tenant_id=str(tenant.id),
|
||||
@@ -1475,7 +1439,6 @@ class TestGenerateComplianceReportsOptimized:
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.usefixtures("patch_report_rendering")
|
||||
class TestGenerateComplianceReportsCIS:
|
||||
"""Test suite covering the CIS branch of generate_compliance_reports."""
|
||||
|
||||
@@ -1505,7 +1468,7 @@ class TestGenerateComplianceReportsCIS:
|
||||
monkeypatch,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
aws_provider,
|
||||
providers_fixture,
|
||||
):
|
||||
"""CIS branch should generate a single PDF for the highest version.
|
||||
|
||||
@@ -1515,7 +1478,7 @@ class TestGenerateComplianceReportsCIS:
|
||||
"""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
|
||||
self._force_scan_has_findings(monkeypatch)
|
||||
|
||||
@@ -1564,12 +1527,12 @@ class TestGenerateComplianceReportsCIS:
|
||||
monkeypatch,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
aws_provider,
|
||||
providers_fixture,
|
||||
):
|
||||
"""A failure in the latest CIS variant must be surfaced in the flat results entry."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
|
||||
self._force_scan_has_findings(monkeypatch)
|
||||
|
||||
@@ -1611,14 +1574,14 @@ class TestGenerateComplianceReportsCIS:
|
||||
monkeypatch,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
aws_provider,
|
||||
providers_fixture,
|
||||
):
|
||||
"""When ``Compliance.get_bulk`` returns no CIS entry the CIS branch
|
||||
must skip cleanly and record a flat ``{"upload": False, "path": ""}``
|
||||
entry — no hard-coded provider whitelist is consulted."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
|
||||
self._force_scan_has_findings(monkeypatch)
|
||||
mock_stats.return_value = {}
|
||||
@@ -1650,12 +1613,12 @@ class TestGenerateComplianceReportsCIS:
|
||||
monkeypatch,
|
||||
tenants_fixture,
|
||||
scans_fixture,
|
||||
aws_provider,
|
||||
providers_fixture,
|
||||
):
|
||||
"""CIS output dir errors must be captured in results (not raised)."""
|
||||
tenant = tenants_fixture[0]
|
||||
scan = scans_fixture[0]
|
||||
provider = aws_provider
|
||||
provider = providers_fixture[0]
|
||||
|
||||
self._force_scan_has_findings(monkeypatch)
|
||||
mock_stats.return_value = {}
|
||||
|
||||
@@ -43,7 +43,6 @@ from tasks.jobs.reports import ( # Configuration; Colors; Components; Charts; B
|
||||
get_framework_config,
|
||||
get_status_color,
|
||||
)
|
||||
from tasks.tests.report_test_helpers import PNG_SIGNATURE, fake_png_buffer
|
||||
|
||||
# =============================================================================
|
||||
# Configuration Tests
|
||||
@@ -453,47 +452,174 @@ class TestSectionHeader:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestChartRenderingSmoke:
|
||||
"""Small real-render coverage for the chart helpers."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("chart_helper", "kwargs"),
|
||||
[
|
||||
(
|
||||
create_vertical_bar_chart,
|
||||
{"labels": ["Section 1", "Section 2"], "values": [90, 70]},
|
||||
),
|
||||
(
|
||||
create_horizontal_bar_chart,
|
||||
{"labels": ["Category 1", "Category 2"], "values": [85, 65]},
|
||||
),
|
||||
(
|
||||
create_radar_chart,
|
||||
{"labels": ["A", "B", "C"], "values": [50, 60, 70]},
|
||||
),
|
||||
(
|
||||
create_pie_chart,
|
||||
{"labels": ["Pass", "Fail"], "values": [80, 20]},
|
||||
),
|
||||
(
|
||||
create_stacked_bar_chart,
|
||||
{
|
||||
"labels": ["Section 1", "Section 2"],
|
||||
"data_series": {"Pass": [8, 6], "Fail": [2, 4]},
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_chart_helper_renders_valid_png(self, chart_helper, kwargs):
|
||||
buffer = chart_helper(**kwargs)
|
||||
image_bytes = buffer.getvalue()
|
||||
class TestChartCreation:
|
||||
"""Tests for chart creation functions."""
|
||||
|
||||
def test_create_vertical_bar_chart(self):
|
||||
"""Test vertical bar chart creation."""
|
||||
buffer = create_vertical_bar_chart(
|
||||
labels=["A", "B", "C"],
|
||||
values=[80, 60, 40],
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
assert image_bytes
|
||||
assert image_bytes.startswith(PNG_SIGNATURE)
|
||||
assert buffer.getvalue() # Not empty
|
||||
|
||||
buffer.seek(0)
|
||||
assert Image(buffer, width=1 * inch, height=1 * inch)
|
||||
def test_create_vertical_bar_chart_with_options(self):
|
||||
"""Test vertical bar chart with custom options."""
|
||||
buffer = create_vertical_bar_chart(
|
||||
labels=["Section 1", "Section 2"],
|
||||
values=[90, 70],
|
||||
ylabel="Compliance",
|
||||
title="Test Chart",
|
||||
figsize=(8, 6),
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_create_horizontal_bar_chart(self):
|
||||
"""Test horizontal bar chart creation."""
|
||||
buffer = create_horizontal_bar_chart(
|
||||
labels=["Category 1", "Category 2", "Category 3"],
|
||||
values=[85, 65, 45],
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
assert buffer.getvalue()
|
||||
|
||||
def test_create_horizontal_bar_chart_with_options(self):
|
||||
"""Test horizontal bar chart with custom options."""
|
||||
buffer = create_horizontal_bar_chart(
|
||||
labels=["A", "B"],
|
||||
values=[100, 50],
|
||||
xlabel="Percentage",
|
||||
title="Custom Chart",
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_create_radar_chart(self):
|
||||
"""Test radar chart creation."""
|
||||
buffer = create_radar_chart(
|
||||
labels=["Dim 1", "Dim 2", "Dim 3", "Dim 4", "Dim 5"],
|
||||
values=[80, 70, 60, 90, 75],
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
assert buffer.getvalue()
|
||||
|
||||
def test_create_radar_chart_with_options(self):
|
||||
"""Test radar chart with custom options."""
|
||||
buffer = create_radar_chart(
|
||||
labels=["A", "B", "C"],
|
||||
values=[50, 60, 70],
|
||||
color="#FF0000",
|
||||
fill_alpha=0.5,
|
||||
title="Custom Radar",
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_create_pie_chart(self):
|
||||
"""Test pie chart creation."""
|
||||
buffer = create_pie_chart(
|
||||
labels=["Pass", "Fail"],
|
||||
values=[80, 20],
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
assert buffer.getvalue()
|
||||
|
||||
def test_create_pie_chart_with_options(self):
|
||||
"""Test pie chart with custom options."""
|
||||
buffer = create_pie_chart(
|
||||
labels=["Pass", "Fail", "Manual"],
|
||||
values=[60, 30, 10],
|
||||
colors=["#4CAF50", "#F44336", "#9E9E9E"],
|
||||
title="Status Distribution",
|
||||
autopct="%1.0f%%",
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_create_stacked_bar_chart(self):
|
||||
"""Test stacked bar chart creation."""
|
||||
buffer = create_stacked_bar_chart(
|
||||
labels=["Section 1", "Section 2", "Section 3"],
|
||||
data_series={
|
||||
"Pass": [8, 6, 4],
|
||||
"Fail": [2, 4, 6],
|
||||
},
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
assert buffer.getvalue()
|
||||
|
||||
def test_create_stacked_bar_chart_with_options(self):
|
||||
"""Test stacked bar chart with custom options."""
|
||||
buffer = create_stacked_bar_chart(
|
||||
labels=["A", "B"],
|
||||
data_series={
|
||||
"Pass": [10, 5],
|
||||
"Fail": [2, 3],
|
||||
"Manual": [1, 2],
|
||||
},
|
||||
colors={
|
||||
"Pass": "#4CAF50",
|
||||
"Fail": "#F44336",
|
||||
"Manual": "#9E9E9E",
|
||||
},
|
||||
xlabel="Categories",
|
||||
ylabel="Requirements",
|
||||
title="Requirements by Status",
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_create_stacked_bar_chart_without_legend(self):
|
||||
"""Test stacked bar chart without legend."""
|
||||
buffer = create_stacked_bar_chart(
|
||||
labels=["X", "Y"],
|
||||
data_series={"A": [1, 2]},
|
||||
show_legend=False,
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_create_vertical_bar_chart_without_labels(self):
|
||||
"""Test vertical bar chart without value labels."""
|
||||
buffer = create_vertical_bar_chart(
|
||||
labels=["A", "B"],
|
||||
values=[50, 75],
|
||||
show_labels=False,
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_create_vertical_bar_chart_with_explicit_colors(self):
|
||||
"""Test vertical bar chart with explicit color list."""
|
||||
buffer = create_vertical_bar_chart(
|
||||
labels=["Pass", "Fail"],
|
||||
values=[80, 20],
|
||||
colors=["#4CAF50", "#F44336"],
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_create_horizontal_bar_chart_auto_figsize(self):
|
||||
"""Test horizontal bar chart auto-calculates figure size for many items."""
|
||||
labels = [f"Item {i}" for i in range(20)]
|
||||
values = [50 + i * 2 for i in range(20)]
|
||||
buffer = create_horizontal_bar_chart(
|
||||
labels=labels,
|
||||
values=values,
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_create_horizontal_bar_chart_with_explicit_colors(self):
|
||||
"""Test horizontal bar chart with explicit colors."""
|
||||
buffer = create_horizontal_bar_chart(
|
||||
labels=["A", "B", "C"],
|
||||
values=[80, 60, 40],
|
||||
colors=["#4CAF50", "#FFEB3B", "#F44336"],
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_create_radar_chart_with_custom_ticks(self):
|
||||
"""Test radar chart with custom y-axis ticks."""
|
||||
buffer = create_radar_chart(
|
||||
labels=["A", "B", "C", "D"],
|
||||
values=[25, 50, 75, 100],
|
||||
y_ticks=[0, 25, 50, 75, 100],
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -930,7 +1056,10 @@ class TestExampleReportGenerator:
|
||||
]
|
||||
|
||||
def create_charts_section(self, data):
|
||||
chart_buffer = fake_png_buffer()
|
||||
chart_buffer = create_vertical_bar_chart(
|
||||
labels=["Pass", "Fail"],
|
||||
values=[80, 20],
|
||||
)
|
||||
return [Image(chart_buffer, width=6 * inch, height=4 * inch)]
|
||||
|
||||
def create_requirements_index(self, data):
|
||||
@@ -1021,6 +1150,63 @@ class TestExampleReportGenerator:
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestChartEdgeCases:
|
||||
"""Tests for chart edge cases."""
|
||||
|
||||
def test_vertical_bar_chart_empty_data(self):
|
||||
"""Test vertical bar chart with empty data."""
|
||||
buffer = create_vertical_bar_chart(labels=[], values=[])
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_vertical_bar_chart_single_item(self):
|
||||
"""Test vertical bar chart with single item."""
|
||||
buffer = create_vertical_bar_chart(labels=["Single"], values=[75.0])
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_horizontal_bar_chart_empty_data(self):
|
||||
"""Test horizontal bar chart with empty data."""
|
||||
buffer = create_horizontal_bar_chart(labels=[], values=[])
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_horizontal_bar_chart_single_item(self):
|
||||
"""Test horizontal bar chart with single item."""
|
||||
buffer = create_horizontal_bar_chart(labels=["Single"], values=[50.0])
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_radar_chart_minimum_points(self):
|
||||
"""Test radar chart with minimum number of points (3)."""
|
||||
buffer = create_radar_chart(
|
||||
labels=["A", "B", "C"],
|
||||
values=[30.0, 60.0, 90.0],
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_pie_chart_single_slice(self):
|
||||
"""Test pie chart with single slice."""
|
||||
buffer = create_pie_chart(labels=["Only"], values=[100.0])
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_pie_chart_many_slices(self):
|
||||
"""Test pie chart with many slices."""
|
||||
labels = [f"Item {i}" for i in range(10)]
|
||||
values = [10.0] * 10
|
||||
buffer = create_pie_chart(labels=labels, values=values)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_stacked_bar_chart_single_series(self):
|
||||
"""Test stacked bar chart with single series."""
|
||||
buffer = create_stacked_bar_chart(
|
||||
labels=["A", "B"],
|
||||
data_series={"Only": [10.0, 20.0]},
|
||||
)
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
def test_stacked_bar_chart_empty_data(self):
|
||||
"""Test stacked bar chart with empty data."""
|
||||
buffer = create_stacked_bar_chart(labels=[], data_series={})
|
||||
assert isinstance(buffer, io.BytesIO)
|
||||
|
||||
|
||||
class TestComponentEdgeCases:
|
||||
"""Tests for component edge cases."""
|
||||
|
||||
|
||||
@@ -4,13 +4,11 @@ import pytest
|
||||
from api.models import StatusChoices
|
||||
from reportlab.platypus import Image, LongTable, Paragraph, Table
|
||||
from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData
|
||||
from tasks.jobs.reports import cis as cis_report_module
|
||||
from tasks.jobs.reports.cis import (
|
||||
CISReportGenerator,
|
||||
_normalize_profile,
|
||||
_profile_badge_text,
|
||||
)
|
||||
from tasks.tests.report_test_helpers import patch_chart_helpers
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
@@ -401,69 +399,18 @@ class TestCISExecutiveSummary:
|
||||
|
||||
|
||||
class TestCISChartsSection:
|
||||
def test_charts_rendered(
|
||||
self, monkeypatch, cis_generator, populated_cis_compliance_data
|
||||
):
|
||||
chart_calls = patch_chart_helpers(
|
||||
monkeypatch,
|
||||
cis_report_module,
|
||||
(
|
||||
"create_pie_chart",
|
||||
"create_horizontal_bar_chart",
|
||||
"create_stacked_bar_chart",
|
||||
),
|
||||
)
|
||||
|
||||
def test_charts_rendered(self, cis_generator, populated_cis_compliance_data):
|
||||
elements = cis_generator.create_charts_section(populated_cis_compliance_data)
|
||||
|
||||
# At least 1 image for the pie + 1 for section bar + 1 for stacked
|
||||
images = [e for e in elements if isinstance(e, Image)]
|
||||
assert len(images) == 3
|
||||
assert len(images) >= 1
|
||||
|
||||
pie_kwargs = chart_calls["create_pie_chart"][0]["kwargs"]
|
||||
assert pie_kwargs["labels"] == ["Pass (2)", "Fail (2)", "Manual (1)"]
|
||||
assert pie_kwargs["values"] == [2, 2, 1]
|
||||
assert pie_kwargs["colors"]
|
||||
|
||||
bar_kwargs = chart_calls["create_horizontal_bar_chart"][0]["kwargs"]
|
||||
assert set(bar_kwargs["labels"]) == {
|
||||
"1 Identity and Access Management",
|
||||
"2 Storage",
|
||||
}
|
||||
assert bar_kwargs["values"] == [50.0, 50.0]
|
||||
assert bar_kwargs["xlabel"] == "Compliance (%)"
|
||||
assert bar_kwargs["color_func"]
|
||||
assert bar_kwargs["label_fontsize"] == 9
|
||||
|
||||
stacked_kwargs = chart_calls["create_stacked_bar_chart"][0]["kwargs"]
|
||||
assert stacked_kwargs["labels"] == ["Level 1", "Level 2"]
|
||||
assert stacked_kwargs["data_series"] == {
|
||||
"Pass": [1, 1],
|
||||
"Fail": [2, 0],
|
||||
"Manual": [0, 1],
|
||||
}
|
||||
assert stacked_kwargs["xlabel"] == "Profile"
|
||||
assert stacked_kwargs["ylabel"] == "Requirements"
|
||||
|
||||
def test_charts_no_data_no_crash(
|
||||
self, monkeypatch, cis_generator, basic_cis_compliance_data
|
||||
):
|
||||
chart_calls = patch_chart_helpers(
|
||||
monkeypatch,
|
||||
cis_report_module,
|
||||
(
|
||||
"create_pie_chart",
|
||||
"create_horizontal_bar_chart",
|
||||
"create_stacked_bar_chart",
|
||||
),
|
||||
)
|
||||
def test_charts_no_data_no_crash(self, cis_generator, basic_cis_compliance_data):
|
||||
basic_cis_compliance_data.requirements = []
|
||||
basic_cis_compliance_data.attributes_by_requirement_id = {}
|
||||
elements = cis_generator.create_charts_section(basic_cis_compliance_data)
|
||||
|
||||
# Must not raise; may or may not have any Image
|
||||
assert isinstance(elements, list)
|
||||
assert chart_calls["create_pie_chart"] == []
|
||||
assert chart_calls["create_horizontal_bar_chart"] == []
|
||||
assert chart_calls["create_stacked_bar_chart"] == []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -2,11 +2,9 @@ import io
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from reportlab.platypus import Image, PageBreak, Paragraph, Table
|
||||
from reportlab.platypus import PageBreak, Paragraph, Table
|
||||
from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData
|
||||
from tasks.jobs.reports import csa as csa_report_module
|
||||
from tasks.jobs.reports.csa import CSAReportGenerator
|
||||
from tasks.tests.report_test_helpers import patch_chart_helpers
|
||||
|
||||
|
||||
# Use string status values directly to avoid Django DB initialization
|
||||
@@ -31,13 +29,6 @@ def csa_generator():
|
||||
return CSAReportGenerator(config)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_csa_charts(monkeypatch):
|
||||
return patch_chart_helpers(
|
||||
monkeypatch, csa_report_module, ("create_horizontal_bar_chart",)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_csa_requirement_attribute_iam():
|
||||
"""Create a mock CSA CCM requirement attribute for Identity & Access Management."""
|
||||
@@ -329,7 +320,7 @@ class TestCSAChartsSection:
|
||||
"""Test suite for CSA charts section generation."""
|
||||
|
||||
def test_charts_section_has_section_chart_title(
|
||||
self, csa_generator, basic_csa_compliance_data, patched_csa_charts
|
||||
self, csa_generator, basic_csa_compliance_data
|
||||
):
|
||||
"""Test that charts section has section compliance title."""
|
||||
basic_csa_compliance_data.requirements = []
|
||||
@@ -340,14 +331,9 @@ class TestCSAChartsSection:
|
||||
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
|
||||
content = " ".join(str(p.text) for p in paragraphs)
|
||||
assert "Section" in content or "Compliance" in content
|
||||
assert any(isinstance(e, Image) for e in elements)
|
||||
chart_kwargs = patched_csa_charts["create_horizontal_bar_chart"][0]["kwargs"]
|
||||
assert chart_kwargs["labels"] == []
|
||||
assert chart_kwargs["values"] == []
|
||||
assert chart_kwargs["xlabel"] == "Compliance (%)"
|
||||
|
||||
def test_charts_section_has_page_break(
|
||||
self, csa_generator, basic_csa_compliance_data, patched_csa_charts
|
||||
self, csa_generator, basic_csa_compliance_data
|
||||
):
|
||||
"""Test that charts section has page breaks."""
|
||||
basic_csa_compliance_data.requirements = []
|
||||
@@ -357,14 +343,12 @@ class TestCSAChartsSection:
|
||||
|
||||
page_breaks = [e for e in elements if isinstance(e, PageBreak)]
|
||||
assert len(page_breaks) >= 1
|
||||
assert len(patched_csa_charts["create_horizontal_bar_chart"]) == 1
|
||||
|
||||
def test_charts_section_has_section_breakdown(
|
||||
self,
|
||||
csa_generator,
|
||||
basic_csa_compliance_data,
|
||||
mock_csa_requirement_attribute_iam,
|
||||
patched_csa_charts,
|
||||
):
|
||||
"""Test that charts section includes section breakdown table."""
|
||||
basic_csa_compliance_data.requirements = [
|
||||
@@ -388,11 +372,6 @@ class TestCSAChartsSection:
|
||||
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
|
||||
content = " ".join(str(p.text) for p in paragraphs)
|
||||
assert "Section" in content or "Breakdown" in content
|
||||
assert any(isinstance(e, Image) for e in elements)
|
||||
chart_kwargs = patched_csa_charts["create_horizontal_bar_chart"][0]["kwargs"]
|
||||
assert chart_kwargs["labels"] == ["Identity & Access Management"]
|
||||
assert chart_kwargs["values"] == [100.0]
|
||||
assert chart_kwargs["color_func"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -408,7 +387,6 @@ class TestCSASectionChart:
|
||||
csa_generator,
|
||||
basic_csa_compliance_data,
|
||||
mock_csa_requirement_attribute_iam,
|
||||
patched_csa_charts,
|
||||
):
|
||||
"""Test that section chart is created successfully."""
|
||||
basic_csa_compliance_data.requirements = [
|
||||
@@ -431,17 +409,12 @@ class TestCSASectionChart:
|
||||
|
||||
assert isinstance(chart_buffer, io.BytesIO)
|
||||
assert chart_buffer.getvalue() # Not empty
|
||||
chart_kwargs = patched_csa_charts["create_horizontal_bar_chart"][0]["kwargs"]
|
||||
assert chart_kwargs["labels"] == ["Identity & Access Management"]
|
||||
assert chart_kwargs["values"] == [100.0]
|
||||
assert chart_kwargs["xlabel"] == "Compliance (%)"
|
||||
|
||||
def test_section_chart_excludes_manual(
|
||||
self,
|
||||
csa_generator,
|
||||
basic_csa_compliance_data,
|
||||
mock_csa_requirement_attribute_iam,
|
||||
patched_csa_charts,
|
||||
):
|
||||
"""Test that manual requirements are excluded from section chart."""
|
||||
basic_csa_compliance_data.requirements = [
|
||||
@@ -474,9 +447,6 @@ class TestCSASectionChart:
|
||||
# Should not raise any errors
|
||||
chart_buffer = csa_generator._create_section_chart(basic_csa_compliance_data)
|
||||
assert isinstance(chart_buffer, io.BytesIO)
|
||||
chart_kwargs = patched_csa_charts["create_horizontal_bar_chart"][0]["kwargs"]
|
||||
assert chart_kwargs["labels"] == ["Identity & Access Management"]
|
||||
assert chart_kwargs["values"] == [100.0]
|
||||
|
||||
def test_section_chart_multiple_sections(
|
||||
self,
|
||||
@@ -485,7 +455,6 @@ class TestCSASectionChart:
|
||||
mock_csa_requirement_attribute_iam,
|
||||
mock_csa_requirement_attribute_logging,
|
||||
mock_csa_requirement_attribute_crypto,
|
||||
patched_csa_charts,
|
||||
):
|
||||
"""Test section chart with multiple sections."""
|
||||
basic_csa_compliance_data.requirements = [
|
||||
@@ -532,13 +501,6 @@ class TestCSASectionChart:
|
||||
|
||||
chart_buffer = csa_generator._create_section_chart(basic_csa_compliance_data)
|
||||
assert isinstance(chart_buffer, io.BytesIO)
|
||||
chart_kwargs = patched_csa_charts["create_horizontal_bar_chart"][0]["kwargs"]
|
||||
assert chart_kwargs["labels"] == [
|
||||
"Cryptography & Encryption",
|
||||
"Identity & Access Management",
|
||||
"Logging and Monitoring",
|
||||
]
|
||||
assert chart_kwargs["values"] == [100.0, 100.0, 0.0]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -2,11 +2,9 @@ import io
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from reportlab.platypus import Image, PageBreak, Paragraph, Table
|
||||
from reportlab.platypus import PageBreak, Paragraph, Table
|
||||
from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData
|
||||
from tasks.jobs.reports import ens as ens_report_module
|
||||
from tasks.jobs.reports.ens import ENSReportGenerator
|
||||
from tasks.tests.report_test_helpers import patch_chart_helpers
|
||||
|
||||
|
||||
# Use string status values directly to avoid Django DB initialization
|
||||
@@ -31,15 +29,6 @@ def ens_generator():
|
||||
return ENSReportGenerator(config)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_ens_charts(monkeypatch):
|
||||
return patch_chart_helpers(
|
||||
monkeypatch,
|
||||
ens_report_module,
|
||||
("create_horizontal_bar_chart", "create_radar_chart"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ens_requirement_attribute():
|
||||
"""Create a mock ENS requirement attribute with all fields."""
|
||||
@@ -366,7 +355,7 @@ class TestENSChartsSection:
|
||||
"""Test suite for ENS charts section generation."""
|
||||
|
||||
def test_charts_section_has_page_breaks(
|
||||
self, ens_generator, basic_ens_compliance_data, patched_ens_charts
|
||||
self, ens_generator, basic_ens_compliance_data
|
||||
):
|
||||
"""Test that charts section has page breaks between charts."""
|
||||
basic_ens_compliance_data.requirements = []
|
||||
@@ -376,25 +365,9 @@ class TestENSChartsSection:
|
||||
|
||||
page_breaks = [e for e in elements if isinstance(e, PageBreak)]
|
||||
assert len(page_breaks) >= 2 # At least 2 page breaks for different charts
|
||||
assert any(isinstance(e, Image) for e in elements)
|
||||
assert len(patched_ens_charts["create_horizontal_bar_chart"]) == 1
|
||||
assert len(patched_ens_charts["create_radar_chart"]) == 1
|
||||
|
||||
marco_kwargs = patched_ens_charts["create_horizontal_bar_chart"][0]["kwargs"]
|
||||
assert marco_kwargs["labels"] == []
|
||||
assert marco_kwargs["values"] == []
|
||||
|
||||
radar_kwargs = patched_ens_charts["create_radar_chart"][0]["kwargs"]
|
||||
assert radar_kwargs["labels"] == ens_report_module.DIMENSION_NAMES
|
||||
assert radar_kwargs["values"] == [100, 100, 100, 100, 100]
|
||||
assert radar_kwargs["color"] == "#2196F3"
|
||||
|
||||
def test_charts_section_has_marco_category_chart(
|
||||
self,
|
||||
ens_generator,
|
||||
basic_ens_compliance_data,
|
||||
mock_ens_requirement_attribute,
|
||||
patched_ens_charts,
|
||||
self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
|
||||
):
|
||||
"""Test that charts section contains Marco/Categoría chart."""
|
||||
basic_ens_compliance_data.requirements = [
|
||||
@@ -418,18 +391,9 @@ class TestENSChartsSection:
|
||||
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
|
||||
content = " ".join(str(p.text) for p in paragraphs)
|
||||
assert "Marco" in content or "Categoría" in content
|
||||
assert any(isinstance(e, Image) for e in elements)
|
||||
chart_kwargs = patched_ens_charts["create_horizontal_bar_chart"][0]["kwargs"]
|
||||
assert chart_kwargs["labels"] == ["Operacional - Gestión de incidentes"]
|
||||
assert chart_kwargs["values"] == [100.0]
|
||||
assert chart_kwargs["xlabel"] == "Porcentaje de Cumplimiento (%)"
|
||||
|
||||
def test_charts_section_has_dimensions_radar(
|
||||
self,
|
||||
ens_generator,
|
||||
basic_ens_compliance_data,
|
||||
mock_ens_requirement_attribute,
|
||||
patched_ens_charts,
|
||||
self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
|
||||
):
|
||||
"""Test that charts section contains dimensions radar chart."""
|
||||
basic_ens_compliance_data.requirements = [
|
||||
@@ -453,17 +417,9 @@ class TestENSChartsSection:
|
||||
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
|
||||
content = " ".join(str(p.text) for p in paragraphs)
|
||||
assert "Dimensiones" in content or "dimensiones" in content.lower()
|
||||
radar_kwargs = patched_ens_charts["create_radar_chart"][0]["kwargs"]
|
||||
assert radar_kwargs["labels"] == ens_report_module.DIMENSION_NAMES
|
||||
assert radar_kwargs["values"] == [100, 100, 100.0, 100.0, 100]
|
||||
assert radar_kwargs["color"] == "#2196F3"
|
||||
|
||||
def test_charts_section_has_tipo_distribution(
|
||||
self,
|
||||
ens_generator,
|
||||
basic_ens_compliance_data,
|
||||
mock_ens_requirement_attribute,
|
||||
patched_ens_charts,
|
||||
self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
|
||||
):
|
||||
"""Test that charts section contains tipo distribution."""
|
||||
basic_ens_compliance_data.requirements = [
|
||||
@@ -487,8 +443,6 @@ class TestENSChartsSection:
|
||||
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
|
||||
content = " ".join(str(p.text) for p in paragraphs)
|
||||
assert "Tipo" in content or "tipo" in content.lower()
|
||||
assert len(patched_ens_charts["create_horizontal_bar_chart"]) == 1
|
||||
assert len(patched_ens_charts["create_radar_chart"]) == 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -875,11 +829,7 @@ class TestENSDimensionHandling:
|
||||
"""Test suite for ENS security dimension handling."""
|
||||
|
||||
def test_dimensions_as_list(
|
||||
self,
|
||||
ens_generator,
|
||||
basic_ens_compliance_data,
|
||||
mock_ens_requirement_attribute,
|
||||
patched_ens_charts,
|
||||
self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
|
||||
):
|
||||
"""Test handling dimensions as a list."""
|
||||
# mock_ens_requirement_attribute has Dimensiones as list
|
||||
@@ -887,9 +837,9 @@ class TestENSDimensionHandling:
|
||||
RequirementData(
|
||||
id="REQ-001",
|
||||
description="Test requirement",
|
||||
status=StatusChoices.FAIL,
|
||||
passed_findings=0,
|
||||
failed_findings=10,
|
||||
status=StatusChoices.PASS,
|
||||
passed_findings=10,
|
||||
failed_findings=0,
|
||||
total_findings=10,
|
||||
),
|
||||
]
|
||||
@@ -904,16 +854,12 @@ class TestENSDimensionHandling:
|
||||
basic_ens_compliance_data
|
||||
)
|
||||
assert isinstance(chart_buffer, io.BytesIO)
|
||||
chart_kwargs = patched_ens_charts["create_radar_chart"][0]["kwargs"]
|
||||
assert chart_kwargs["labels"] == ens_report_module.DIMENSION_NAMES
|
||||
assert chart_kwargs["values"] == [100, 100, 0.0, 0.0, 100]
|
||||
|
||||
def test_dimensions_as_string(
|
||||
self,
|
||||
ens_generator,
|
||||
basic_ens_compliance_data,
|
||||
mock_ens_requirement_attribute_medio,
|
||||
patched_ens_charts,
|
||||
):
|
||||
"""Test handling dimensions as comma-separated string."""
|
||||
# mock_ens_requirement_attribute_medio has Dimensiones as string
|
||||
@@ -921,9 +867,9 @@ class TestENSDimensionHandling:
|
||||
RequirementData(
|
||||
id="REQ-001",
|
||||
description="Test requirement",
|
||||
status=StatusChoices.FAIL,
|
||||
passed_findings=0,
|
||||
failed_findings=10,
|
||||
status=StatusChoices.PASS,
|
||||
passed_findings=10,
|
||||
failed_findings=0,
|
||||
total_findings=10,
|
||||
),
|
||||
]
|
||||
@@ -938,16 +884,12 @@ class TestENSDimensionHandling:
|
||||
basic_ens_compliance_data
|
||||
)
|
||||
assert isinstance(chart_buffer, io.BytesIO)
|
||||
chart_kwargs = patched_ens_charts["create_radar_chart"][0]["kwargs"]
|
||||
assert chart_kwargs["labels"] == ens_report_module.DIMENSION_NAMES
|
||||
assert chart_kwargs["values"] == [0.0, 0.0, 100, 100, 100]
|
||||
|
||||
def test_dimensions_empty(
|
||||
self,
|
||||
ens_generator,
|
||||
basic_ens_compliance_data,
|
||||
mock_ens_requirement_attribute_opcional,
|
||||
patched_ens_charts,
|
||||
):
|
||||
"""Test handling empty dimensions."""
|
||||
# mock_ens_requirement_attribute_opcional has empty Dimensiones
|
||||
@@ -974,9 +916,6 @@ class TestENSDimensionHandling:
|
||||
basic_ens_compliance_data
|
||||
)
|
||||
assert isinstance(chart_buffer, io.BytesIO)
|
||||
chart_kwargs = patched_ens_charts["create_radar_chart"][0]["kwargs"]
|
||||
assert chart_kwargs["labels"] == ens_report_module.DIMENSION_NAMES
|
||||
assert chart_kwargs["values"] == [100, 100, 100, 100, 100]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1122,11 +1061,7 @@ class TestENSMarcoCategoryChart:
|
||||
"""Test suite for ENS Marco/Categoría chart."""
|
||||
|
||||
def test_marco_category_chart_creation(
|
||||
self,
|
||||
ens_generator,
|
||||
basic_ens_compliance_data,
|
||||
mock_ens_requirement_attribute,
|
||||
patched_ens_charts,
|
||||
self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
|
||||
):
|
||||
"""Test that Marco/Categoría chart is created successfully."""
|
||||
basic_ens_compliance_data.requirements = [
|
||||
@@ -1151,17 +1086,9 @@ class TestENSMarcoCategoryChart:
|
||||
|
||||
assert isinstance(chart_buffer, io.BytesIO)
|
||||
assert chart_buffer.getvalue() # Not empty
|
||||
chart_kwargs = patched_ens_charts["create_horizontal_bar_chart"][0]["kwargs"]
|
||||
assert chart_kwargs["labels"] == ["Operacional - Gestión de incidentes"]
|
||||
assert chart_kwargs["values"] == [100.0]
|
||||
assert chart_kwargs["xlabel"] == "Porcentaje de Cumplimiento (%)"
|
||||
|
||||
def test_marco_category_chart_excludes_manual(
|
||||
self,
|
||||
ens_generator,
|
||||
basic_ens_compliance_data,
|
||||
mock_ens_requirement_attribute,
|
||||
patched_ens_charts,
|
||||
self, ens_generator, basic_ens_compliance_data, mock_ens_requirement_attribute
|
||||
):
|
||||
"""Test that manual requirements are excluded from chart."""
|
||||
basic_ens_compliance_data.requirements = [
|
||||
@@ -1196,9 +1123,6 @@ class TestENSMarcoCategoryChart:
|
||||
basic_ens_compliance_data
|
||||
)
|
||||
assert isinstance(chart_buffer, io.BytesIO)
|
||||
chart_kwargs = patched_ens_charts["create_horizontal_bar_chart"][0]["kwargs"]
|
||||
assert chart_kwargs["labels"] == ["Operacional - Gestión de incidentes"]
|
||||
assert chart_kwargs["values"] == [100.0]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -2,11 +2,9 @@ import io
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from reportlab.platypus import Image, PageBreak, Paragraph, Table
|
||||
from reportlab.platypus import PageBreak, Paragraph, Table
|
||||
from tasks.jobs.reports import FRAMEWORK_REGISTRY, ComplianceData, RequirementData
|
||||
from tasks.jobs.reports import nis2 as nis2_report_module
|
||||
from tasks.jobs.reports.nis2 import NIS2ReportGenerator, _extract_section_number
|
||||
from tasks.tests.report_test_helpers import patch_chart_helpers
|
||||
|
||||
|
||||
# Use string status values directly to avoid Django DB initialization
|
||||
@@ -31,13 +29,6 @@ def nis2_generator():
|
||||
return NIS2ReportGenerator(config)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_nis2_charts(monkeypatch):
|
||||
return patch_chart_helpers(
|
||||
monkeypatch, nis2_report_module, ("create_horizontal_bar_chart",)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_nis2_requirement_attribute_section1():
|
||||
"""Create a mock NIS2 requirement attribute for Section 1."""
|
||||
@@ -389,7 +380,7 @@ class TestNIS2ChartsSection:
|
||||
"""Test suite for NIS2 charts section generation."""
|
||||
|
||||
def test_charts_section_has_section_chart_title(
|
||||
self, nis2_generator, basic_nis2_compliance_data, patched_nis2_charts
|
||||
self, nis2_generator, basic_nis2_compliance_data
|
||||
):
|
||||
"""Test that charts section has section compliance title."""
|
||||
basic_nis2_compliance_data.requirements = []
|
||||
@@ -400,14 +391,9 @@ class TestNIS2ChartsSection:
|
||||
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
|
||||
content = " ".join(str(p.text) for p in paragraphs)
|
||||
assert "Section" in content or "Compliance" in content
|
||||
assert any(isinstance(e, Image) for e in elements)
|
||||
chart_kwargs = patched_nis2_charts["create_horizontal_bar_chart"][0]["kwargs"]
|
||||
assert chart_kwargs["labels"] == []
|
||||
assert chart_kwargs["values"] == []
|
||||
assert chart_kwargs["xlabel"] == "Compliance (%)"
|
||||
|
||||
def test_charts_section_has_page_break(
|
||||
self, nis2_generator, basic_nis2_compliance_data, patched_nis2_charts
|
||||
self, nis2_generator, basic_nis2_compliance_data
|
||||
):
|
||||
"""Test that charts section has page breaks."""
|
||||
basic_nis2_compliance_data.requirements = []
|
||||
@@ -417,14 +403,12 @@ class TestNIS2ChartsSection:
|
||||
|
||||
page_breaks = [e for e in elements if isinstance(e, PageBreak)]
|
||||
assert len(page_breaks) >= 1
|
||||
assert len(patched_nis2_charts["create_horizontal_bar_chart"]) == 1
|
||||
|
||||
def test_charts_section_has_subsection_breakdown(
|
||||
self,
|
||||
nis2_generator,
|
||||
basic_nis2_compliance_data,
|
||||
mock_nis2_requirement_attribute_section1,
|
||||
patched_nis2_charts,
|
||||
):
|
||||
"""Test that charts section includes subsection breakdown table."""
|
||||
basic_nis2_compliance_data.requirements = [
|
||||
@@ -450,11 +434,6 @@ class TestNIS2ChartsSection:
|
||||
paragraphs = [e for e in elements if isinstance(e, Paragraph)]
|
||||
content = " ".join(str(p.text) for p in paragraphs)
|
||||
assert "SubSection" in content or "Breakdown" in content
|
||||
assert any(isinstance(e, Image) for e in elements)
|
||||
chart_kwargs = patched_nis2_charts["create_horizontal_bar_chart"][0]["kwargs"]
|
||||
assert chart_kwargs["labels"] == ["1. Policy on Security"]
|
||||
assert chart_kwargs["values"] == [100.0]
|
||||
assert chart_kwargs["color_func"]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -470,7 +449,6 @@ class TestNIS2SectionChart:
|
||||
nis2_generator,
|
||||
basic_nis2_compliance_data,
|
||||
mock_nis2_requirement_attribute_section1,
|
||||
patched_nis2_charts,
|
||||
):
|
||||
"""Test that section chart is created successfully."""
|
||||
basic_nis2_compliance_data.requirements = [
|
||||
@@ -495,17 +473,12 @@ class TestNIS2SectionChart:
|
||||
|
||||
assert isinstance(chart_buffer, io.BytesIO)
|
||||
assert chart_buffer.getvalue() # Not empty
|
||||
chart_kwargs = patched_nis2_charts["create_horizontal_bar_chart"][0]["kwargs"]
|
||||
assert chart_kwargs["labels"] == ["1. Policy on Security"]
|
||||
assert chart_kwargs["values"] == [100.0]
|
||||
assert chart_kwargs["xlabel"] == "Compliance (%)"
|
||||
|
||||
def test_section_chart_excludes_manual(
|
||||
self,
|
||||
nis2_generator,
|
||||
basic_nis2_compliance_data,
|
||||
mock_nis2_requirement_attribute_section1,
|
||||
patched_nis2_charts,
|
||||
):
|
||||
"""Test that manual requirements are excluded from section chart."""
|
||||
basic_nis2_compliance_data.requirements = [
|
||||
@@ -542,9 +515,6 @@ class TestNIS2SectionChart:
|
||||
# Should not raise any errors
|
||||
chart_buffer = nis2_generator._create_section_chart(basic_nis2_compliance_data)
|
||||
assert isinstance(chart_buffer, io.BytesIO)
|
||||
chart_kwargs = patched_nis2_charts["create_horizontal_bar_chart"][0]["kwargs"]
|
||||
assert chart_kwargs["labels"] == ["1. Policy on Security"]
|
||||
assert chart_kwargs["values"] == [100.0]
|
||||
|
||||
def test_section_chart_multiple_sections(
|
||||
self,
|
||||
@@ -553,7 +523,6 @@ class TestNIS2SectionChart:
|
||||
mock_nis2_requirement_attribute_section1,
|
||||
mock_nis2_requirement_attribute_section2,
|
||||
mock_nis2_requirement_attribute_section11,
|
||||
patched_nis2_charts,
|
||||
):
|
||||
"""Test section chart with multiple sections."""
|
||||
basic_nis2_compliance_data.requirements = [
|
||||
@@ -602,13 +571,6 @@ class TestNIS2SectionChart:
|
||||
|
||||
chart_buffer = nis2_generator._create_section_chart(basic_nis2_compliance_data)
|
||||
assert isinstance(chart_buffer, io.BytesIO)
|
||||
chart_kwargs = patched_nis2_charts["create_horizontal_bar_chart"][0]["kwargs"]
|
||||
assert chart_kwargs["labels"] == [
|
||||
"1. Policy on Security",
|
||||
"2. Risk Management",
|
||||
"11. Access Control",
|
||||
]
|
||||
assert chart_kwargs["values"] == [100.0, 0.0, 100.0]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -10,8 +10,6 @@ from tasks.jobs.reports import (
|
||||
RequirementData,
|
||||
ThreatScoreReportGenerator,
|
||||
)
|
||||
from tasks.jobs.reports import threatscore as threatscore_report_module
|
||||
from tasks.tests.report_test_helpers import patch_chart_helpers
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
@@ -25,13 +23,6 @@ def threatscore_generator():
|
||||
return ThreatScoreReportGenerator(config)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_threatscore_charts(monkeypatch):
|
||||
return patch_chart_helpers(
|
||||
monkeypatch, threatscore_report_module, ("create_vertical_bar_chart",)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_requirement_attribute():
|
||||
"""Create a mock requirement attribute with numeric values."""
|
||||
@@ -686,7 +677,7 @@ class TestSectionScoreChart:
|
||||
"""Test suite for section score chart generation."""
|
||||
|
||||
def test_create_section_chart_empty_data(
|
||||
self, threatscore_generator, basic_compliance_data, patched_threatscore_charts
|
||||
self, threatscore_generator, basic_compliance_data
|
||||
):
|
||||
"""Test chart creation with no requirements."""
|
||||
basic_compliance_data.requirements = []
|
||||
@@ -698,22 +689,9 @@ class TestSectionScoreChart:
|
||||
|
||||
assert isinstance(result, io.BytesIO)
|
||||
assert result.getvalue() # Should have content
|
||||
chart_kwargs = patched_threatscore_charts["create_vertical_bar_chart"][0][
|
||||
"kwargs"
|
||||
]
|
||||
assert chart_kwargs["labels"] == []
|
||||
assert chart_kwargs["values"] == []
|
||||
assert chart_kwargs["ylabel"] == "Compliance Score (%)"
|
||||
assert chart_kwargs["xlabel"] == ""
|
||||
assert chart_kwargs["color_func"]
|
||||
assert chart_kwargs["rotation"] == 0
|
||||
|
||||
def test_create_section_chart_single_section(
|
||||
self,
|
||||
threatscore_generator,
|
||||
basic_compliance_data,
|
||||
mock_requirement_attribute,
|
||||
patched_threatscore_charts,
|
||||
self, threatscore_generator, basic_compliance_data, mock_requirement_attribute
|
||||
):
|
||||
"""Test chart creation with a single section."""
|
||||
basic_compliance_data.requirements = [
|
||||
@@ -735,14 +713,9 @@ class TestSectionScoreChart:
|
||||
)
|
||||
|
||||
assert isinstance(result, io.BytesIO)
|
||||
chart_kwargs = patched_threatscore_charts["create_vertical_bar_chart"][0][
|
||||
"kwargs"
|
||||
]
|
||||
assert chart_kwargs["labels"] == ["1. IAM"]
|
||||
assert chart_kwargs["values"] == [100.0]
|
||||
|
||||
def test_create_section_chart_multiple_sections(
|
||||
self, threatscore_generator, basic_compliance_data, patched_threatscore_charts
|
||||
self, threatscore_generator, basic_compliance_data
|
||||
):
|
||||
"""Test chart creation with multiple sections."""
|
||||
mock_attr_1 = Mock()
|
||||
@@ -783,14 +756,9 @@ class TestSectionScoreChart:
|
||||
)
|
||||
|
||||
assert isinstance(result, io.BytesIO)
|
||||
chart_kwargs = patched_threatscore_charts["create_vertical_bar_chart"][0][
|
||||
"kwargs"
|
||||
]
|
||||
assert chart_kwargs["labels"] == ["1. IAM", "2. Attack Surface"]
|
||||
assert chart_kwargs["values"] == [100.0, 50.0]
|
||||
|
||||
def test_create_section_chart_no_findings_section_gets_100(
|
||||
self, threatscore_generator, basic_compliance_data, patched_threatscore_charts
|
||||
self, threatscore_generator, basic_compliance_data
|
||||
):
|
||||
"""Test that sections without findings get 100% score."""
|
||||
mock_attr = Mock()
|
||||
@@ -818,11 +786,6 @@ class TestSectionScoreChart:
|
||||
)
|
||||
|
||||
assert isinstance(result, io.BytesIO)
|
||||
chart_kwargs = patched_threatscore_charts["create_vertical_bar_chart"][0][
|
||||
"kwargs"
|
||||
]
|
||||
assert chart_kwargs["labels"] == ["1. IAM"]
|
||||
assert chart_kwargs["values"] == [100.0]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -834,11 +797,7 @@ class TestExecutiveSummary:
|
||||
"""Test suite for executive summary generation."""
|
||||
|
||||
def test_executive_summary_contains_chart(
|
||||
self,
|
||||
threatscore_generator,
|
||||
basic_compliance_data,
|
||||
mock_requirement_attribute,
|
||||
patched_threatscore_charts,
|
||||
self, threatscore_generator, basic_compliance_data, mock_requirement_attribute
|
||||
):
|
||||
"""Test that executive summary contains a chart."""
|
||||
basic_compliance_data.requirements = [
|
||||
@@ -859,18 +818,9 @@ class TestExecutiveSummary:
|
||||
|
||||
assert len(elements) > 0
|
||||
assert any(isinstance(e, Image) for e in elements)
|
||||
chart_kwargs = patched_threatscore_charts["create_vertical_bar_chart"][0][
|
||||
"kwargs"
|
||||
]
|
||||
assert chart_kwargs["labels"] == ["1. IAM"]
|
||||
assert chart_kwargs["values"] == [100.0]
|
||||
|
||||
def test_executive_summary_contains_score_table(
|
||||
self,
|
||||
threatscore_generator,
|
||||
basic_compliance_data,
|
||||
mock_requirement_attribute,
|
||||
patched_threatscore_charts,
|
||||
self, threatscore_generator, basic_compliance_data, mock_requirement_attribute
|
||||
):
|
||||
"""Test that executive summary contains a score table."""
|
||||
basic_compliance_data.requirements = [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user