mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-20 10:00:40 +00:00
Compare commits
156
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c076df74f9 | ||
|
|
5e812c614b | ||
|
|
cd3d5630b7 | ||
|
|
e3adb38bb6 | ||
|
|
1d3cefa490 | ||
|
|
5f94be2611 | ||
|
|
065ce48c0f | ||
|
|
8ee4ae2f46 | ||
|
|
2d535b19cc | ||
|
|
d480679638 | ||
|
|
6e24a49128 | ||
|
|
a9a60c9448 | ||
|
|
a180d8a775 | ||
|
|
43ecebc849 | ||
|
|
a70a69a7d0 | ||
|
|
6d7b1ef153 | ||
|
|
f26c814e5a | ||
|
|
587f9bcfa6 | ||
|
|
b12aa854d6 | ||
|
|
407ce0d4ca | ||
|
|
fbbf9fe1cd | ||
|
|
ca9feed805 | ||
|
|
234e69da3f | ||
|
|
f1b3b76efc | ||
|
|
5cd673787b | ||
|
|
b9aa863e52 | ||
|
|
39cbcbfe2b | ||
|
|
f19106281b | ||
|
|
137b388df9 | ||
|
|
e0cb18bdbd | ||
|
|
b08d2eb472 | ||
|
|
74a760517e | ||
|
|
2db3bebd15 | ||
|
|
b56df840fc | ||
|
|
bd9fa88d2a | ||
|
|
9adc248c05 | ||
|
|
ea0c7eb271 | ||
|
|
88c666a0d2 | ||
|
|
7275b46707 | ||
|
|
60bc06271a | ||
|
|
8abd72e857 | ||
|
|
9185b043da | ||
|
|
3dd6b29477 | ||
|
|
6db407ed3c | ||
|
|
0b98a34687 | ||
|
|
fc0204a40d | ||
|
|
7a62926a09 | ||
|
|
7a6a35afec | ||
|
|
b7281a5221 | ||
|
|
5c4b0ba1fe | ||
|
|
f3b8ac1dbb | ||
|
|
8dac2a7ccf | ||
|
|
6192b8ac32 | ||
|
|
e49babb9e7 | ||
|
|
f8be9afa7c | ||
|
|
ecf7ec8e85 | ||
|
|
a57a507cee | ||
|
|
9b565586da | ||
|
|
17f726f816 | ||
|
|
3fd748994a | ||
|
|
976220dbf5 | ||
|
|
a77e56b5a0 | ||
|
|
34e4d25576 | ||
|
|
d4a33c0d1c | ||
|
|
4c3017e2ed | ||
|
|
03f2ab46c9 | ||
|
|
7f1cdb82ae | ||
|
|
1bb3fc9bda | ||
|
|
463e8309f7 | ||
|
|
59a7d30a3e | ||
|
|
2ae1062e76 | ||
|
|
1218b0920f | ||
|
|
06ea61ffbf | ||
|
|
8eeb37aea4 | ||
|
|
7ba96ab2e2 | ||
|
|
4987d8a08e | ||
|
|
2bd89ceb97 | ||
|
|
0d4a21b5a4 | ||
|
|
1460f7b188 | ||
|
|
e9bbde2f01 | ||
|
|
76239b7076 | ||
|
|
0c0f150cd4 | ||
|
|
43de7709cb | ||
|
|
bb18dcb882 | ||
|
|
f5fe9c7b40 | ||
|
|
60c81f6549 | ||
|
|
f18c2841a8 | ||
|
|
6b21e31a28 | ||
|
|
294e665d9e | ||
|
|
94e14660da | ||
|
|
339930ef13 | ||
|
|
d933a8ecab | ||
|
|
da09ad9813 | ||
|
|
2298d4a3f8 | ||
|
|
066d53467a | ||
|
|
cf433128ed | ||
|
|
0b782fcb8c | ||
|
|
b80e3a7bfb | ||
|
|
885555e080 | ||
|
|
641c418816 | ||
|
|
10d173f8da | ||
|
|
34de660755 | ||
|
|
fb75146e34 | ||
|
|
9f5ef80e69 | ||
|
|
2f6aedf291 | ||
|
|
dcf2736e8d | ||
|
|
7f0dc9b7da | ||
|
|
ff45f46047 | ||
|
|
3bd13d173d | ||
|
|
2b7f7e7dc0 | ||
|
|
98015dafef | ||
|
|
d70a7e3d02 | ||
|
|
7d2a22c45a | ||
|
|
bf82e9ff3d | ||
|
|
eece938350 | ||
|
|
2b0e34818c | ||
|
|
f587dbf419 | ||
|
|
2d684c1996 | ||
|
|
7f9d64a996 | ||
|
|
e943ded978 | ||
|
|
bb20f69a63 | ||
|
|
97233189c3 | ||
|
|
b624818b8e | ||
|
|
cb31856025 | ||
|
|
13a9caa803 | ||
|
|
4e22289a19 | ||
|
|
e035e0ff62 | ||
|
|
dd81793480 | ||
|
|
457297a5f6 | ||
|
|
4da5aed519 | ||
|
|
95521d26cb | ||
|
|
cbe06314ca | ||
|
|
4b72cc8dd4 | ||
|
|
ce9d46065a | ||
|
|
35b3ff2c8e | ||
|
|
d7d4cc4849 | ||
|
|
1459046985 | ||
|
|
84c3c9ce0a | ||
|
|
8271659fda | ||
|
|
9916e1ac66 | ||
|
|
ccec96ac5f | ||
|
|
f5ea116763 | ||
|
|
99ca260855 | ||
|
|
bfc6b9e577 | ||
|
|
bc3c922177 | ||
|
|
0e20d2388e | ||
|
|
e0ddc0de27 | ||
|
|
cf840588c7 | ||
|
|
e1c2e9373c | ||
|
|
641a11e4a0 | ||
|
|
59b13778e2 | ||
|
|
7d8c64d35b | ||
|
|
a022ad5c7a | ||
|
|
0f31f1a3c2 | ||
|
|
1ee71c1f20 | ||
|
|
0dc424031b |
@@ -72,8 +72,8 @@ NEO4J_APOC_IMPORT_FILE_ENABLED=false
|
||||
NEO4J_APOC_IMPORT_FILE_USE_NEO4J_CONFIG=true
|
||||
NEO4J_APOC_TRIGGER_ENABLED=false
|
||||
NEO4J_DBMS_CONNECTOR_BOLT_LISTEN_ADDRESS=0.0.0.0:7687
|
||||
# Neo4j Prowler settings
|
||||
ATTACK_PATHS_BATCH_SIZE=1000
|
||||
# Attack Paths graph settings
|
||||
ATTACK_PATHS_GRAPH_MUTATION_BATCH_SIZE=1000
|
||||
ATTACK_PATHS_SERVICE_UNAVAILABLE_MAX_RETRIES=3
|
||||
ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS=30
|
||||
ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES=250
|
||||
@@ -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.37.2
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -54,7 +54,7 @@ runs:
|
||||
trivy-db-${{ runner.os }}-
|
||||
|
||||
- name: Run Trivy vulnerability scan (JSON)
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ inputs.image-name }}:${{ inputs.image-tag }}
|
||||
format: 'json'
|
||||
@@ -67,7 +67,7 @@ runs:
|
||||
|
||||
- name: Run Trivy vulnerability scan (SARIF)
|
||||
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ inputs.image-name }}:${{ inputs.image-tag }}
|
||||
format: 'sarif'
|
||||
|
||||
@@ -51,7 +51,8 @@ STDERR="$(mktemp)"
|
||||
trap 'rm -f "${STDERR}"' EXIT
|
||||
|
||||
set +e
|
||||
OUTPUT="$(osv-scanner scan source "${SCAN_ARGS[@]}" --format=json "$@" 2>"${STDERR}")"
|
||||
# ${a[@]+...} guard: an empty array trips `set -u` on bash before 4.4.
|
||||
OUTPUT="$(osv-scanner scan source ${SCAN_ARGS[@]+"${SCAN_ARGS[@]}"} --format=json "$@" 2>"${STDERR}")"
|
||||
RC=$?
|
||||
set -e
|
||||
|
||||
@@ -100,6 +101,8 @@ FINDINGS="$(printf '%s' "${OUTPUT}" | jq --argjson sevs "${SEVERITY_JSON}" '
|
||||
]
|
||||
')"
|
||||
|
||||
# jq exits 0 with no output on empty stdin, but non-zero on malformed JSON.
|
||||
# Let the failure abort under set -e rather than reporting zero findings.
|
||||
COUNT="$(printf '%s' "${FINDINGS}" | jq 'length')"
|
||||
|
||||
# Write the findings JSON to OSV_REPORT_FILE so callers (e.g. the composite
|
||||
@@ -108,7 +111,7 @@ if [ -n "${OSV_REPORT_FILE:-}" ]; then
|
||||
printf '%s' "${FINDINGS}" > "${OSV_REPORT_FILE}"
|
||||
fi
|
||||
|
||||
if [ "${COUNT}" -gt 0 ]; then
|
||||
if [ "${COUNT:-0}" -gt 0 ]; then
|
||||
echo "osv-scanner: ${COUNT} finding(s) at severity ${SEVERITY_LEVELS}"
|
||||
printf '%s' "${FINDINGS}" | jq -r '
|
||||
.[] | " [\(.severity)\(if .score then " \(.score)" else "" end)] \(.id) \(.ecosystem)/\(.package)@\(.version) — \(.summary // "(no summary)")"
|
||||
|
||||
@@ -249,6 +249,7 @@ modules:
|
||||
- ui/tests/profile/**
|
||||
- ui/tests/lighthouse/**
|
||||
- ui/tests/home/**
|
||||
- ui/tests/navigation/**
|
||||
- ui/tests/attack-paths/**
|
||||
|
||||
- name: api-serializers
|
||||
@@ -275,6 +276,7 @@ modules:
|
||||
- ui/tests/profile/**
|
||||
- ui/tests/lighthouse/**
|
||||
- ui/tests/home/**
|
||||
- ui/tests/navigation/**
|
||||
- ui/tests/attack-paths/**
|
||||
|
||||
- name: api-filters
|
||||
@@ -432,6 +434,14 @@ modules:
|
||||
e2e:
|
||||
- ui/tests/lighthouse/**
|
||||
|
||||
- name: ui-navigation
|
||||
match:
|
||||
- ui/components/layout/**
|
||||
- ui/tests/navigation/**
|
||||
tests: []
|
||||
e2e:
|
||||
- ui/tests/navigation/**
|
||||
|
||||
- name: ui-overview
|
||||
match:
|
||||
- ui/components/overview/**
|
||||
@@ -464,6 +474,7 @@ modules:
|
||||
- ui/tests/profile/**
|
||||
- ui/tests/lighthouse/**
|
||||
- ui/tests/home/**
|
||||
- ui/tests/navigation/**
|
||||
- ui/tests/attack-paths/**
|
||||
|
||||
- name: ui-attack-paths
|
||||
|
||||
@@ -105,7 +105,9 @@ jobs:
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: api/**
|
||||
files: |
|
||||
api/**
|
||||
.github/actions/trivy-scan/**
|
||||
files_ignore: |
|
||||
api/docs/**
|
||||
api/README.md
|
||||
@@ -113,6 +115,15 @@ jobs:
|
||||
api/changelog.d/**
|
||||
api/AGENTS.md
|
||||
|
||||
# api-container-build-push.yml resolves the SDK pin to the branch tip
|
||||
# before building, so match it here and scan what ships. Push only: PRs
|
||||
# stay deterministic against the committed lock.
|
||||
- name: Refresh prowler SDK pin to current branch tip
|
||||
if: steps.check-changes.outputs.any_changed == 'true' && github.event_name == 'push'
|
||||
run: |
|
||||
pip install --no-cache-dir "uv==0.11.14"
|
||||
(cd api && uv lock --upgrade-package prowler)
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
@@ -107,6 +107,7 @@ jobs:
|
||||
files: |
|
||||
api/**
|
||||
.github/workflows/api-tests.yml
|
||||
codecov.yml
|
||||
files_ignore: |
|
||||
api/docs/**
|
||||
api/README.md
|
||||
|
||||
@@ -38,11 +38,14 @@ jobs:
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5.0.0
|
||||
|
||||
- name: Set appVersion from release tag
|
||||
- name: Set chart version and appVersion from release tag
|
||||
run: |
|
||||
RELEASE_TAG="${GITHUB_EVENT_RELEASE_TAG_NAME}"
|
||||
echo "Setting appVersion to ${RELEASE_TAG}"
|
||||
sed -i "s/^appVersion:.*/appVersion: \"${RELEASE_TAG}\"/" ${{ env.CHART_PATH }}/Chart.yaml
|
||||
# Strip any leading "v" so the chart version is valid SemVer 2.
|
||||
RELEASE_TAG="${GITHUB_EVENT_RELEASE_TAG_NAME#v}"
|
||||
echo "Setting chart version and appVersion to ${RELEASE_TAG}"
|
||||
# Publish an immutable chart version per release instead of the static
|
||||
# 0.0.1 in source, so every release is a distinct, addressable artifact.
|
||||
yq -i ".version = \"${RELEASE_TAG}\" | .appVersion = \"${RELEASE_TAG}\"" ${{ env.CHART_PATH }}/Chart.yaml
|
||||
env:
|
||||
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
|
||||
|
||||
@@ -98,7 +98,9 @@ jobs:
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: mcp_server/**
|
||||
files: |
|
||||
mcp_server/**
|
||||
.github/actions/trivy-scan/**
|
||||
files_ignore: |
|
||||
mcp_server/README.md
|
||||
mcp_server/CHANGELOG.md
|
||||
|
||||
@@ -25,6 +25,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
@@ -33,6 +34,12 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Enable release freeze
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
gh variable set RELEASE_FREEZE --body true --repo "${GITHUB_REPOSITORY}"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -382,3 +389,4 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
rm -f prowler_changelog.md api_changelog.md ui_changelog.md mcp_changelog.md combined_changelog.md
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: 'Tools: Release Freeze Gate'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
merge_group:
|
||||
branches:
|
||||
- 'master'
|
||||
types:
|
||||
- checks_requested
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
release-freeze-gate:
|
||||
name: release-freeze-gate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check release freeze status
|
||||
env:
|
||||
RELEASE_FREEZE: ${{ vars.RELEASE_FREEZE }}
|
||||
run: |
|
||||
case "${RELEASE_FREEZE}" in
|
||||
true|TRUE|True)
|
||||
echo "::error::Release freeze is active. Merges to master are temporarily blocked."
|
||||
echo "Set the RELEASE_FREEZE repository variable to false when the release is complete."
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Release freeze is not active."
|
||||
;;
|
||||
esac
|
||||
@@ -113,6 +113,7 @@ jobs:
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
.github/workflows/sdk-container-checks.yml
|
||||
.github/actions/trivy-scan/**
|
||||
files_ignore: |
|
||||
prowler/CHANGELOG.md
|
||||
prowler/changelog.d/**
|
||||
|
||||
@@ -99,7 +99,9 @@ jobs:
|
||||
id: check-changes
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: ui/**
|
||||
files: |
|
||||
ui/**
|
||||
.github/actions/trivy-scan/**
|
||||
files_ignore: |
|
||||
ui/CHANGELOG.md
|
||||
ui/changelog.d/**
|
||||
|
||||
@@ -36,6 +36,7 @@ jobs:
|
||||
needs: impact-analysis
|
||||
if: |
|
||||
github.repository == 'prowler-cloud/prowler' &&
|
||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) &&
|
||||
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -118,6 +119,104 @@ jobs:
|
||||
env:
|
||||
NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES: ${{ needs.impact-analysis.outputs.modules }}
|
||||
|
||||
- name: Validate E2E prerequisites
|
||||
shell: bash
|
||||
run: |
|
||||
declare -A required=()
|
||||
|
||||
suite_selected() {
|
||||
[[ "${RUN_ALL_TESTS}" == "true" ]] ||
|
||||
[[ " ${E2E_TEST_PATHS} " == *"ui/tests/$1/"* ]]
|
||||
}
|
||||
|
||||
require_vars() {
|
||||
local variable
|
||||
for variable in "$@"; do
|
||||
required["${variable}"]=1
|
||||
done
|
||||
}
|
||||
|
||||
if suite_selected auth || suite_selected providers ||
|
||||
suite_selected invitations || suite_selected scans ||
|
||||
suite_selected navigation; then
|
||||
require_vars E2E_ADMIN_USER E2E_ADMIN_PASSWORD
|
||||
fi
|
||||
|
||||
if suite_selected sign-up; then
|
||||
require_vars E2E_NEW_USER_PASSWORD
|
||||
fi
|
||||
|
||||
if suite_selected invitations; then
|
||||
require_vars E2E_NEW_USER_PASSWORD E2E_ORGANIZATION_ID
|
||||
fi
|
||||
|
||||
if suite_selected scans; then
|
||||
require_vars \
|
||||
E2E_AWS_PROVIDER_ACCOUNT_ID \
|
||||
E2E_AWS_PROVIDER_ACCESS_KEY \
|
||||
E2E_AWS_PROVIDER_SECRET_KEY
|
||||
fi
|
||||
|
||||
if suite_selected providers; then
|
||||
require_vars \
|
||||
E2E_AWS_PROVIDER_ACCOUNT_ID \
|
||||
E2E_AWS_PROVIDER_ACCESS_KEY \
|
||||
E2E_AWS_PROVIDER_SECRET_KEY \
|
||||
E2E_AWS_PROVIDER_ROLE_ARN \
|
||||
E2E_AZURE_SUBSCRIPTION_ID \
|
||||
E2E_AZURE_CLIENT_ID \
|
||||
E2E_AZURE_SECRET_ID \
|
||||
E2E_AZURE_TENANT_ID \
|
||||
E2E_M365_DOMAIN_ID \
|
||||
E2E_M365_CLIENT_ID \
|
||||
E2E_M365_SECRET_ID \
|
||||
E2E_M365_TENANT_ID \
|
||||
E2E_M365_CERTIFICATE_CONTENT \
|
||||
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY \
|
||||
E2E_GCP_PROJECT_ID \
|
||||
E2E_GITHUB_APP_ID \
|
||||
E2E_GITHUB_BASE64_APP_PRIVATE_KEY \
|
||||
E2E_GITHUB_USERNAME \
|
||||
E2E_GITHUB_PERSONAL_ACCESS_TOKEN \
|
||||
E2E_GITHUB_ORGANIZATION \
|
||||
E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN \
|
||||
E2E_OCI_TENANCY_ID \
|
||||
E2E_OCI_USER_ID \
|
||||
E2E_OCI_FINGERPRINT \
|
||||
E2E_OCI_KEY_CONTENT \
|
||||
E2E_ALIBABACLOUD_ACCOUNT_ID \
|
||||
E2E_ALIBABACLOUD_ACCESS_KEY_ID \
|
||||
E2E_ALIBABACLOUD_ACCESS_KEY_SECRET \
|
||||
E2E_ALIBABACLOUD_ROLE_ARN \
|
||||
E2E_OKTA_DOMAIN \
|
||||
E2E_OKTA_CLIENT_ID \
|
||||
E2E_OKTA_BASE64_PRIVATE_KEY \
|
||||
E2E_GOOGLEWORKSPACE_CUSTOMER_ID \
|
||||
E2E_GOOGLEWORKSPACE_SERVICE_ACCOUNT_JSON \
|
||||
E2E_GOOGLEWORKSPACE_DELEGATED_USER \
|
||||
E2E_VERCEL_TEAM_ID \
|
||||
E2E_VERCEL_API_TOKEN
|
||||
fi
|
||||
|
||||
missing=()
|
||||
if (( ${#required[@]} > 0 )); then
|
||||
while IFS= read -r variable; do
|
||||
[[ -z "${!variable:-}" ]] && missing+=("${variable}")
|
||||
done < <(printf '%s\n' "${!required[@]}" | sort)
|
||||
fi
|
||||
|
||||
if (( ${#missing[@]} > 0 )); then
|
||||
echo "Missing required E2E variables:"
|
||||
printf ' - %s\n' "${missing[@]}"
|
||||
{
|
||||
echo "## Missing E2E prerequisites"
|
||||
printf -- "- \`%s\`\n" "${missing[@]}"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "E2E prerequisite preflight passed."
|
||||
|
||||
- name: Create k8s Kind Cluster
|
||||
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # v1
|
||||
with:
|
||||
@@ -304,6 +403,29 @@ jobs:
|
||||
run: |
|
||||
docker compose down -v || true
|
||||
|
||||
# Fork pull requests cannot access the secrets required by the E2E suites.
|
||||
fork-e2e-unavailable:
|
||||
needs: impact-analysis
|
||||
if: |
|
||||
github.repository == 'prowler-cloud/prowler' &&
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.fork == true &&
|
||||
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Report unavailable E2E tests
|
||||
run: |
|
||||
echo "## E2E Tests Skipped" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "UI E2E tests require repository secrets and cannot run for fork pull requests." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Skip job - provides clear feedback when no E2E tests needed
|
||||
skip-e2e:
|
||||
needs: impact-analysis
|
||||
|
||||
@@ -181,9 +181,9 @@ jobs:
|
||||
if: steps.check-changes.outputs.any_changed == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
- name: Run browser tests
|
||||
- name: Run integration tests
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: pnpm run test:browser
|
||||
run: pnpm run test:integration
|
||||
|
||||
- name: Build application
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -173,3 +173,5 @@ GEMINI.md
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
docker-compose-dev.override.yml
|
||||
# Local Pi runtime state
|
||||
.atl/
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# Findings excluded from the Grype gate, each with a reason.
|
||||
# Anything not listed here blocks the pull request at critical or high severity.
|
||||
# Pairs are explicit: a new CVE against an already-listed package still blocks.
|
||||
#
|
||||
# Every entry below has a published fix we cannot take. Findings with no fix at all are
|
||||
# not listed: the scan runs with only-fixed, so they never reach the gate.
|
||||
|
||||
ignore:
|
||||
|
||||
# Modules compiled into the Trivy binary we ship.
|
||||
# Only a Trivy rebuild by its vendor can change these; the version is pinned in our Dockerfile.
|
||||
- vulnerability: CVE-2026-56852
|
||||
package:
|
||||
name: golang.org/x/text
|
||||
- vulnerability: GHSA-hrxh-6v49-42gf
|
||||
package:
|
||||
name: google.golang.org/grpc
|
||||
- vulnerability: CVE-2026-50151
|
||||
package:
|
||||
name: oras.land/oras-go/v2
|
||||
|
||||
# Shipped inside the PowerShell tarball, in its bundled MicrosoftTeams module.
|
||||
# Not a dependency we declare, and not one we can upgrade independently.
|
||||
- vulnerability: CVE-2026-26127
|
||||
package:
|
||||
name: Microsoft.Bcl.Memory
|
||||
|
||||
|
||||
# The CPython interpreter, compiled into the official base image.
|
||||
# TEMPORARY, unlike the entries above: moving to Python 3.13 clears seven of these, and
|
||||
# that is a runtime upgrade pending its own evaluation. The remaining three need 3.15 and
|
||||
# are unfixable either way -- the MCP image already runs 3.13.14 and still reports them.
|
||||
- vulnerability: CVE-2026-11940
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-11972
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-15308
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-3298
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-3644
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-4224
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-4786
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-6100
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-7210
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-9669
|
||||
package:
|
||||
name: python
|
||||
+50
-101
@@ -1,109 +1,58 @@
|
||||
# Trivy ignore file for prowlercloud/prowler SDK container image.
|
||||
# Trivy ignore file for the prowlercloud/prowler SDK and API container images.
|
||||
# Each entry below documents (a) the affected package and why it ships in the
|
||||
# image, (b) why the CVE is not exploitable in Prowler's runtime, and (c) the
|
||||
# upstream fix status. Entries carry an expiry so they auto-force re-review.
|
||||
# Entries are scoped per-package so suppressions cannot drift onto unrelated
|
||||
# packages that may be assigned the same CVE in the future.
|
||||
# The `pkg:` selector on each line is documentation only. Trivy's classic
|
||||
# .trivyignore format parses the CVE ID and ignores the rest, so each entry
|
||||
# suppresses its CVE across every package in the image, not just the one named.
|
||||
# Verified against Trivy 0.65.0: an entry written `pkg:zlib1g` still suppressed
|
||||
# the finding on perl-base. Real per-package scoping needs .trivyignore.yaml
|
||||
# with purls — tracked in PROWLER-2327.
|
||||
# `exp:` IS honoured: an entry dated in the past correctly lapses.
|
||||
#
|
||||
# Keep expiries staggered, and only suppress packages the images actually install.
|
||||
#
|
||||
# Scanned by: .github/actions/trivy-scan via .github/workflows/sdk-container-checks.yml
|
||||
# and .github/workflows/api-container-checks.yml
|
||||
|
||||
# CVE-2026-42496 — perl-archive-tar path traversal via crafted symlinks.
|
||||
# CVE-2026-8376 — perl heap buffer overflow when compiling regex.
|
||||
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
|
||||
# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
|
||||
# removed without breaking dpkg. The Prowler SDK does not invoke perl at runtime;
|
||||
# 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
|
||||
# perl-base is Debian "Essential: yes". Trivy spreads src:perl CVEs across every
|
||||
# binary package, so perl-base gets flagged for modules only perl-modules-5.40 ships.
|
||||
# Neither image installs perl-modules-5.40, and nothing in either invokes perl.
|
||||
#
|
||||
# Why these four cannot be fixed rather than accepted (reviewed 2026-07-31):
|
||||
#
|
||||
# 1. No fix exists. All four report no fixed version on perl-base 5.40.1-6.
|
||||
# Debian's tracker marks CVE-2026-42496 "fix_deferred" and the other three
|
||||
# "affected". Updating the base image, apt upgrade, or moving to a newer
|
||||
# Debian release changes nothing, because Debian has not shipped a fix.
|
||||
# 2. The package cannot be removed. "Essential: yes" means removal needs
|
||||
# dpkg --force-remove-essential, which also breaks apt for anything built
|
||||
# downstream from these images.
|
||||
# 3. Changing base distribution was evaluated and rejected. Alpine removes
|
||||
# perl entirely, but PowerShell publishes no linux-musl-arm64 build in any
|
||||
# release, so M365 scanning would break on arm64 — which is what we run in
|
||||
# production. Wolfi keeps glibc and drops perl, but pinnable versioned tags
|
||||
# are a paid tier, so builds would not be reproducibly pinnable.
|
||||
#
|
||||
# Not-invoked claim verified by sweeping both images for: files with a perl
|
||||
# shebang, shell/python callers of perl, ELF binaries containing "perl", and
|
||||
# .pl/.pm files or perl subprocess calls anywhere in site-packages. The only
|
||||
# consumers found are dpkg/debconf/adduser/pam tooling, none of which runs at
|
||||
# runtime, plus one build-time script inside the ExchangeOnlineManagement
|
||||
# PowerShell module that is never invoked (it generates that module's manifest,
|
||||
# and lives in its netFramework/ path, while Linux loads netCore/).
|
||||
|
||||
# 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.
|
||||
# Archive::Tar path traversal. Not installed: `perl -MArchive::Tar -e1` cannot locate it.
|
||||
CVE-2026-42496 pkg:perl-base exp:2027-01-31
|
||||
|
||||
# Storable integer overflow. Not installed: `perl -MStorable -e1` cannot locate it.
|
||||
CVE-2026-57433 pkg:perl-base exp:2027-01-31
|
||||
|
||||
# Regex heap overflow on 32-bit builds only; both published arches are 64-bit.
|
||||
CVE-2026-8376 pkg:perl-base exp:2027-01-31
|
||||
|
||||
# Regex trie bug giving silently wrong matches above 65535 alternation branches. Now on
|
||||
# perl 5.40.1, which is in range (the 5.36-predates-it argument no longer applies), so this
|
||||
# rests on nothing invoking perl. Short expiry to force a re-look.
|
||||
# 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-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-2026-43185 — Linux kernel ksmbd signedness bug.
|
||||
# Package: linux-libc-dev.
|
||||
# Why ignored: linux-libc-dev ships kernel headers for build-time compilation,
|
||||
# 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-2023-45853 — zlib MiniZip integer overflow / heap overflow in
|
||||
# zipOpenNewFileInZip4_64.
|
||||
# Packages: zlib1g, zlib1g-dev.
|
||||
# Why ignored: Debian Security Tracker status for bookworm is <ignored>, with
|
||||
# the published rationale "contrib/minizip not built and src:zlib not producing
|
||||
# binary packages" — i.e. the vulnerable symbol is not present in the libz.so
|
||||
# shipped by Debian. Real-not-affected, not unpatched. Upstream fix is in
|
||||
# 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-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).
|
||||
# Package: libssh2-1.
|
||||
# Why ignored: libssh2-1 is pulled in only as a transitive dependency of libcurl4
|
||||
# (installed in the SDK Dockerfile for the networking/PowerShell stack). The
|
||||
# vulnerable path is reached exclusively when libssh2 acts as an SSH/SCP/SFTP
|
||||
# client parsing transport packets from a server. Prowler never uses libcurl's
|
||||
# SSH/SCP/SFTP transports; it talks to cloud provider HTTPS endpoints only, so the
|
||||
# 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
|
||||
|
||||
# --- API container image (api/Dockerfile) ---
|
||||
# The entries below are specific to the Prowler API image, which ships
|
||||
# PowerShell and additional build tooling on top of the same bookworm base.
|
||||
|
||||
# CVE-2026-7210 — CPython/Expat hash-flooding denial of service in
|
||||
# `xml.parsers.expat` and `xml.etree.ElementTree`.
|
||||
# Packages: the Debian system Python 3.11 (python3.11*, libpython3.11*).
|
||||
# Why ignored: the API runs under the Python 3.12 interpreter shipped in its
|
||||
# `.venv`; the system `python3.11` is only present because `python3-dev` is
|
||||
# pulled in to compile native extensions (xmlsec, lxml) and is never executed
|
||||
# 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-33278 — Unbound DNSSEC validator use-after-free (DoS, possible RCE).
|
||||
# CVE-2026-42960 — Unbound DNS cache poisoning via promiscuous additional records.
|
||||
# Package: libunbound8.
|
||||
# Why ignored: libunbound8 is a transitive apt dependency of the TLS/networking
|
||||
# stack (GnuTLS DANE support); only the shared library ships in the image. Both
|
||||
# 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-13221 pkg:perl-base exp:2026-11-30
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# Trivy suppressions for the prowlercloud/prowler SDK and API container images.
|
||||
#
|
||||
# This file replaces the classic .trivyignore, which parsed only the CVE id: the
|
||||
# `pkg:` selector written on each line was documentation and the entry suppressed
|
||||
# its CVE across every package in the image. The `purls` field below is honoured,
|
||||
# so each entry is scoped to the package it names. Verified against Trivy 0.71.2:
|
||||
# an entry given the wrong purl leaves the finding reported, where the classic
|
||||
# format suppressed it.
|
||||
#
|
||||
# `expired_at` forces re-review. Keep the dates staggered.
|
||||
#
|
||||
# The four entries below are currently redundant: the scan runs with ignore-unfixed,
|
||||
# and none of them has a published fix, so they never reach the gate either way. They
|
||||
# are kept because the reasoning is what justifies accepting them, and because they
|
||||
# apply again the moment any of them gains a fix we do not take.
|
||||
#
|
||||
# perl-base is Debian "Essential: yes". Trivy spreads src:perl CVEs across every
|
||||
# binary package built from that source, so perl-base is flagged for modules only
|
||||
# perl-modules-* ships. Neither image installs those, and nothing in either
|
||||
# invokes perl.
|
||||
#
|
||||
# Why these four are accepted rather than fixed (reviewed 2026-07-31):
|
||||
#
|
||||
# 1. No fix exists. All four report no fixed version on perl-base 5.40.1-6.
|
||||
# Debian marks CVE-2026-42496 "fix_deferred" and the other three "affected".
|
||||
# A newer base image, apt upgrade, or a newer Debian release changes nothing.
|
||||
# 2. The package cannot be removed. "Essential: yes" means removal needs
|
||||
# dpkg --force-remove-essential, which breaks apt for anything built
|
||||
# downstream from these images.
|
||||
# 3. Changing base distribution was evaluated and rejected. Alpine drops perl
|
||||
# entirely, but PowerShell publishes no linux-musl-arm64 build in any
|
||||
# release, so M365 scanning would break on arm64 -- which is what we run in
|
||||
# production. Wolfi keeps glibc and drops perl, but pinnable versioned tags
|
||||
# are a paid tier, so builds would not be reproducibly pinnable.
|
||||
#
|
||||
# Not-invoked claim verified by sweeping both images for files with a perl
|
||||
# shebang, shell/python callers of perl, ELF binaries containing "perl", and
|
||||
# .pl/.pm files or perl subprocess calls anywhere in site-packages. The only
|
||||
# consumers found are dpkg/debconf/adduser/pam tooling, none of which runs at
|
||||
# runtime, plus one build-time script inside the ExchangeOnlineManagement
|
||||
# PowerShell module that is never invoked.
|
||||
|
||||
vulnerabilities:
|
||||
# Archive::Tar path traversal. Not installed: `perl -MArchive::Tar -e1` cannot locate it.
|
||||
- id: CVE-2026-42496
|
||||
purls:
|
||||
- "pkg:deb/debian/perl-base"
|
||||
expired_at: 2027-01-31
|
||||
|
||||
# Storable integer overflow. Not installed: `perl -MStorable -e1` cannot locate it.
|
||||
- id: CVE-2026-57433
|
||||
purls:
|
||||
- "pkg:deb/debian/perl-base"
|
||||
expired_at: 2027-01-31
|
||||
|
||||
# Regex heap overflow on 32-bit builds only; both published arches are 64-bit.
|
||||
- id: CVE-2026-8376
|
||||
purls:
|
||||
- "pkg:deb/debian/perl-base"
|
||||
expired_at: 2027-01-31
|
||||
|
||||
# Regex trie bug giving silently wrong matches above 65535 alternation branches.
|
||||
# perl 5.40.1 is in range, so this rests on nothing invoking perl. Short expiry
|
||||
# to force a re-look. Ref: https://github.com/Perl/perl5/issues/23388
|
||||
- id: CVE-2026-13221
|
||||
purls:
|
||||
- "pkg:deb/debian/perl-base"
|
||||
expired_at: 2026-11-30
|
||||
|
||||
# Declared in the SPDX manifest that ships inside PowerShell's MicrosoftTeams module
|
||||
# (Modules/MicrosoftTeams/7.9.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that
|
||||
# SBOM and reports what it declares, which is not the same as what the image contains:
|
||||
# there is no Node runtime and no node_modules anywhere in the image, and the .NET
|
||||
# assemblies target net472, a Windows-only framework. Nothing here is reachable, and none
|
||||
# of it is a dependency we declare -- only Microsoft can change the module's contents.
|
||||
- id: CVE-2020-0606
|
||||
purls:
|
||||
- "pkg:nuget/Microsoft.WindowsDesktop.App.Ref"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2019-0820
|
||||
purls:
|
||||
- "pkg:nuget/System.Text.RegularExpressions"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-47302
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-47304
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-50525
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-50527
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-50648
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-13676
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-16221
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-18446
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-69192
|
||||
purls:
|
||||
- "pkg:npm/ip-address"
|
||||
expired_at: 2027-01-31
|
||||
|
||||
# Modules compiled into the Trivy binary the images ship. The binary is pinned by version
|
||||
# and verified by checksum in the Dockerfile; only a rebuild by its vendor moves these.
|
||||
- id: CVE-2026-56852
|
||||
purls:
|
||||
- "pkg:golang/golang.org/x/text"
|
||||
expired_at: 2026-12-31
|
||||
- id: GHSA-hrxh-6v49-42gf
|
||||
purls:
|
||||
- "pkg:golang/google.golang.org/grpc"
|
||||
expired_at: 2026-12-31
|
||||
- id: CVE-2026-50151
|
||||
purls:
|
||||
- "pkg:golang/oras.land/oras-go/v2"
|
||||
expired_at: 2026-12-31
|
||||
- id: CVE-2026-50163
|
||||
purls:
|
||||
- "pkg:golang/oras.land/oras-go/v2"
|
||||
expired_at: 2026-12-31
|
||||
- id: CVE-2026-39822
|
||||
purls:
|
||||
- "pkg:golang/stdlib"
|
||||
expired_at: 2026-12-31
|
||||
|
||||
@@ -62,6 +62,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Action | Skill |
|
||||
|--------|-------|
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| Adding ConfigRequirements guardrails to compliance requirements | `prowler-compliance` |
|
||||
| Adding DRF pagination or permissions | `django-drf` |
|
||||
| Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` |
|
||||
| Adding indexes or constraints to database tables | `django-migration-psql` |
|
||||
@@ -84,6 +85,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
|
||||
| Creating Zod schemas | `zod-4` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating a universal (multi-provider) compliance framework | `prowler-compliance` |
|
||||
| Creating new checks | `prowler-sdk-check` |
|
||||
| Creating new skills | `skill-creator` |
|
||||
| Creating or reviewing Django migrations | `django-migration-psql` |
|
||||
|
||||
+18
-5
@@ -1,14 +1,14 @@
|
||||
FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build
|
||||
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/prowler"
|
||||
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
|
||||
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
ARG POWERSHELL_VERSION=7.5.9
|
||||
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
|
||||
ARG TRIVY_VERSION=0.72.0
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
ARG ZIZMOR_VERSION=1.24.1
|
||||
@@ -16,7 +16,7 @@ ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
|
||||
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget libicu72 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
|
||||
wget libicu76 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
|
||||
build-essential pkg-config libzstd-dev zlib1g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -89,7 +89,7 @@ ENV HOME='/home/prowler'
|
||||
ENV PATH="${HOME}/.local/bin:${PATH}"
|
||||
#hadolint ignore=DL3013
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir uv==0.11.14
|
||||
pip install --no-cache-dir uv==0.12.0
|
||||
|
||||
RUN uv sync --locked --compile-bytecode && \
|
||||
rm -rf ~/.cache/uv
|
||||
@@ -105,6 +105,9 @@ RUN apt-get purge -y --auto-remove \
|
||||
pkg-config \
|
||||
libzstd-dev \
|
||||
zlib1g-dev \
|
||||
wget \
|
||||
gnupg \
|
||||
apt-transport-https \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER prowler
|
||||
@@ -113,5 +116,15 @@ USER prowler
|
||||
RUN pip uninstall dash-html-components -y && \
|
||||
pip uninstall dash-core-components -y
|
||||
|
||||
USER root
|
||||
|
||||
# pip is build-only; the entrypoint runs the venv directly.
|
||||
RUN rm -rf /usr/local/lib/python3.12/site-packages/pip \
|
||||
/usr/local/lib/python3.12/site-packages/pip-*.dist-info \
|
||||
/home/prowler/.local/lib/python3.12/site-packages/pip \
|
||||
/home/prowler/.local/lib/python3.12/site-packages/pip-*.dist-info \
|
||||
/usr/local/bin/pip /usr/local/bin/pip3 /usr/local/bin/pip3.12 \
|
||||
/home/prowler/.local/bin/pip /home/prowler/.local/bin/pip3 /home/prowler/.local/bin/pip3.12
|
||||
|
||||
USER prowler
|
||||
ENTRYPOINT ["/home/prowler/.venv/bin/prowler"]
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
<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.
|
||||
</p>
|
||||
<p align="center">
|
||||
<b>Secure ANY cloud at AI Speed at <a href="https://prowler.com">prowler.com</i></b>
|
||||
<b>The Agentic Cloud Defender</i></b>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://cloud.prowler.com/sign-up">Try Prowler Cloud</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -56,7 +59,7 @@ Prowler includes hundreds of built-in controls to ensure compliance with standar
|
||||
|
||||
## Prowler Cloud & Prowler Local Server
|
||||
|
||||
[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 Cloud](https://cloud.prowler.com/sign-up) 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.
|
||||
|
||||

|
||||

|
||||
@@ -123,24 +126,25 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
|
||||
|
||||
| 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 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| AWS | 615 | 86 | 47 | 19 | Official | UI, API, CLI |
|
||||
| Azure | 190 | 22 | 21 | 16 | Official | UI, API, CLI |
|
||||
| AWS | 621 | 86 | 47 | 19 | Official | UI, API, CLI |
|
||||
| Azure | 191 | 22 | 21 | 16 | Official | UI, API, CLI |
|
||||
| GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI |
|
||||
| Kubernetes | 90 | 7 | 8 | 11 | Official | UI, API, CLI |
|
||||
| Kubernetes | 92 | 7 | 8 | 11 | Official | UI, API, CLI |
|
||||
| GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI |
|
||||
| M365 | 109 | 10 | 6 | 10 | Official | UI, API, CLI |
|
||||
| M365 | 111 | 10 | 6 | 10 | Official | UI, API, CLI |
|
||||
| OCI | 52 | 14 | 5 | 10 | Official | UI, API, CLI |
|
||||
| Alibaba Cloud | 63 | 9 | 6 | 9 | Official | UI, API, CLI |
|
||||
| Cloudflare | 29 | 3 | 2 | 5 | Official | UI, API, CLI |
|
||||
| IaC | [See `trivy` docs.](https://trivy.dev/latest/docs/coverage/iac/) | N/A | N/A | N/A | Official | UI, API, CLI |
|
||||
| MongoDB Atlas | 10 | 3 | 1 | 8 | Official | UI, API, CLI |
|
||||
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
|
||||
| Image | N/A | N/A | N/A | N/A | Official | CLI, API |
|
||||
| Image | N/A | N/A | N/A | N/A | Official | UI, API, CLI |
|
||||
| Google Workspace | 65 | 11 | 3 | 6 | Official | UI, API, CLI |
|
||||
| OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI |
|
||||
| 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 |
|
||||
| Huawei Cloud [Contact us](https://prowler.com/contact) | 25 | 10 | 1 | 6 | 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 |
|
||||
|
||||
@@ -4,6 +4,83 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [1.38.1] (Prowler v5.37.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Entra Conditional Access guest-user checks no longer report false FAILs in M365 scans: microsoft-kiota packages overridden to 1.9.10 so `guestOrExternalUserTypes` (a flags enum Graph serializes as a comma-separated string) deserializes correctly instead of returning an empty list [(#12315)](https://github.com/prowler-cloud/prowler/pull/12315)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- The API container image now builds on Debian 13 (trixie), taking its critical CVE count from 18 to 4 [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Bumped PowerShell, Trivy and uv in the API container image, clearing 14 high-severity CVEs [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Bumped `workos` and `pyopenssl` so the API can move to `cryptography` 48.0.1 [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Removed `gnupg` and `apt-transport-https` from the API container image [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- The API container image no longer ships `git`; removing it also dropped `perl`, `perl-modules`, `libperl` and `liberror-perl`, clearing 12 critical CVEs. Only `perl-base` remains, which Debian marks Essential and cannot be removed [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Removed `pip` from the API container image, clearing two high-severity CVEs in the vendored copies of `setuptools` and `msgpack` [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Bumped `pillow` to 12.3.0, `httplib2` to 0.32.0 and `pyasn1` to 0.6.4 to resolve known CVEs [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
|
||||
---
|
||||
|
||||
## [1.38.0] (Prowler v5.37.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Attack Paths: four AWS privilege-escalation detection queries from pathfinding.cloud: cross-account role trust (STS-002), wildcard role trust (STS-003), user permissions-boundary removal (IAM-022), and IAM Identity Center permission-set escalation (SSO-001) [(#11460)](https://github.com/prowler-cloud/prowler/pull/11460)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Attack Paths IAM privilege-escalation queries no longer build an all-nodes × all-resource-items cartesian product, fixing runtime errors and timeouts on accounts with many IAM roles, users, or groups [(#12136)](https://github.com/prowler-cloud/prowler/pull/12136)
|
||||
- `task_args` serialization no longer returns HTTP 500 errors when Celery truncates stored task keyword arguments [(#12165)](https://github.com/prowler-cloud/prowler/pull/12165)
|
||||
- Attack Paths predefined queries on migrated graphs are now scoped with the provider label, letting the graph database seed from its label index instead of a global label scan and preventing query timeouts on Neptune [(#12167)](https://github.com/prowler-cloud/prowler/pull/12167)
|
||||
- Authentication with an API key whose owning user was deleted now returns `401` instead of an unhandled `AttributeError`, and user deletion now revokes the user's API keys across all their tenants [(#12210)](https://github.com/prowler-cloud/prowler/pull/12210)
|
||||
- AWS Security Hub integrations now persist successful connection checks during finding delivery so their connection status and last checked timestamp stay current [(#12212)](https://github.com/prowler-cloud/prowler/pull/12212)
|
||||
- SAML users without a `userType` attribute and without an existing role in the SAML tenant now receive a least-privilege `read_only` fallback role; a numeric suffix is used when that name belongs to a role with different permissions [(#12223)](https://github.com/prowler-cloud/prowler/pull/12223)
|
||||
- Social signups create users and authentication records in one database transaction, preventing incomplete accounts when provisioning fails [(#12245)](https://github.com/prowler-cloud/prowler/pull/12245)
|
||||
- Requesting integrations with a sparse fieldset that leaves out `configuration` no longer returns HTTP 500 errors when the tenant has a Jira integration [(#12261)](https://github.com/prowler-cloud/prowler/pull/12261)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Provider deletion and connection checks, scan creation, provider secrets, provider groups, and daily schedules now respect role provider-group visibility [(#12216)](https://github.com/prowler-cloud/prowler/pull/12216)
|
||||
|
||||
---
|
||||
|
||||
## [1.37.0] (Prowler v5.36.0)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- OCI provider secrets no longer require `region`; legacy `region` input is accepted for backwards compatibility but ignored before storing or scanning [(#11741)](https://github.com/prowler-cloud/prowler/pull/11741)
|
||||
- Compliance overview ingest now runs in a single transaction per scan with a configurable `COPY` batch size (`DJANGO_COMPLIANCE_COPY_BATCH_SIZE`, default 2000), reducing write pressure on the database [(#11875)](https://github.com/prowler-cloud/prowler/pull/11875)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Scan findings now recover resources missing from the in-memory cache after resource pre-resolution, preventing valid findings from being skipped [(#12002)](https://github.com/prowler-cloud/prowler/pull/12002)
|
||||
- Tenant-wide integrations that are not attached to any provider, such as Jira, are now visible and manageable by roles with `manage_integrations` and without unlimited visibility [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Output generation now removes the scan's temporary output directory before writing, so a re-run of the task for the same scan (e.g. broker redelivery after a worker is killed mid-run) no longer appends to the previous run's files and duplicates finding rows in the exported CSV and other outputs [(#12097)](https://github.com/prowler-cloud/prowler/pull/12097)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Integration responses no longer disclose providers outside the visibility of the role, including the resources sideloaded through `?include=providers` [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Integration connection checks, Jira issue type lookups and Jira dispatches now resolve the integration through the provider visibility of the role instead of the whole tenant [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Roles without unlimited visibility can no longer attach an integration to providers they cannot see, nor edit or delete an integration bound to them [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Kubernetes kubeconfig validation now rejects legacy `auth-provider.config.cmd-path` command authentication in Prowler Cloud/API [(#12091)](https://github.com/prowler-cloud/prowler/pull/12091)
|
||||
|
||||
---
|
||||
|
||||
## [1.36.0] (Prowler v5.35.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `attack-paths-scan-perform` Celery tasks now use the configurable long-task time limits instead of the six-hour defaults [(#12009)](https://github.com/prowler-cloud/prowler/pull/12009)
|
||||
- Attack Paths scans handle provider deletion races cleanly, detect stale tasks after 16 hours, use backend-specific graph synchronization batches, and report exhausted Neptune write retries with the original database error [(#12019)](https://github.com/prowler-cloud/prowler/pull/12019)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Jira integration credentials only accept bare Atlassian site names containing letters, numbers, and hyphens [(#12012)](https://github.com/prowler-cloud/prowler/pull/12012)
|
||||
- Social account linking requires a verified matching email from both the identity provider and the existing user account without sending account connection notifications [(#12013)](https://github.com/prowler-cloud/prowler/pull/12013)
|
||||
|
||||
---
|
||||
|
||||
## [1.35.0] (Prowler v5.34.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
+23
-7
@@ -1,13 +1,13 @@
|
||||
FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build
|
||||
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/api"
|
||||
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
ARG POWERSHELL_VERSION=7.5.9
|
||||
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
|
||||
ARG TRIVY_VERSION=0.72.0
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
ARG ZIZMOR_VERSION=1.24.1
|
||||
@@ -17,7 +17,7 @@ ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
git \
|
||||
libicu72 \
|
||||
libicu76 \
|
||||
gcc \
|
||||
g++ \
|
||||
make \
|
||||
@@ -28,7 +28,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libtool \
|
||||
libxslt1-dev \
|
||||
python3-dev \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PowerShell
|
||||
@@ -94,7 +93,7 @@ RUN mkdir -p /tmp/prowler_api_output
|
||||
COPY --chown=prowler:prowler pyproject.toml uv.lock ./
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir uv==0.11.14
|
||||
pip install --no-cache-dir uv==0.12.0
|
||||
|
||||
ENV PATH="/home/prowler/.local/bin:$PATH"
|
||||
|
||||
@@ -102,24 +101,41 @@ ENV PATH="/home/prowler/.local/bin:$PATH"
|
||||
RUN uv sync --locked --no-install-project && \
|
||||
rm -rf ~/.cache/uv
|
||||
|
||||
RUN .venv/bin/python .venv/lib/python3.12/site-packages/prowler/providers/m365/lib/powershell/m365_powershell.py
|
||||
# Invoked as a module so the base image's Python minor version is not baked
|
||||
# into a site-packages path.
|
||||
RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell
|
||||
|
||||
USER root
|
||||
|
||||
# Remove build-only packages from the final image after Python dependencies are installed.
|
||||
# git is only needed by uv sync for the `prowler @ git+...` dependency; purging it drops perl too.
|
||||
# wget stays: the compose healthcheck shells out to it.
|
||||
RUN apt-get purge -y --auto-remove \
|
||||
gcc \
|
||||
g++ \
|
||||
git \
|
||||
make \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libxmlsec1-openssl \
|
||||
libxmlsec1t64 \
|
||||
libxmlsec1t64-openssl \
|
||||
pkg-config \
|
||||
libtool \
|
||||
libxslt1-dev \
|
||||
python3-dev \
|
||||
gnupg \
|
||||
apt-transport-https \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# pip is build-only; the entrypoint runs uv against the prepared venv. uv stays.
|
||||
RUN rm -rf /usr/local/lib/python3.12/site-packages/pip \
|
||||
/usr/local/lib/python3.12/site-packages/pip-*.dist-info \
|
||||
/home/prowler/.local/lib/python3.12/site-packages/pip \
|
||||
/home/prowler/.local/lib/python3.12/site-packages/pip-*.dist-info \
|
||||
/usr/local/bin/pip /usr/local/bin/pip3 /usr/local/bin/pip3.12 \
|
||||
/home/prowler/.local/bin/pip /home/prowler/.local/bin/pip3 /home/prowler/.local/bin/pip3.12
|
||||
|
||||
USER prowler
|
||||
|
||||
COPY --chown=prowler:prowler src/backend/ ./backend/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Upgrade cryptography to 50.0.0, closing CVE-2026-69247 and CVE-2026-69249
|
||||
@@ -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
|
||||
+38
-25
@@ -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.37",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
@@ -63,7 +63,7 @@ dependencies = [
|
||||
"werkzeug (==3.1.7)",
|
||||
"sqlparse (==0.5.5)",
|
||||
"fonttools (==4.62.1)",
|
||||
"uvicorn-worker (==0.4.0)",
|
||||
"uvicorn-worker (==0.4.0)"
|
||||
]
|
||||
description = "Prowler's API (Django/DRF)"
|
||||
license = "Apache-2.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.38.2"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
@@ -92,6 +92,8 @@ extend-select = [
|
||||
|
||||
[tool.uv]
|
||||
# Transitive pins matching master to avoid silent drift; bump deliberately.
|
||||
# workos and pyopenssl run ahead of master: the versions master pins cap cryptography
|
||||
# below 48, so both were bumped to versions that allow it (PROWLER-2310).
|
||||
constraint-dependencies = [
|
||||
"about-time==4.2.1",
|
||||
"adal==1.2.7",
|
||||
@@ -128,7 +130,7 @@ constraint-dependencies = [
|
||||
"alibabacloud-sls20201230==5.9.0",
|
||||
"alibabacloud-sts20150401==1.1.6",
|
||||
"alibabacloud-tea==0.4.3",
|
||||
"alibabacloud-tea-openapi==0.4.4",
|
||||
"alibabacloud-tea-openapi==0.4.5",
|
||||
"alibabacloud-tea-util==0.3.14",
|
||||
"alibabacloud-tea-xml==0.0.3",
|
||||
"alibabacloud-vpc20160428==6.13.0",
|
||||
@@ -210,9 +212,9 @@ constraint-dependencies = [
|
||||
"coverage==7.5.4",
|
||||
"cron-descriptor==1.4.5",
|
||||
"crowdstrike-falconpy==1.6.0",
|
||||
"cryptography==46.0.7",
|
||||
"cryptography==50.0.0",
|
||||
"cycler==0.12.1",
|
||||
"darabonba-core==1.0.5",
|
||||
"darabonba-core==1.0.8",
|
||||
"dash==3.1.1",
|
||||
"dash-bootstrap-components==2.0.3",
|
||||
"debugpy==1.8.20",
|
||||
@@ -277,7 +279,7 @@ constraint-dependencies = [
|
||||
"h2==4.3.0",
|
||||
"hpack==4.1.0",
|
||||
"httpcore==1.0.9",
|
||||
"httplib2==0.31.2",
|
||||
"httplib2==0.32.0",
|
||||
"httpx==0.28.1",
|
||||
"humanfriendly==10.0",
|
||||
"hyperframe==6.1.0",
|
||||
@@ -314,13 +316,13 @@ constraint-dependencies = [
|
||||
"matplotlib==3.10.8",
|
||||
"mccabe==0.7.0",
|
||||
"mdurl==0.1.2",
|
||||
"microsoft-kiota-abstractions==1.9.9",
|
||||
"microsoft-kiota-authentication-azure==1.9.9",
|
||||
"microsoft-kiota-http==1.9.9",
|
||||
"microsoft-kiota-serialization-form==1.9.9",
|
||||
"microsoft-kiota-serialization-json==1.9.9",
|
||||
"microsoft-kiota-serialization-multipart==1.9.9",
|
||||
"microsoft-kiota-serialization-text==1.9.9",
|
||||
"microsoft-kiota-abstractions==1.9.10",
|
||||
"microsoft-kiota-authentication-azure==1.9.10",
|
||||
"microsoft-kiota-http==1.9.10",
|
||||
"microsoft-kiota-serialization-form==1.9.10",
|
||||
"microsoft-kiota-serialization-json==1.9.10",
|
||||
"microsoft-kiota-serialization-multipart==1.9.10",
|
||||
"microsoft-kiota-serialization-text==1.9.10",
|
||||
"microsoft-security-utilities-secret-masker==1.0.0b4",
|
||||
"msal==1.35.0b1",
|
||||
"msal-extensions==1.2.0",
|
||||
@@ -337,7 +339,7 @@ constraint-dependencies = [
|
||||
"nltk==3.9.4",
|
||||
"numpy==2.2.6",
|
||||
"oauthlib==3.3.1",
|
||||
"oci==2.169.0",
|
||||
"oci==2.183.0",
|
||||
"openai==1.109.1",
|
||||
"openstacksdk==4.2.0",
|
||||
"opentelemetry-api==1.39.1",
|
||||
@@ -349,7 +351,7 @@ constraint-dependencies = [
|
||||
"pagerduty==6.1.0",
|
||||
"pandas==2.2.3",
|
||||
"pbr==7.0.3",
|
||||
"pillow==12.2.0",
|
||||
"pillow==12.3.0",
|
||||
"pkginfo==1.12.1.2",
|
||||
"platformdirs==4.5.1",
|
||||
"plotly==6.5.2",
|
||||
@@ -365,8 +367,8 @@ constraint-dependencies = [
|
||||
"psycopg2-binary==2.9.9",
|
||||
"py-deviceid==0.1.1",
|
||||
"py-iam-expand==0.3.0",
|
||||
"py-ocsf-models==0.8.1",
|
||||
"pyasn1==0.6.3",
|
||||
"py-ocsf-models==0.10.0",
|
||||
"pyasn1==0.6.4",
|
||||
"pyasn1-modules==0.4.2",
|
||||
"pycodestyle==2.14.0",
|
||||
"pycparser==3.0",
|
||||
@@ -378,7 +380,7 @@ constraint-dependencies = [
|
||||
"pylint==3.2.5",
|
||||
"pymsalruntime==0.18.1",
|
||||
"pynacl==1.6.2",
|
||||
"pyopenssl==26.0.0",
|
||||
"pyopenssl==26.2.0",
|
||||
"pyparsing==3.3.2",
|
||||
"pyreadline3==3.5.4",
|
||||
"pysocks==1.7.1",
|
||||
@@ -447,7 +449,7 @@ constraint-dependencies = [
|
||||
"wcwidth==0.5.3",
|
||||
"websocket-client==1.9.0",
|
||||
"werkzeug==3.1.7",
|
||||
"workos==6.0.8",
|
||||
"workos==8.3.0",
|
||||
"wrapt==1.17.3",
|
||||
"xlsxwriter==3.2.9",
|
||||
"xmlsec==1.3.17",
|
||||
@@ -466,10 +468,13 @@ constraint-dependencies = [
|
||||
# 0.138.1 requires azure-mgmt-containerservice>=41.0.0. Attack Paths does not
|
||||
# ingest Azure today, so override the Cartography dependency to the Prowler pin.
|
||||
#
|
||||
# prowler@master hard-pins microsoft-kiota-abstractions==1.9.2 in [project.dependencies].
|
||||
# The microsoft-kiota-http security bump to 1.9.9 (GHSA-7j59-v9qr-6fq9) requires
|
||||
# microsoft-kiota-abstractions>=1.9.9, which a constraint cannot satisfy against the
|
||||
# SDK's hard pin; override it to the patched, kiota-aligned version.
|
||||
# prowler@master hard-pins the microsoft-kiota packages in [project.dependencies].
|
||||
# microsoft-kiota-serialization-json 1.9.10 fixes get_collection_of_enum_values
|
||||
# returning [] for flags enums serialized as CSV strings (microsoft/kiota-python#515),
|
||||
# which broke the Entra Conditional Access guest-user checks; the kiota packages
|
||||
# release in lockstep and 1.9.10 requires microsoft-kiota-abstractions>=1.9.10, which
|
||||
# a constraint cannot satisfy against the SDK's hard pins, so override the whole set
|
||||
# to 1.9.10 until the SDK bump propagates to the pinned master rev.
|
||||
#
|
||||
# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies].
|
||||
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0
|
||||
@@ -480,8 +485,16 @@ constraint-dependencies = [
|
||||
# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive.
|
||||
override-dependencies = [
|
||||
"okta==3.4.2",
|
||||
# alibabacloud-tea-openapi 0.4.5 caps cryptography below 49 and is the latest release.
|
||||
"cryptography==50.0.0",
|
||||
"azure-mgmt-containerservice==34.1.0",
|
||||
"microsoft-kiota-abstractions==1.9.9",
|
||||
"microsoft-kiota-abstractions==1.9.10",
|
||||
"microsoft-kiota-authentication-azure==1.9.10",
|
||||
"microsoft-kiota-http==1.9.10",
|
||||
"microsoft-kiota-serialization-form==1.9.10",
|
||||
"microsoft-kiota-serialization-json==1.9.10",
|
||||
"microsoft-kiota-serialization-multipart==1.9.10",
|
||||
"microsoft-kiota-serialization-text==1.9.10",
|
||||
"dulwich==1.2.5",
|
||||
"pyjwt[crypto]==2.13.0"
|
||||
]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from allauth.account.models import EmailAddress
|
||||
from allauth.core.exceptions import ImmediateHttpResponse
|
||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||
from api.db_router import MainRouter
|
||||
from api.db_router import MainRouter, write_db_alias
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import (
|
||||
Membership,
|
||||
@@ -11,6 +13,7 @@ from api.models import (
|
||||
)
|
||||
from api.utils import accept_invitation_for_user
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponseForbidden
|
||||
|
||||
|
||||
class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
@@ -38,8 +41,13 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
return None
|
||||
|
||||
def pre_social_login(self, request, sociallogin):
|
||||
# Link existing accounts with the same email address
|
||||
email = sociallogin.account.extra_data.get("email")
|
||||
# The provider account is already bound, so no email-based linking is needed.
|
||||
if sociallogin.account.pk:
|
||||
return
|
||||
|
||||
# Prefer the normalized email populated by allauth. GitHub can return the
|
||||
# primary email separately from the profile stored in extra_data.
|
||||
email = sociallogin.user.email or sociallogin.account.extra_data.get("email")
|
||||
if sociallogin.provider.id == "saml":
|
||||
# For SAML, the asserted NameID email cannot be trusted on its own:
|
||||
# any tenant can claim any email domain in its SAML configuration. To
|
||||
@@ -80,6 +88,17 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
if email:
|
||||
existing_user = self.get_user_by_email(email)
|
||||
if existing_user:
|
||||
email_is_verified = EmailAddress.objects.filter(
|
||||
user=existing_user,
|
||||
email__iexact=email,
|
||||
verified=True,
|
||||
).exists()
|
||||
provider_verified_email = any(
|
||||
address.verified and address.email.casefold() == email.casefold()
|
||||
for address in sociallogin.email_addresses
|
||||
)
|
||||
if not email_is_verified or not provider_verified_email:
|
||||
raise ImmediateHttpResponse(HttpResponseForbidden())
|
||||
sociallogin.connect(request, existing_user)
|
||||
|
||||
def save_user(self, request, sociallogin, form=None):
|
||||
@@ -88,7 +107,10 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
and is about to be saved to the DB for the first time.
|
||||
"""
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
user = super().save_user(request, sociallogin, form)
|
||||
# Allauth saves the user without an explicit alias. Route that save
|
||||
# through admin so every signup record shares this transaction.
|
||||
with write_db_alias(MainRouter.admin_db):
|
||||
user = super().save_user(request, sociallogin, form)
|
||||
provider = sociallogin.provider.id
|
||||
extra = sociallogin.account.extra_data
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from django.conf import (
|
||||
MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250)
|
||||
|
||||
TEMP_DB_PREFIX = "db-tmp-scan-"
|
||||
DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
|
||||
|
||||
|
||||
# Exceptions
|
||||
@@ -44,6 +45,10 @@ class GraphDatabaseQueryException(Exception):
|
||||
return self.message
|
||||
|
||||
|
||||
class NeptuneWriteRetryExhaustedException(GraphDatabaseQueryException):
|
||||
pass
|
||||
|
||||
|
||||
class WriteQueryNotAllowedException(GraphDatabaseQueryException):
|
||||
pass
|
||||
|
||||
|
||||
@@ -1927,12 +1927,20 @@ AWS_IAM_PRIVESC_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
|
||||
OR act.value = '*'
|
||||
WITH DISTINCT aws, principal, stmt, path_principal
|
||||
|
||||
// Find target users that the principal can create access keys for
|
||||
MATCH path_target = (aws)--(target_user:AWSUser)
|
||||
// Pre-aggregate this statement's resource values into a list so the user
|
||||
// match below is evaluated once per user (in-memory `any`) instead of
|
||||
// building an (all-users x all-resource-items) cartesian product.
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
OR res.value CONTAINS target_user.name
|
||||
OR target_user.arn CONTAINS res.value
|
||||
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
|
||||
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
|
||||
|
||||
// Find target users that the principal can create access keys for.
|
||||
// Bind name/arn once so the `any` predicate reads locals.
|
||||
MATCH path_target = (aws)--(target_user:AWSUser)
|
||||
WITH path_principal, path_target, res_values, res_wildcard,
|
||||
target_user.name AS uname, target_user.arn AS uarn
|
||||
WHERE res_wildcard
|
||||
OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0
|
||||
|
||||
WITH DISTINCT path_principal, path_target
|
||||
WITH collect(path_principal) + collect(path_target) AS paths
|
||||
@@ -1975,16 +1983,24 @@ AWS_IAM_PRIVESC_DELETE_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
|
||||
OR act2.value = '*'
|
||||
WITH DISTINCT aws, principal, stmt, stmt2, path_principal
|
||||
|
||||
// Find target users that the principal can rotate access keys for
|
||||
MATCH path_target = (aws)--(target_user:AWSUser)
|
||||
// Pre-aggregate both statements' resource values into lists so the user
|
||||
// match below is evaluated once per user (in-memory `any`) instead of
|
||||
// building an (all-users x res x res2) cartesian product.
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
OR res.value CONTAINS target_user.name
|
||||
OR target_user.arn CONTAINS res.value
|
||||
WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values
|
||||
MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
|
||||
WHERE res2.value = '*'
|
||||
OR res2.value CONTAINS target_user.name
|
||||
OR target_user.arn CONTAINS res2.value
|
||||
WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values
|
||||
WITH aws, path_principal, res_values, res2_values,
|
||||
('*' IN res_values) AS res_wildcard,
|
||||
('*' IN res2_values) AS res2_wildcard
|
||||
|
||||
// Find target users that the principal can rotate access keys for.
|
||||
// Bind name/arn once so the `any` predicates read locals.
|
||||
MATCH path_target = (aws)--(target_user:AWSUser)
|
||||
WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard,
|
||||
target_user.name AS uname, target_user.arn AS uarn
|
||||
WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0)
|
||||
AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0)
|
||||
|
||||
WITH DISTINCT path_principal, path_target
|
||||
WITH collect(path_principal) + collect(path_target) AS paths
|
||||
@@ -2019,12 +2035,20 @@ AWS_IAM_PRIVESC_CREATE_LOGIN_PROFILE = AttackPathsQueryDefinition(
|
||||
OR act.value = '*'
|
||||
WITH DISTINCT aws, principal, stmt, path_principal
|
||||
|
||||
// Find target users that the principal can create login profiles for
|
||||
MATCH path_target = (aws)--(target_user:AWSUser)
|
||||
// Pre-aggregate this statement's resource values into a list so the user
|
||||
// match below is evaluated once per user (in-memory `any`) instead of
|
||||
// building an (all-users x all-resource-items) cartesian product.
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
OR res.value CONTAINS target_user.name
|
||||
OR target_user.arn CONTAINS res.value
|
||||
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
|
||||
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
|
||||
|
||||
// Find target users that the principal can create login profiles for.
|
||||
// Bind name/arn once so the `any` predicate reads locals.
|
||||
MATCH path_target = (aws)--(target_user:AWSUser)
|
||||
WITH path_principal, path_target, res_values, res_wildcard,
|
||||
target_user.name AS uname, target_user.arn AS uarn
|
||||
WHERE res_wildcard
|
||||
OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0
|
||||
|
||||
WITH DISTINCT path_principal, path_target
|
||||
WITH collect(path_principal) + collect(path_target) AS paths
|
||||
@@ -2097,12 +2121,20 @@ AWS_IAM_PRIVESC_UPDATE_LOGIN_PROFILE = AttackPathsQueryDefinition(
|
||||
OR act.value = '*'
|
||||
WITH DISTINCT aws, principal, stmt, path_principal
|
||||
|
||||
// Find target users that the principal can update login profiles for
|
||||
MATCH path_target = (aws)--(target_user:AWSUser)
|
||||
// Pre-aggregate this statement's resource values into a list so the user
|
||||
// match below is evaluated once per user (in-memory `any`) instead of
|
||||
// building an (all-users x all-resource-items) cartesian product.
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
OR res.value CONTAINS target_user.name
|
||||
OR target_user.arn CONTAINS res.value
|
||||
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
|
||||
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
|
||||
|
||||
// Find target users that the principal can update login profiles for.
|
||||
// Bind name/arn once so the `any` predicate reads locals.
|
||||
MATCH path_target = (aws)--(target_user:AWSUser)
|
||||
WITH path_principal, path_target, res_values, res_wildcard,
|
||||
target_user.name AS uname, target_user.arn AS uarn
|
||||
WHERE res_wildcard
|
||||
OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0
|
||||
|
||||
WITH DISTINCT path_principal, path_target
|
||||
WITH collect(path_principal) + collect(path_target) AS paths
|
||||
@@ -2333,12 +2365,21 @@ AWS_IAM_PRIVESC_UPDATE_ASSUME_ROLE_POLICY = AttackPathsQueryDefinition(
|
||||
// Collapse the action-item fan-out: one row per (statement chain), not per matching action
|
||||
WITH DISTINCT aws, stmt, path_principal
|
||||
|
||||
// Find target roles whose trust policy this statement's resource can target
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
// Pre-aggregate this statement's resource values into a list so the role
|
||||
// match below is evaluated once per role (in-memory `any`) instead of
|
||||
// building an (all-roles x all-resource-items) cartesian product.
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
OR res.value CONTAINS target_role.name
|
||||
OR target_role.arn CONTAINS res.value
|
||||
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
|
||||
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
|
||||
|
||||
// Find target roles whose trust policy this statement's resource can target.
|
||||
// Bind the role's name/arn once so the `any` predicate reads them from a
|
||||
// local variable instead of re-reading the property store per resource.
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
WITH path_principal, path_target, res_values, res_wildcard,
|
||||
target_role.name AS rname, target_role.arn AS rarn
|
||||
WHERE res_wildcard
|
||||
OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0
|
||||
|
||||
WITH DISTINCT path_principal, path_target
|
||||
WITH collect(path_principal) + collect(path_target) AS paths
|
||||
@@ -2373,12 +2414,20 @@ AWS_IAM_PRIVESC_ADD_USER_TO_GROUP = AttackPathsQueryDefinition(
|
||||
OR act.value = '*'
|
||||
WITH DISTINCT aws, principal, stmt, path_principal
|
||||
|
||||
// Find target groups the principal can add users to
|
||||
MATCH path_target = (aws)--(target_group:AWSGroup)
|
||||
// Pre-aggregate this statement's resource values into a list so the group
|
||||
// match below is evaluated once per group (in-memory `any`) instead of
|
||||
// building an (all-groups x all-resource-items) cartesian product.
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
OR res.value CONTAINS target_group.name
|
||||
OR target_group.arn CONTAINS res.value
|
||||
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
|
||||
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
|
||||
|
||||
// Find target groups the principal can add users to.
|
||||
// Bind name/arn once so the `any` predicate reads locals.
|
||||
MATCH path_target = (aws)--(target_group:AWSGroup)
|
||||
WITH path_principal, path_target, res_values, res_wildcard,
|
||||
target_group.name AS gname, target_group.arn AS garn
|
||||
WHERE res_wildcard
|
||||
OR size([rv IN res_values WHERE rv CONTAINS gname OR garn CONTAINS rv]) > 0
|
||||
|
||||
WITH DISTINCT path_principal, path_target
|
||||
WITH collect(path_principal) + collect(path_target) AS paths
|
||||
@@ -2462,16 +2511,24 @@ AWS_IAM_PRIVESC_ATTACH_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinitio
|
||||
OR act2.value = '*'
|
||||
WITH DISTINCT aws, principal, stmt, stmt2, path_principal
|
||||
|
||||
// Find target users the principal can attach policies to and create keys for
|
||||
MATCH path_target = (aws)--(target_user:AWSUser)
|
||||
// Pre-aggregate both statements' resource values into lists so the user
|
||||
// match below is evaluated once per user (in-memory `any`) instead of
|
||||
// building an (all-users x res x res2) cartesian product.
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
OR res.value CONTAINS target_user.name
|
||||
OR target_user.arn CONTAINS res.value
|
||||
WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values
|
||||
MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
|
||||
WHERE res2.value = '*'
|
||||
OR res2.value CONTAINS target_user.name
|
||||
OR target_user.arn CONTAINS res2.value
|
||||
WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values
|
||||
WITH aws, path_principal, res_values, res2_values,
|
||||
('*' IN res_values) AS res_wildcard,
|
||||
('*' IN res2_values) AS res2_wildcard
|
||||
|
||||
// Find target users the principal can attach policies to and create keys for.
|
||||
// Bind name/arn once so the `any` predicates read locals.
|
||||
MATCH path_target = (aws)--(target_user:AWSUser)
|
||||
WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard,
|
||||
target_user.name AS uname, target_user.arn AS uarn
|
||||
WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0)
|
||||
AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0)
|
||||
|
||||
WITH DISTINCT path_principal, path_target
|
||||
WITH collect(path_principal) + collect(path_target) AS paths
|
||||
@@ -2596,16 +2653,24 @@ AWS_IAM_PRIVESC_PUT_USER_POLICY_CREATE_ACCESS_KEY = AttackPathsQueryDefinition(
|
||||
OR act2.value = '*'
|
||||
WITH DISTINCT aws, principal, stmt, stmt2, path_principal
|
||||
|
||||
// Find target users the principal can put policies on and create keys for
|
||||
MATCH path_target = (aws)--(target_user:AWSUser)
|
||||
// Pre-aggregate both statements' resource values into lists so the user
|
||||
// match below is evaluated once per user (in-memory `any`) instead of
|
||||
// building an (all-users x res x res2) cartesian product.
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
OR res.value CONTAINS target_user.name
|
||||
OR target_user.arn CONTAINS res.value
|
||||
WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values
|
||||
MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
|
||||
WHERE res2.value = '*'
|
||||
OR res2.value CONTAINS target_user.name
|
||||
OR target_user.arn CONTAINS res2.value
|
||||
WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values
|
||||
WITH aws, path_principal, res_values, res2_values,
|
||||
('*' IN res_values) AS res_wildcard,
|
||||
('*' IN res2_values) AS res2_wildcard
|
||||
|
||||
// Find target users the principal can put policies on and create keys for.
|
||||
// Bind name/arn once so the `any` predicates read locals.
|
||||
MATCH path_target = (aws)--(target_user:AWSUser)
|
||||
WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard,
|
||||
target_user.name AS uname, target_user.arn AS uarn
|
||||
WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0)
|
||||
AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS uname OR uarn CONTAINS rv]) > 0)
|
||||
|
||||
WITH DISTINCT path_principal, path_target
|
||||
WITH collect(path_principal) + collect(path_target) AS paths
|
||||
@@ -2647,16 +2712,24 @@ AWS_IAM_PRIVESC_ATTACH_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefiniti
|
||||
OR act2.value = '*'
|
||||
WITH DISTINCT aws, principal, stmt, stmt2, path_principal
|
||||
|
||||
// Find target roles the principal can attach policies to and update trust policy for
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
// Pre-aggregate both statements' resource values into lists so the role
|
||||
// match below is evaluated once per role (in-memory `any`) instead of
|
||||
// building an (all-roles x res x res2) cartesian product.
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
OR res.value CONTAINS target_role.name
|
||||
OR target_role.arn CONTAINS res.value
|
||||
WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values
|
||||
MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
|
||||
WHERE res2.value = '*'
|
||||
OR res2.value CONTAINS target_role.name
|
||||
OR target_role.arn CONTAINS res2.value
|
||||
WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values
|
||||
WITH aws, path_principal, res_values, res2_values,
|
||||
('*' IN res_values) AS res_wildcard,
|
||||
('*' IN res2_values) AS res2_wildcard
|
||||
|
||||
// Find target roles the principal can attach policies to and update trust
|
||||
// policy for. Bind name/arn once so the `any` predicates read locals.
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard,
|
||||
target_role.name AS rname, target_role.arn AS rarn
|
||||
WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0)
|
||||
AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0)
|
||||
|
||||
WITH DISTINCT path_principal, path_target
|
||||
WITH collect(path_principal) + collect(path_target) AS paths
|
||||
@@ -2698,17 +2771,31 @@ AWS_IAM_PRIVESC_CREATE_POLICY_VERSION_UPDATE_ASSUME_ROLE = AttackPathsQueryDefin
|
||||
OR act2.value = '*'
|
||||
WITH DISTINCT aws, principal, stmt, stmt2, path_principal
|
||||
|
||||
// Find target roles with customer-managed policies the principal can modify and update trust policy for
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
// Pre-aggregate both statements' resource values into lists so the role
|
||||
// and policy matches below are evaluated with an in-memory `any` instead
|
||||
// of building an (all-roles x res2) x (policies x res) cartesian product.
|
||||
MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
|
||||
WHERE res2.value = '*'
|
||||
OR res2.value CONTAINS target_role.name
|
||||
OR target_role.arn CONTAINS res2.value
|
||||
MATCH (target_role)-[:POLICY]->(target_policy:AWSPolicy)
|
||||
WHERE target_policy.arn CONTAINS $provider_uid
|
||||
WITH aws, stmt, path_principal, collect(DISTINCT res2.value) AS res2_values
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
OR target_policy.arn CONTAINS res.value
|
||||
WITH aws, path_principal, res2_values, collect(DISTINCT res.value) AS res_values
|
||||
WITH aws, path_principal, res2_values, res_values,
|
||||
('*' IN res2_values) AS res2_wildcard,
|
||||
('*' IN res_values) AS res_wildcard
|
||||
|
||||
// Find target roles with customer-managed policies the principal can
|
||||
// modify and update trust policy for. Bind name/arn once so the `any`
|
||||
// predicates read locals instead of re-reading the property store.
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
WITH path_principal, path_target, target_role, res_values, res_wildcard,
|
||||
res2_values, res2_wildcard,
|
||||
target_role.name AS rname, target_role.arn AS rarn
|
||||
WHERE res2_wildcard
|
||||
OR size([rv IN res2_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0
|
||||
MATCH (target_role)-[:POLICY]->(target_policy:AWSPolicy)
|
||||
WITH path_principal, path_target, res_values, res_wildcard,
|
||||
target_policy.arn AS parn
|
||||
WHERE parn CONTAINS $provider_uid
|
||||
AND (res_wildcard OR size([rv IN res_values WHERE parn CONTAINS rv]) > 0)
|
||||
|
||||
WITH DISTINCT path_principal, path_target
|
||||
WITH collect(path_principal) + collect(path_target) AS paths
|
||||
@@ -2750,16 +2837,24 @@ AWS_IAM_PRIVESC_PUT_ROLE_POLICY_UPDATE_ASSUME_ROLE = AttackPathsQueryDefinition(
|
||||
OR act2.value = '*'
|
||||
WITH DISTINCT aws, principal, stmt, stmt2, path_principal
|
||||
|
||||
// Find target roles the principal can put inline policies on and update trust policy for
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
// Pre-aggregate both statements' resource values into lists so the role
|
||||
// match below is evaluated once per role (in-memory `any`) instead of
|
||||
// building an (all-roles x res x res2) cartesian product.
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
OR res.value CONTAINS target_role.name
|
||||
OR target_role.arn CONTAINS res.value
|
||||
WITH aws, stmt2, path_principal, collect(DISTINCT res.value) AS res_values
|
||||
MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
|
||||
WHERE res2.value = '*'
|
||||
OR res2.value CONTAINS target_role.name
|
||||
OR target_role.arn CONTAINS res2.value
|
||||
WITH aws, path_principal, res_values, collect(DISTINCT res2.value) AS res2_values
|
||||
WITH aws, path_principal, res_values, res2_values,
|
||||
('*' IN res_values) AS res_wildcard,
|
||||
('*' IN res2_values) AS res2_wildcard
|
||||
|
||||
// Find target roles the principal can put inline policies on and update
|
||||
// trust policy for. Bind name/arn once so the `any` predicates read locals.
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
WITH path_principal, path_target, res_values, res_wildcard, res2_values, res2_wildcard,
|
||||
target_role.name AS rname, target_role.arn AS rarn
|
||||
WHERE (res_wildcard OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0)
|
||||
AND (res2_wildcard OR size([rv IN res2_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0)
|
||||
|
||||
WITH DISTINCT path_principal, path_target
|
||||
WITH collect(path_principal) + collect(path_target) AS paths
|
||||
@@ -3441,6 +3536,160 @@ AWS_STS_PRIVESC_ASSUME_ROLE = AttackPathsQueryDefinition(
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
# STS-002
|
||||
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST = AttackPathsQueryDefinition(
|
||||
id="aws-sts-privesc-cross-account-trust",
|
||||
name="Cross-Account Role Trust for Privilege Escalation (STS-002)",
|
||||
short_description="Roles that trust an external account's root principal can be assumed by any principal in that account, enabling confused-deputy escalation.",
|
||||
description="Detect IAM roles whose trust policy allows an external AWS account root principal (arn:aws:iam::<account-id>:root) to assume them. Any principal in the trusted external account that holds sts:AssumeRole can assume the role and gain its permissions, which is the confused-deputy escalation surface. The ingested graph does not record trust-policy conditions, so roles protected by an sts:ExternalId condition cannot be filtered out automatically and are surfaced here for manual review.",
|
||||
attribution=AttackPathsQueryAttribution(
|
||||
text="pathfinding.cloud - STS-002 - sts:AssumeRole",
|
||||
link="https://pathfinding.cloud/paths/sts-002",
|
||||
),
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
// Find roles that trust an external account's root principal (cross-account trust)
|
||||
MATCH path_target = (aws:AWSAccount {{id: $provider_uid}})--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(trusted:AWSRootPrincipal)
|
||||
WHERE trusted.arn CONTAINS ':root'
|
||||
AND NOT trusted.arn CONTAINS aws.id
|
||||
|
||||
WITH DISTINCT path_target
|
||||
WITH collect(path_target) AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
WITH paths, collect(DISTINCT n) AS unique_nodes
|
||||
UNWIND unique_nodes AS n
|
||||
|
||||
OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
|
||||
|
||||
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
# STS-003
|
||||
AWS_STS_PRIVESC_WILDCARD_TRUST = AttackPathsQueryDefinition(
|
||||
id="aws-sts-privesc-wildcard-trust",
|
||||
name="Potential Wildcard Role Trust (STS-003)",
|
||||
short_description="Potential wildcard role trusts that need manual review before they are treated as assumable.",
|
||||
description='Find IAM roles linked to a wildcard principal ("AWS": "*"). The ingested graph does not preserve trust-policy Effect or Condition fields, so a match can come from a Deny statement or a restricted Allow statement. Treat each result as a candidate for manual review, not as a confirmed assumable role.',
|
||||
attribution=AttackPathsQueryAttribution(
|
||||
text="pathfinding.cloud - STS-003 - sts:AssumeRole",
|
||||
link="https://pathfinding.cloud/paths/sts-003",
|
||||
),
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
// Find roles linked to a wildcard principal for manual review
|
||||
MATCH path_target = (aws:AWSAccount {{id: $provider_uid}})--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(trusted:AWSPrincipal)
|
||||
WHERE trusted.arn = '*'
|
||||
|
||||
WITH DISTINCT path_target
|
||||
WITH collect(path_target) AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
WITH paths, collect(DISTINCT n) AS unique_nodes
|
||||
UNWIND unique_nodes AS n
|
||||
|
||||
OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
|
||||
|
||||
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
# IAM-022
|
||||
AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY = AttackPathsQueryDefinition(
|
||||
id="aws-iam-privesc-delete-user-permissions-boundary",
|
||||
name="Permissions Boundary Removal for Self-Escalation (IAM-022)",
|
||||
short_description="IAM users that can remove their own permissions boundary, if one is attached.",
|
||||
description="Find IAM users whose policies allow iam:DeleteUserPermissionsBoundary on their own user ARN. The graph does not record whether a boundary is attached or whether removing it grants more access, so each result needs manual review.",
|
||||
attribution=AttackPathsQueryAttribution(
|
||||
text="pathfinding.cloud - IAM-022 - iam:DeleteUserPermissionsBoundary",
|
||||
link="https://pathfinding.cloud/paths/iam-022",
|
||||
),
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
// Find IAM users with iam:DeleteUserPermissionsBoundary permission
|
||||
MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSUser)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
|
||||
MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
|
||||
WHERE toLower(act.value) IN ['iam:*', 'iam:deleteuserpermissionsboundary']
|
||||
OR act.value = '*'
|
||||
WITH DISTINCT principal, stmt, path_principal
|
||||
|
||||
// Keep only users that can remove the boundary from their own user ARN
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
OR res.value = principal.arn
|
||||
OR (res.value ENDS WITH '*' AND principal.arn STARTS WITH replace(res.value, '*', ''))
|
||||
|
||||
WITH DISTINCT path_principal
|
||||
WITH collect(path_principal) AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
WITH paths, collect(DISTINCT n) AS unique_nodes
|
||||
UNWIND unique_nodes AS n
|
||||
|
||||
OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
|
||||
|
||||
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
# SSO-001
|
||||
AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION = AttackPathsQueryDefinition(
|
||||
id="aws-sso-privesc-permission-set-escalation",
|
||||
name="Identity Center Permission Set Escalation (SSO-001)",
|
||||
short_description="Create an administrative Identity Center permission set and assign it to gain organization-wide admin access.",
|
||||
description="Detect principals that hold sso:CreatePermissionSet, sso:AttachManagedPolicyToPermissionSet, and sso:CreateAccountAssignment together. With all three, a principal can create a new IAM Identity Center permission set, attach the AdministratorAccess managed policy to it, and assign it to their own user or group for any account in the organization, gaining administrative access across the organization through the Identity Center portal.",
|
||||
attribution=AttackPathsQueryAttribution(
|
||||
text="pathfinding.cloud - SSO-001 - sso:CreatePermissionSet + sso:AttachManagedPolicyToPermissionSet + sso:CreateAccountAssignment",
|
||||
link="https://pathfinding.cloud/paths/sso-001",
|
||||
),
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
// Find principals with sso:CreatePermissionSet permission
|
||||
MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
|
||||
MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
|
||||
WHERE toLower(act.value) IN ['sso:*', 'sso:createpermissionset']
|
||||
OR act.value = '*'
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
WITH DISTINCT aws, principal, path_principal
|
||||
|
||||
// Find sso:AttachManagedPolicyToPermissionSet permission on the same principal
|
||||
MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
|
||||
WHERE toLower(act2.value) IN ['sso:*', 'sso:attachmanagedpolicytopermissionset']
|
||||
OR act2.value = '*'
|
||||
MATCH (stmt2)-[:HAS_RESOURCE]->(res2:AWSPolicyStatementResourceItem)
|
||||
WHERE res2.value = '*'
|
||||
WITH DISTINCT principal, path_principal
|
||||
|
||||
// Find sso:CreateAccountAssignment permission on the same principal
|
||||
MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(stmt3:AWSPolicyStatement {{effect: 'Allow'}})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
|
||||
WHERE toLower(act3.value) IN ['sso:*', 'sso:createaccountassignment']
|
||||
OR act3.value = '*'
|
||||
MATCH (stmt3)-[:HAS_RESOURCE]->(res3:AWSPolicyStatementResourceItem)
|
||||
WHERE res3.value = '*'
|
||||
|
||||
WITH DISTINCT path_principal
|
||||
WITH collect(path_principal) AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
WITH paths, collect(DISTINCT n) AS unique_nodes
|
||||
UNWIND unique_nodes AS n
|
||||
|
||||
OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
|
||||
|
||||
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
# AWS Queries List
|
||||
|
||||
AWS_QUERIES: list[AttackPathsQueryDefinition] = [
|
||||
@@ -3522,4 +3771,8 @@ AWS_QUERIES: list[AttackPathsQueryDefinition] = [
|
||||
AWS_SSM_PRIVESC_START_SESSION,
|
||||
AWS_SSM_PRIVESC_SEND_COMMAND,
|
||||
AWS_STS_PRIVESC_ASSUME_ROLE,
|
||||
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST,
|
||||
AWS_STS_PRIVESC_WILDCARD_TRUST,
|
||||
AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY,
|
||||
AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION,
|
||||
]
|
||||
|
||||
@@ -10,6 +10,28 @@ import neo4j.exceptions
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RetryExhaustedError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
retry_context: str,
|
||||
method_name: str,
|
||||
attempts: int,
|
||||
elapsed_seconds: float,
|
||||
last_error: Exception,
|
||||
) -> None:
|
||||
self.retry_context = retry_context
|
||||
self.method_name = method_name
|
||||
self.attempts = attempts
|
||||
self.elapsed_seconds = elapsed_seconds
|
||||
self.last_error = last_error
|
||||
last_message = getattr(last_error, "message", None) or str(last_error)
|
||||
super().__init__(
|
||||
f"{retry_context} {method_name} failed after {attempts} attempts over "
|
||||
f"{elapsed_seconds:.3f}s. Last error: {last_message}"
|
||||
)
|
||||
|
||||
|
||||
class RetryableSession:
|
||||
"""Wrapper around ``neo4j.Session`` with a refreshable retry policy."""
|
||||
|
||||
@@ -19,11 +41,13 @@ class RetryableSession:
|
||||
max_retries: int,
|
||||
retry_if: Callable[[Exception], bool] | None = None,
|
||||
initial_retry_delay_seconds: float = 0,
|
||||
retry_context: str | None = None,
|
||||
) -> None:
|
||||
self._session_factory = session_factory
|
||||
self._max_retries = max(0, max_retries)
|
||||
self._retry_if = retry_if
|
||||
self._initial_retry_delay_seconds = max(0.0, initial_retry_delay_seconds)
|
||||
self._retry_context = retry_context
|
||||
self._session = self._session_factory()
|
||||
|
||||
def close(self) -> None:
|
||||
@@ -54,6 +78,7 @@ class RetryableSession:
|
||||
def _call_with_retry(self, method_name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
attempt = 0
|
||||
last_exc: Exception | None = None
|
||||
started_at = time.monotonic()
|
||||
|
||||
while attempt <= self._max_retries:
|
||||
try:
|
||||
@@ -68,17 +93,38 @@ class RetryableSession:
|
||||
attempt += 1
|
||||
|
||||
if attempt > self._max_retries:
|
||||
if self._retry_context is not None:
|
||||
raise RetryExhaustedError(
|
||||
retry_context=self._retry_context,
|
||||
method_name=method_name,
|
||||
attempts=attempt,
|
||||
elapsed_seconds=time.monotonic() - started_at,
|
||||
last_error=exc,
|
||||
) from exc
|
||||
raise
|
||||
|
||||
delay = self._retry_delay(attempt)
|
||||
logger.warning(
|
||||
"Graph session %s failed with %s; retry %s/%s in %.3fs",
|
||||
method_name,
|
||||
type(exc).__name__,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
delay,
|
||||
)
|
||||
if self._retry_context is not None:
|
||||
error_message = getattr(exc, "message", None) or str(exc)
|
||||
logger.warning(
|
||||
"%s %s failed with %s: %s; retry %s/%s in %.3fs",
|
||||
self._retry_context,
|
||||
method_name,
|
||||
type(exc).__name__,
|
||||
error_message,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
delay,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Graph session %s failed with %s; retry %s/%s in %.3fs",
|
||||
method_name,
|
||||
type(exc).__name__,
|
||||
attempt,
|
||||
self._max_retries,
|
||||
delay,
|
||||
)
|
||||
self._refresh_session()
|
||||
if delay:
|
||||
time.sleep(delay)
|
||||
|
||||
@@ -15,6 +15,8 @@ class SinkDatabase(Protocol):
|
||||
has a single graph, and isolation is label-based).
|
||||
"""
|
||||
|
||||
sync_batch_size: int
|
||||
|
||||
def init(self) -> None: ...
|
||||
|
||||
def close(self) -> None: ...
|
||||
|
||||
@@ -54,6 +54,8 @@ DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
|
||||
class Neo4jSink(SinkDatabase):
|
||||
"""Neo4j-backed sink. Multi-database cluster; tenant isolation is physical."""
|
||||
|
||||
sync_batch_size = env.int("ATTACK_PATHS_NEO4J_SYNC_BATCH_SIZE", default=1000)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._driver: neo4j.Driver | None = None
|
||||
self._lock = threading.Lock()
|
||||
@@ -203,7 +205,7 @@ class Neo4jSink(SinkDatabase):
|
||||
"""
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
BATCH_SIZE,
|
||||
GRAPH_MUTATION_BATCH_SIZE,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
get_provider_label,
|
||||
)
|
||||
@@ -251,7 +253,7 @@ class Neo4jSink(SinkDatabase):
|
||||
total_key="rels",
|
||||
deleted_key="deleted_rels",
|
||||
initial_total=deleted_relationships,
|
||||
batch_size=BATCH_SIZE,
|
||||
batch_size=GRAPH_MUTATION_BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
relationship_batches += phase_batches
|
||||
@@ -270,7 +272,7 @@ class Neo4jSink(SinkDatabase):
|
||||
total_key="nodes",
|
||||
deleted_key="deleted_nodes",
|
||||
initial_total=0,
|
||||
batch_size=BATCH_SIZE,
|
||||
batch_size=GRAPH_MUTATION_BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from urllib.parse import urlsplit
|
||||
|
||||
import neo4j
|
||||
import neo4j.exceptions
|
||||
from api.attack_paths.retryable_session import RetryableSession
|
||||
from api.attack_paths.retryable_session import RetryableSession, RetryExhaustedError
|
||||
from api.attack_paths.sink.base import SinkDatabase
|
||||
from api.attack_paths.sink.drop import (
|
||||
NODE_DELETE_QUERY_TEMPLATE,
|
||||
@@ -85,6 +85,8 @@ def _is_retryable_write_error(exc: Exception) -> bool:
|
||||
class NeptuneSink(SinkDatabase):
|
||||
"""Neptune-backed sink. Single database; isolation is label-based."""
|
||||
|
||||
sync_batch_size = env.int("ATTACK_PATHS_NEPTUNE_SYNC_BATCH_SIZE", default=500)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._writer: neo4j.Driver | None = None
|
||||
self._reader: neo4j.Driver | None = None
|
||||
@@ -206,6 +208,7 @@ class NeptuneSink(SinkDatabase):
|
||||
from api.attack_paths.database import (
|
||||
ClientStatementException,
|
||||
GraphDatabaseQueryException,
|
||||
NeptuneWriteRetryExhaustedException,
|
||||
WriteQueryNotAllowedException,
|
||||
)
|
||||
|
||||
@@ -227,9 +230,17 @@ class NeptuneSink(SinkDatabase):
|
||||
initial_retry_delay_seconds=(
|
||||
NEPTUNE_WRITE_RETRY_DELAY_SECONDS if is_write_session else 0
|
||||
),
|
||||
retry_context="Neptune write" if is_write_session else None,
|
||||
)
|
||||
yield session_wrapper
|
||||
|
||||
except RetryExhaustedError as exc:
|
||||
last_error = exc.last_error
|
||||
raise NeptuneWriteRetryExhaustedException(
|
||||
message=str(exc),
|
||||
code=getattr(last_error, "code", None),
|
||||
) from last_error
|
||||
|
||||
except neo4j.exceptions.Neo4jError as exc:
|
||||
if (
|
||||
default_access_mode == neo4j.READ_ACCESS
|
||||
@@ -291,7 +302,7 @@ class NeptuneSink(SinkDatabase):
|
||||
graph's branching factor.
|
||||
"""
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
BATCH_SIZE,
|
||||
GRAPH_MUTATION_BATCH_SIZE,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
get_provider_label,
|
||||
)
|
||||
@@ -330,7 +341,7 @@ class NeptuneSink(SinkDatabase):
|
||||
total_key="rels",
|
||||
deleted_key="deleted_rels",
|
||||
initial_total=deleted_relationships,
|
||||
batch_size=BATCH_SIZE,
|
||||
batch_size=GRAPH_MUTATION_BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
relationship_batches += phase_batches
|
||||
@@ -349,7 +360,7 @@ class NeptuneSink(SinkDatabase):
|
||||
total_key="nodes",
|
||||
deleted_key="deleted_nodes",
|
||||
initial_total=0,
|
||||
batch_size=BATCH_SIZE,
|
||||
batch_size=GRAPH_MUTATION_BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
|
||||
|
||||
@@ -115,7 +115,26 @@ def execute_query(
|
||||
# TODO: drop after Neptune cutover
|
||||
# Route reads by the scan row's recorded sink, not by current settings.
|
||||
backend = sink_module.get_backend_for_scan(scan)
|
||||
graph = backend.execute_read_query(database_name, definition.cypher, parameters)
|
||||
|
||||
cypher = definition.cypher
|
||||
# Every synced node carries a `_Provider_{uuid}` isolation label (the
|
||||
# sync labels the whole provider subgraph). Injecting it into the
|
||||
# predefined query's node patterns gives the planner a selective label
|
||||
# index to seed from instead of a global label scan (`:AWSRole` across
|
||||
# every tenant), which on Neptune is the difference between a sub-second
|
||||
# plan and a query that times out. The custom-query path relies on this
|
||||
# same injection.
|
||||
#
|
||||
# Restrict it to migrated scans: that catalog runs on the Neptune sink
|
||||
# where the plan blowup happens, while the pre-cutover legacy catalog
|
||||
# runs on the old sink and is dropped after the cutover, so leave it
|
||||
# byte-for-byte unchanged. This only affects the query plan, not
|
||||
# isolation - `_serialize_graph` already label-filters both catalogs.
|
||||
# TODO: drop the is_migrated guard after Neptune cutover
|
||||
if scan.is_migrated:
|
||||
cypher = inject_provider_label(cypher, provider_id)
|
||||
|
||||
graph = backend.execute_read_query(database_name, cypher, parameters)
|
||||
return _serialize_graph(graph, provider_id)
|
||||
|
||||
except graph_database.WriteQueryNotAllowedException:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from math import isfinite
|
||||
from uuid import UUID
|
||||
|
||||
@@ -5,6 +6,7 @@ from api.db_router import MainRouter
|
||||
from api.models import TenantAPIKey, TenantAPIKeyManager
|
||||
from cryptography.fernet import InvalidToken
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth
|
||||
from drf_simple_apikey.crypto import get_crypto
|
||||
@@ -14,6 +16,16 @@ from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework.request import Request
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrphanedAPIKeyError(Exception):
|
||||
"""Raised when an API key outlived the user that owns it.
|
||||
|
||||
Handled by `authenticate`, which commits the revocation written while detecting it
|
||||
and then rejects the request with `AuthenticationFailed`.
|
||||
"""
|
||||
|
||||
|
||||
class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
model = TenantAPIKey
|
||||
@@ -24,10 +36,13 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
def _authenticate_credentials(self, request, key):
|
||||
"""
|
||||
Override to use admin connection, bypassing RLS during authentication.
|
||||
|
||||
Returns the validated API key row, locked with `select_for_update`, so callers
|
||||
must run inside `transaction.atomic(using=MainRouter.admin_db)`.
|
||||
"""
|
||||
try:
|
||||
payload = self.key_crypto.decrypt(key)
|
||||
except ValueError:
|
||||
except (ValueError, InvalidToken):
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
@@ -52,13 +67,33 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
raise AuthenticationFailed("API Key has already expired.")
|
||||
|
||||
try:
|
||||
api_key = self.model.objects.using(MainRouter.admin_db).get(id=api_key_pk)
|
||||
api_key = (
|
||||
self.model.objects.using(MainRouter.admin_db)
|
||||
.select_for_update()
|
||||
.get(id=api_key_pk)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise AuthenticationFailed("No entity matching this api key.")
|
||||
|
||||
if api_key.revoked:
|
||||
raise AuthenticationFailed("This API Key has been revoked.")
|
||||
|
||||
# `entity` is nullable and `on_delete=SET_NULL` leaves the key behind when its
|
||||
# owner is deleted, so a key can outlive its user. Reject it here: further down
|
||||
# the authentication would return `None` as the authenticated user, which blows
|
||||
# up while building the auth dict and surfaces as a 500 instead of a 401.
|
||||
# Revoke it as well, so it stops showing up as active and later attempts fail
|
||||
# the `revoked` check above like any other revoked key.
|
||||
if api_key.entity_id is None:
|
||||
api_key.revoked = True
|
||||
api_key.save(update_fields=["revoked"], using=MainRouter.admin_db)
|
||||
logger.warning(
|
||||
"Revoked orphaned API key: prefix=%s tenant=%s",
|
||||
api_key.prefix,
|
||||
api_key.tenant_id,
|
||||
)
|
||||
raise OrphanedAPIKeyError
|
||||
|
||||
client_ip = request.META.get(package_settings.IP_ADDRESS_HEADER)
|
||||
if api_key.blacklisted_ips and client_ip in api_key.blacklisted_ips:
|
||||
raise AuthenticationFailed("Access denied from blacklisted IP.")
|
||||
@@ -66,7 +101,7 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
if api_key.whitelisted_ips and client_ip not in api_key.whitelisted_ips:
|
||||
raise AuthenticationFailed("Access restricted to specific IP addresses.")
|
||||
|
||||
return api_key.entity, key
|
||||
return api_key
|
||||
|
||||
def authenticate(self, request: Request):
|
||||
prefixed_key = self.get_key(request)
|
||||
@@ -77,36 +112,34 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
|
||||
except ValueError:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
try:
|
||||
entity, _ = self._authenticate_credentials(request, key)
|
||||
except InvalidToken:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
# Validation, the `last_used_at` update and the auth claims all read the same
|
||||
# row, locked until the transaction ends. Looking the key up a second time to
|
||||
# build the claims used to leave a window where a key revoked or orphaned right
|
||||
# after passing validation still authenticated.
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
try:
|
||||
api_key = self._authenticate_credentials(request, key)
|
||||
except OrphanedAPIKeyError:
|
||||
# Rejected below instead of here: leaving the block normally commits
|
||||
# the revocation `_authenticate_credentials` wrote, while raising from
|
||||
# inside would roll it back.
|
||||
pass
|
||||
else:
|
||||
# The prefix used to be checked by the second lookup
|
||||
if api_key.prefix != prefix:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
# Get the API key instance to update last_used_at and retrieve tenant info
|
||||
# We need to decrypt again to get the pk (already validated by _authenticate_credentials)
|
||||
payload = self.key_crypto.decrypt(key)
|
||||
api_key_pk = payload["_pk"]
|
||||
api_key.last_used_at = timezone.now()
|
||||
api_key.save(update_fields=["last_used_at"], using=MainRouter.admin_db)
|
||||
|
||||
# Convert string UUID back to UUID object for lookup
|
||||
if isinstance(api_key_pk, str):
|
||||
api_key_pk = UUID(api_key_pk)
|
||||
entity = api_key.entity
|
||||
return entity, {
|
||||
"tenant_id": str(api_key.tenant_id),
|
||||
"sub": str(entity.id),
|
||||
"api_key_prefix": api_key.prefix,
|
||||
}
|
||||
|
||||
try:
|
||||
api_key_instance = TenantAPIKey.objects.using(MainRouter.admin_db).get(
|
||||
id=api_key_pk, prefix=prefix
|
||||
)
|
||||
except TenantAPIKey.DoesNotExist:
|
||||
raise AuthenticationFailed("Invalid API Key.")
|
||||
|
||||
# Update last_used_at
|
||||
api_key_instance.last_used_at = timezone.now()
|
||||
api_key_instance.save(update_fields=["last_used_at"], using=MainRouter.admin_db)
|
||||
|
||||
return entity, {
|
||||
"tenant_id": str(api_key_instance.tenant_id),
|
||||
"sub": str(api_key_instance.entity.id),
|
||||
"api_key_prefix": prefix,
|
||||
}
|
||||
raise AuthenticationFailed("No entity matching this api key.")
|
||||
|
||||
|
||||
class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication):
|
||||
|
||||
@@ -3,9 +3,10 @@ from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias
|
||||
from api.db_utils import POSTGRES_USER_VAR, rls_transaction
|
||||
from api.filters import CustomDjangoFilterBackend
|
||||
from api.models import Role, UserRoleRelationship
|
||||
from api.rbac.permissions import HasPermissions
|
||||
from api.rbac.permissions import HasPermissions, get_role
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils.functional import cached_property
|
||||
from rest_framework import permissions
|
||||
from rest_framework.exceptions import NotAuthenticated
|
||||
from rest_framework.filters import SearchFilter
|
||||
@@ -100,6 +101,11 @@ class BaseRLSViewSet(BaseViewSet):
|
||||
context["tenant_id"] = self.request.tenant_id
|
||||
return context
|
||||
|
||||
@cached_property
|
||||
def user_role(self):
|
||||
"""Role of the requesting user in the active tenant, resolved once per request."""
|
||||
return get_role(self.request.user, self.request.tenant_id)
|
||||
|
||||
|
||||
class BaseTenantViewset(BaseViewSet):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import ast
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
_UNPARSED = object()
|
||||
|
||||
|
||||
def decode_celery_field(value: Any, default: Any) -> Any:
|
||||
"""Decode a Celery result field and require JSON-serializable output."""
|
||||
decoded = value
|
||||
for _ in range(2):
|
||||
if not isinstance(decoded, str):
|
||||
break
|
||||
|
||||
text = decoded.strip()
|
||||
if not text:
|
||||
decoded = default
|
||||
break
|
||||
|
||||
parsed = _UNPARSED
|
||||
for parser in (json.loads, ast.literal_eval):
|
||||
try:
|
||||
parsed = parser(text)
|
||||
break
|
||||
except (TypeError, ValueError, SyntaxError):
|
||||
continue
|
||||
|
||||
if parsed is _UNPARSED:
|
||||
raise ValueError("Unable to decode Celery result field")
|
||||
decoded = parsed
|
||||
|
||||
decoded = default if decoded is None else decoded
|
||||
try:
|
||||
json.dumps(decoded, allow_nan=False)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError(
|
||||
"Decoded Celery result field is not JSON serializable"
|
||||
) from error
|
||||
|
||||
return decoded
|
||||
@@ -1,3 +1,4 @@
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
|
||||
from django.conf import settings
|
||||
@@ -5,6 +6,7 @@ from django.conf import settings
|
||||
ALLOWED_APPS = ("django", "socialaccount", "account", "authtoken", "silk")
|
||||
|
||||
_read_db_alias = ContextVar("read_db_alias", default=None)
|
||||
_write_db_alias = ContextVar("write_db_alias", default=None)
|
||||
|
||||
|
||||
def set_read_db_alias(alias: str | None):
|
||||
@@ -22,6 +24,30 @@ def reset_read_db_alias(token) -> None:
|
||||
_read_db_alias.reset(token)
|
||||
|
||||
|
||||
def set_write_db_alias(alias: str | None):
|
||||
if not alias:
|
||||
return None
|
||||
return _write_db_alias.set(alias)
|
||||
|
||||
|
||||
def get_write_db_alias() -> str | None:
|
||||
return _write_db_alias.get()
|
||||
|
||||
|
||||
def reset_write_db_alias(token) -> None:
|
||||
if token is not None:
|
||||
_write_db_alias.reset(token)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def write_db_alias(alias: str | None):
|
||||
token = set_write_db_alias(alias)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
reset_write_db_alias(token)
|
||||
|
||||
|
||||
class MainRouter:
|
||||
default_db = "default"
|
||||
admin_db = "admin"
|
||||
@@ -43,6 +69,9 @@ class MainRouter:
|
||||
model_table_name = model._meta.db_table
|
||||
if any(model_table_name.startswith(f"{app}_") for app in ALLOWED_APPS):
|
||||
return self.admin_db
|
||||
write_alias = get_write_db_alias()
|
||||
if write_alias:
|
||||
return write_alias
|
||||
return None
|
||||
|
||||
def allow_migrate(self, db, app_label, model_name=None, **hints): # noqa: F841
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import uuid
|
||||
from functools import wraps
|
||||
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY, rls_transaction
|
||||
from api.exceptions import ProviderDeletedException
|
||||
from api.models import Provider, Scan
|
||||
from api.models import Membership, Provider, Scan, Tenant
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import DatabaseError, connection, transaction
|
||||
from django.db import DEFAULT_DB_ALIAS, DatabaseError, connection, transaction
|
||||
from rest_framework_json_api.serializers import ValidationError
|
||||
|
||||
|
||||
@@ -75,9 +76,11 @@ def handle_provider_deletion(func):
|
||||
"""
|
||||
Decorator that raises `ProviderDeletedException` if provider was deleted during execution.
|
||||
|
||||
Catches `ObjectDoesNotExist` and `DatabaseError` (including `IntegrityError`), checks if
|
||||
provider still exists, and raises `ProviderDeletedException` if not. Otherwise,
|
||||
re-raises original exception.
|
||||
Catches `ObjectDoesNotExist`, `DatabaseError` (including `IntegrityError`), and
|
||||
`GraphDatabaseQueryException`, checks if provider still exists, and raises
|
||||
`ProviderDeletedException` if not. Graph database errors also check whether the
|
||||
tenant still exists and has memberships. Otherwise, re-raises the original
|
||||
exception.
|
||||
|
||||
Requires `tenant_id` and `provider_id` in kwargs.
|
||||
|
||||
@@ -92,11 +95,16 @@ def handle_provider_deletion(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except (ObjectDoesNotExist, DatabaseError):
|
||||
except (ObjectDoesNotExist, DatabaseError, GraphDatabaseQueryException) as exc:
|
||||
tenant_id = kwargs.get("tenant_id")
|
||||
provider_id = kwargs.get("provider_id")
|
||||
database_alias = (
|
||||
DEFAULT_DB_ALIAS
|
||||
if isinstance(exc, GraphDatabaseQueryException)
|
||||
else READ_REPLICA_ALIAS
|
||||
)
|
||||
|
||||
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
|
||||
with rls_transaction(tenant_id, using=database_alias):
|
||||
if provider_id is None:
|
||||
scan_id = kwargs.get("scan_id")
|
||||
if scan_id is None:
|
||||
@@ -113,6 +121,13 @@ def handle_provider_deletion(func):
|
||||
raise ProviderDeletedException(
|
||||
f"Provider '{provider_id}' was deleted during the scan"
|
||||
) from None
|
||||
if isinstance(exc, GraphDatabaseQueryException) and (
|
||||
not Tenant.objects.filter(pk=tenant_id).exists()
|
||||
or not Membership.objects.filter(tenant_id=tenant_id).exists()
|
||||
):
|
||||
raise ProviderDeletedException(
|
||||
f"Tenant '{tenant_id}' was deleted during the scan"
|
||||
) from None
|
||||
raise
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -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)
|
||||
@@ -1,8 +1,8 @@
|
||||
from enum import Enum
|
||||
|
||||
from api.db_router import MainRouter
|
||||
from api.models import Provider, Role, User
|
||||
from django.db.models import QuerySet
|
||||
from api.models import Integration, Provider, Role, User
|
||||
from django.db.models import Q, QuerySet
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
@@ -83,3 +83,32 @@ def get_providers(role: Role) -> QuerySet[Provider]:
|
||||
return Provider.objects.filter(
|
||||
tenant_id=tenant_id, provider_groups__in=provider_groups
|
||||
).distinct()
|
||||
|
||||
|
||||
def get_integrations(
|
||||
role: Role, providers: QuerySet[Provider] | None = None
|
||||
) -> QuerySet[Integration]:
|
||||
"""
|
||||
Return a distinct queryset of Integrations visible to the given role.
|
||||
|
||||
Integrations with no providers attached are tenant-wide, as is always the case for
|
||||
Jira, and stay visible regardless of the provider visibility of the role. Integrations
|
||||
attached to providers are only visible when the role can access at least one of them.
|
||||
|
||||
Args:
|
||||
role: A Role instance.
|
||||
providers: Optional queryset of the providers accessible by the role, to reuse
|
||||
an already resolved `get_providers(role)` result within the same request.
|
||||
|
||||
Returns:
|
||||
A QuerySet of Integration objects visible to the role.
|
||||
"""
|
||||
queryset = Integration.objects.filter(tenant_id=role.tenant_id)
|
||||
if role.unlimited_visibility:
|
||||
return queryset
|
||||
|
||||
if providers is None:
|
||||
providers = get_providers(role)
|
||||
return queryset.filter(
|
||||
Q(providers__isnull=True) | Q(providers__in=providers)
|
||||
).distinct()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from api.db_router import MainRouter
|
||||
from api.db_utils import delete_related_daily_task
|
||||
from api.models import (
|
||||
LighthouseProviderConfiguration,
|
||||
@@ -47,8 +48,15 @@ def revoke_user_api_keys(sender, instance, **kwargs): # noqa: F841
|
||||
|
||||
The entity field will be set to NULL by on_delete=SET_NULL,
|
||||
but we explicitly revoke the keys to prevent further use.
|
||||
|
||||
The update runs on the admin connection because `api_keys` is RLS protected and its
|
||||
policy denies every row when `api.tenant_id` is unset. Users are deleted through the
|
||||
admin connection and may belong to several tenants, so going through the default
|
||||
connection would silently revoke nothing, or only the keys of the active tenant.
|
||||
"""
|
||||
TenantAPIKey.objects.filter(entity=instance).update(revoked=True)
|
||||
TenantAPIKey.objects.using(MainRouter.admin_db).filter(entity=instance).update(
|
||||
revoked=True
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Membership)
|
||||
@@ -58,8 +66,12 @@ def revoke_membership_api_keys(sender, instance, **kwargs): # noqa: F841
|
||||
|
||||
When a membership is deleted, all API keys created by that user
|
||||
in that tenant should be revoked to prevent further access.
|
||||
|
||||
Uses the admin connection for the same reason as `revoke_user_api_keys`: the RLS
|
||||
policy on `api_keys` denies every row when `api.tenant_id` is unset, which is the
|
||||
case when the membership is removed as a cascade of a user deletion.
|
||||
"""
|
||||
TenantAPIKey.objects.filter(
|
||||
TenantAPIKey.objects.using(MainRouter.admin_db).filter(
|
||||
entity_id=instance.user_id, tenant_id=instance.tenant_id
|
||||
).update(revoked=True)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.36.0
|
||||
version: 1.38.2
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
@@ -6629,8 +6629,10 @@ paths:
|
||||
/api/v1/integrations:
|
||||
get:
|
||||
operationId: api_v1_integrations_list
|
||||
description: Retrieve a list of all configured integrations with options for
|
||||
filtering by various criteria.
|
||||
description: |-
|
||||
Retrieve a list of all configured integrations with options for filtering by various criteria.
|
||||
|
||||
Integrations attached to one or more providers are only returned when the role can access at least one of those providers, and each integration lists only the providers visible to the role. Integrations not attached to any provider, such as Jira, are tenant-wide and are returned for every role.
|
||||
summary: List all integrations
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -6781,7 +6783,8 @@ paths:
|
||||
post:
|
||||
operationId: api_v1_integrations_create
|
||||
description: Register a new integration with the system, providing necessary
|
||||
configuration details.
|
||||
configuration details. Only providers visible to the role can be attached
|
||||
to the integration.
|
||||
summary: Create a new integration
|
||||
tags:
|
||||
- Integration
|
||||
@@ -6810,7 +6813,7 @@ paths:
|
||||
post:
|
||||
operationId: api_v1_integrations_jira_dispatches_create
|
||||
description: |-
|
||||
Send a set of filtered findings to the given integration. At least one finding filter must be provided.
|
||||
Send a set of filtered findings to the given integration. At least one finding filter must be provided. Jira integrations are tenant-wide and do not require unlimited visibility, while the findings sent are limited to the providers the role can access.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
@@ -6883,7 +6886,8 @@ paths:
|
||||
get:
|
||||
operationId: api_v1_integrations_jira_issue_types_retrieve
|
||||
description: Fetch the available issue types from Jira for a given project key
|
||||
and update the integration configuration.
|
||||
and update the integration configuration. Jira integrations are tenant-wide
|
||||
and do not require unlimited visibility.
|
||||
summary: Get available issue types for a Jira project
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -6924,7 +6928,8 @@ paths:
|
||||
get:
|
||||
operationId: api_v1_integrations_retrieve
|
||||
description: Fetch detailed information about a specific integration by its
|
||||
ID.
|
||||
ID. Integrations outside the provider visibility of the role are reported
|
||||
the same way as one that does not exist.
|
||||
summary: Retrieve integration details
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -6978,7 +6983,8 @@ paths:
|
||||
patch:
|
||||
operationId: api_v1_integrations_partial_update
|
||||
description: Modify certain fields of an existing integration without affecting
|
||||
other settings.
|
||||
other settings. Integrations attached to providers outside the visibility
|
||||
of the role cannot be modified by it.
|
||||
summary: Partially update an integration
|
||||
parameters:
|
||||
- in: path
|
||||
@@ -7013,7 +7019,8 @@ paths:
|
||||
description: ''
|
||||
delete:
|
||||
operationId: api_v1_integrations_destroy
|
||||
description: Remove an integration from the system by its ID.
|
||||
description: Remove an integration from the system by its ID. Integrations attached
|
||||
to providers outside the visibility of the role cannot be deleted by it.
|
||||
summary: Delete an integration
|
||||
parameters:
|
||||
- in: path
|
||||
@@ -7033,7 +7040,9 @@ paths:
|
||||
/api/v1/integrations/{id}/connection:
|
||||
post:
|
||||
operationId: api_v1_integrations_connection_create
|
||||
description: Try to verify integration connection
|
||||
description: Try to verify integration connection. Integrations outside the
|
||||
provider visibility of the role are reported the same way as one that does
|
||||
not exist.
|
||||
summary: Check integration connection
|
||||
parameters:
|
||||
- in: path
|
||||
|
||||
@@ -4,8 +4,11 @@ from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.db_router import MainRouter
|
||||
from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship
|
||||
from api.signals import revoke_membership_api_keys, revoke_user_api_keys
|
||||
from conftest import TEST_PASSWORD, get_api_tokens, get_authorization_header
|
||||
from django.db.utils import ConnectionDoesNotExist
|
||||
from django.urls import reverse
|
||||
from drf_simple_apikey.crypto import get_crypto
|
||||
from rest_framework.test import APIClient
|
||||
@@ -625,6 +628,34 @@ class TestAPIKeyErrors:
|
||||
assert response.status_code == 401
|
||||
assert "API Key has been revoked." in response.json()["errors"][0]["detail"]
|
||||
|
||||
def test_orphaned_api_key_rejected(
|
||||
self, create_test_user, tenants_fixture, api_keys_fixture
|
||||
):
|
||||
"""Key whose owning user was deleted returns 401 instead of 500."""
|
||||
client = APIClient()
|
||||
|
||||
api_key = api_keys_fixture[0]
|
||||
# `on_delete=SET_NULL` leaves the key behind with no entity when the owner goes
|
||||
TenantAPIKey.objects.filter(id=api_key.id).update(entity=None)
|
||||
|
||||
api_key_headers = get_api_key_header(api_key._raw_key)
|
||||
response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert (
|
||||
"No entity matching this api key." in response.json()["errors"][0]["detail"]
|
||||
)
|
||||
|
||||
# The orphaned key is revoked on use; retries fail the regular revoked check
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.revoked is True
|
||||
|
||||
retry_response = client.get(reverse("provider-list"), headers=api_key_headers)
|
||||
assert retry_response.status_code == 401
|
||||
assert (
|
||||
"API Key has been revoked." in retry_response.json()["errors"][0]["detail"]
|
||||
)
|
||||
|
||||
def test_non_existent_api_key(self, create_test_user, tenants_fixture):
|
||||
"""Key UUID doesn't exist in database."""
|
||||
client = APIClient()
|
||||
@@ -817,6 +848,93 @@ class TestAPIKeyTenantIsolation:
|
||||
error_detail = response_json["errors"][0]["detail"]
|
||||
assert "revoked" in error_detail.lower()
|
||||
|
||||
def test_deleting_user_revokes_api_keys_in_every_tenant(self, tenants_fixture):
|
||||
"""Deleting a user revokes their keys in all their tenants, not just one."""
|
||||
first_tenant, second_tenant = tenants_fixture[0], tenants_fixture[1]
|
||||
|
||||
test_user = User.objects.create_user(
|
||||
name="multi_tenant_user",
|
||||
email="multi_tenant_user@prowler.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
for tenant in (first_tenant, second_tenant):
|
||||
Membership.objects.create(
|
||||
user=test_user, tenant=tenant, role=Membership.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
first_key, _ = TenantAPIKey.objects.create_api_key(
|
||||
name="Key in first tenant", tenant_id=first_tenant.id, entity=test_user
|
||||
)
|
||||
second_key, _ = TenantAPIKey.objects.create_api_key(
|
||||
name="Key in second tenant", tenant_id=second_tenant.id, entity=test_user
|
||||
)
|
||||
|
||||
test_user.delete()
|
||||
|
||||
first_key.refresh_from_db()
|
||||
second_key.refresh_from_db()
|
||||
assert first_key.revoked is True
|
||||
assert second_key.revoked is True
|
||||
# `on_delete=SET_NULL` orphans the keys, so revoking them is what keeps them
|
||||
# from authenticating
|
||||
assert first_key.entity_id is None
|
||||
assert second_key.entity_id is None
|
||||
|
||||
def test_revoke_user_api_keys_uses_the_admin_connection(
|
||||
self, monkeypatch, tenants_fixture
|
||||
):
|
||||
"""The revocation must not go through the default connection.
|
||||
|
||||
`api_keys` is RLS protected and its policy denies every row when `api.tenant_id`
|
||||
is unset, which is the case while a user is deleted through the admin
|
||||
connection: the update would silently revoke nothing and leave usable orphaned
|
||||
keys behind.
|
||||
|
||||
Pointing `admin_db` at a missing alias is the only way to assert the connection
|
||||
here, because the test suite runs on a single superuser database with
|
||||
`MainRouter.admin_db` patched to "default" (see `conftest.py`), so RLS never
|
||||
applies and both connections are otherwise indistinguishable.
|
||||
"""
|
||||
test_user = User.objects.create_user(
|
||||
name="admin_connection_user",
|
||||
email="admin_connection_user@prowler.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
Membership.objects.create(user=test_user, tenant=tenants_fixture[0])
|
||||
TenantAPIKey.objects.create_api_key(
|
||||
name="Key for admin connection check",
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
entity=test_user,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(MainRouter, "admin_db", "missing_admin_alias")
|
||||
|
||||
with pytest.raises(ConnectionDoesNotExist):
|
||||
revoke_user_api_keys(sender=User, instance=test_user)
|
||||
|
||||
def test_revoke_membership_api_keys_uses_the_admin_connection(
|
||||
self, monkeypatch, tenants_fixture
|
||||
):
|
||||
"""Same as the user deletion case: this receiver also runs as its cascade."""
|
||||
test_user = User.objects.create_user(
|
||||
name="admin_connection_membership_user",
|
||||
email="admin_connection_membership_user@prowler.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
membership = Membership.objects.create(
|
||||
user=test_user, tenant=tenants_fixture[0]
|
||||
)
|
||||
TenantAPIKey.objects.create_api_key(
|
||||
name="Key for membership admin connection check",
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
entity=test_user,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(MainRouter, "admin_db", "missing_admin_alias")
|
||||
|
||||
with pytest.raises(ConnectionDoesNotExist):
|
||||
revoke_membership_api_keys(sender=Membership, instance=membership)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAPIKeyLifecycle:
|
||||
|
||||
@@ -2,11 +2,20 @@ from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from allauth.socialaccount.models import SocialLogin
|
||||
from allauth.account import app_settings as account_app_settings
|
||||
from allauth.account.models import EmailAddress
|
||||
from allauth.core import context
|
||||
from allauth.core.exceptions import ImmediateHttpResponse
|
||||
from allauth.socialaccount import app_settings as socialaccount_app_settings
|
||||
from allauth.socialaccount.internal.flows.login import complete_login
|
||||
from allauth.socialaccount.models import SocialAccount, SocialLogin
|
||||
from api.adapters import ProwlerSocialAccountAdapter
|
||||
from api.db_router import MainRouter
|
||||
from api.db_router import MainRouter, get_write_db_alias
|
||||
from api.models import Invitation, Membership, SAMLConfiguration, Tenant
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import mail
|
||||
from django.db import connections
|
||||
from django.db import router as django_router
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -40,6 +49,7 @@ def _saml_request(rf, organization_slug):
|
||||
def _saml_sociallogin(user):
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.account.pk = None
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.provider.id = "saml"
|
||||
sociallogin.account.extra_data = {}
|
||||
@@ -48,6 +58,59 @@ def _saml_sociallogin(user):
|
||||
return sociallogin
|
||||
|
||||
|
||||
def _oauth_sociallogin(
|
||||
user,
|
||||
*,
|
||||
provider="google",
|
||||
provider_email_verified=True,
|
||||
include_extra_email=True,
|
||||
):
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.account.pk = None
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.provider.id = provider
|
||||
sociallogin.account.extra_data = (
|
||||
{"email": user.email} if include_extra_email else {}
|
||||
)
|
||||
sociallogin.email_addresses = [
|
||||
EmailAddress(
|
||||
email=user.email,
|
||||
verified=provider_email_verified,
|
||||
primary=True,
|
||||
)
|
||||
]
|
||||
sociallogin.user = user
|
||||
sociallogin.connect = MagicMock()
|
||||
return sociallogin
|
||||
|
||||
|
||||
def _real_oauth_sociallogin(user, uid):
|
||||
provider = MagicMock()
|
||||
provider.id = "google"
|
||||
provider.app = None
|
||||
provider.get_settings.return_value = {}
|
||||
return SocialLogin(
|
||||
user=user,
|
||||
account=SocialAccount(
|
||||
provider="google",
|
||||
uid=uid,
|
||||
extra_data={"email": user.email},
|
||||
),
|
||||
email_addresses=[EmailAddress(email=user.email, verified=True, primary=True)],
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
|
||||
def _verify_local_email(user):
|
||||
return EmailAddress.objects.create(
|
||||
user=user,
|
||||
email=user.email,
|
||||
verified=True,
|
||||
primary=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProwlerSocialAccountAdapter:
|
||||
def test_get_user_by_email_returns_user(self, create_test_user):
|
||||
@@ -157,6 +220,7 @@ class TestProwlerSocialAccountAdapter:
|
||||
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.account.pk = None
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.user = MagicMock()
|
||||
sociallogin.user.email = ""
|
||||
@@ -168,25 +232,119 @@ class TestProwlerSocialAccountAdapter:
|
||||
|
||||
sociallogin.connect.assert_not_called()
|
||||
|
||||
def test_pre_social_login_non_saml_links_by_email(self, create_test_user, rf):
|
||||
"""Non-SAML providers (e.g. Google/GitHub) still link to an existing
|
||||
local account by email; the tenant binding only applies to SAML."""
|
||||
def test_pre_social_login_blocks_unverified_local_email(self, create_test_user, rf):
|
||||
"""A verified OAuth email must not claim an unverified local account."""
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
sociallogin = _oauth_sociallogin(create_test_user)
|
||||
|
||||
sociallogin = MagicMock(spec=SocialLogin)
|
||||
sociallogin.account = MagicMock()
|
||||
sociallogin.provider = MagicMock()
|
||||
sociallogin.provider.id = "google"
|
||||
sociallogin.account.extra_data = {"email": create_test_user.email}
|
||||
sociallogin.user = create_test_user
|
||||
sociallogin.connect = MagicMock()
|
||||
with pytest.raises(ImmediateHttpResponse) as exc_info:
|
||||
adapter.pre_social_login(rf.get("/"), sociallogin)
|
||||
|
||||
assert exc_info.value.response.status_code == 403
|
||||
sociallogin.connect.assert_not_called()
|
||||
|
||||
def test_complete_oauth_login_does_not_link_unverified_local_email(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
"""Regression test for the complete pre-hijack account-linking flow."""
|
||||
incoming_user = User(email=create_test_user.email)
|
||||
incoming_user.set_unusable_password()
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
incoming_user,
|
||||
uid="victim-google-account",
|
||||
)
|
||||
request = rf.get("/")
|
||||
request.session = {}
|
||||
|
||||
with pytest.raises(ImmediateHttpResponse) as exc_info:
|
||||
complete_login(request, sociallogin, raises=True)
|
||||
|
||||
assert exc_info.value.response.status_code == 403
|
||||
assert not SocialAccount.objects.filter(
|
||||
provider="google", uid="victim-google-account"
|
||||
).exists()
|
||||
|
||||
def test_pre_social_login_allows_already_connected_account(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
"""Existing provider bindings do not need to relink on every login."""
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
sociallogin = _oauth_sociallogin(create_test_user)
|
||||
sociallogin.account.pk = "existing-social-account"
|
||||
|
||||
adapter.pre_social_login(rf.get("/"), sociallogin)
|
||||
|
||||
call_args = sociallogin.connect.call_args
|
||||
assert call_args is not None
|
||||
_, called_user = call_args[0]
|
||||
assert called_user.email == create_test_user.email
|
||||
sociallogin.connect.assert_not_called()
|
||||
|
||||
def test_pre_social_login_blocks_unverified_provider_email(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
"""An OAuth provider must prove ownership of the matching email."""
|
||||
_verify_local_email(create_test_user)
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
sociallogin = _oauth_sociallogin(
|
||||
create_test_user,
|
||||
provider="github",
|
||||
provider_email_verified=False,
|
||||
)
|
||||
|
||||
with pytest.raises(ImmediateHttpResponse) as exc_info:
|
||||
adapter.pre_social_login(rf.get("/"), sociallogin)
|
||||
|
||||
assert exc_info.value.response.status_code == 403
|
||||
sociallogin.connect.assert_not_called()
|
||||
|
||||
def test_pre_social_login_links_verified_emails(self, create_test_user, rf):
|
||||
_verify_local_email(create_test_user)
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
sociallogin = _oauth_sociallogin(create_test_user)
|
||||
request = rf.get("/")
|
||||
|
||||
adapter.pre_social_login(request, sociallogin)
|
||||
|
||||
sociallogin.connect.assert_called_once_with(request, create_test_user)
|
||||
|
||||
def test_verified_social_account_link_does_not_send_notification(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
_verify_local_email(create_test_user)
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
create_test_user,
|
||||
uid="verified-google-account",
|
||||
)
|
||||
|
||||
request = rf.get("/")
|
||||
with context.request_context(request):
|
||||
ProwlerSocialAccountAdapter().pre_social_login(request, sociallogin)
|
||||
|
||||
assert SocialAccount.objects.filter(
|
||||
provider="google",
|
||||
uid="verified-google-account",
|
||||
user=create_test_user,
|
||||
).exists()
|
||||
assert mail.outbox == []
|
||||
|
||||
def test_pre_social_login_uses_verified_email_missing_from_extra_data(
|
||||
self, create_test_user, rf
|
||||
):
|
||||
"""GitHub can return its verified primary email outside extra_data."""
|
||||
_verify_local_email(create_test_user)
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
sociallogin = _oauth_sociallogin(
|
||||
create_test_user,
|
||||
provider="github",
|
||||
include_extra_email=False,
|
||||
)
|
||||
request = rf.get("/")
|
||||
|
||||
adapter.pre_social_login(request, sociallogin)
|
||||
|
||||
sociallogin.connect.assert_called_once_with(request, create_test_user)
|
||||
|
||||
def test_social_account_linking_settings_are_fail_closed(self):
|
||||
assert not socialaccount_app_settings.EMAIL_AUTHENTICATION
|
||||
assert not socialaccount_app_settings.EMAIL_AUTHENTICATION_AUTO_CONNECT
|
||||
assert not account_app_settings.EMAIL_NOTIFICATIONS
|
||||
|
||||
def test_save_user_social_with_invitation_joins_invited_tenant(
|
||||
self, rf, create_test_user, tenants_fixture
|
||||
@@ -226,6 +384,65 @@ class TestProwlerSocialAccountAdapter:
|
||||
role=Membership.RoleChoices.MEMBER,
|
||||
).exists()
|
||||
|
||||
def test_save_user_routes_initial_allauth_write_to_admin_and_resets_on_error(
|
||||
self, rf
|
||||
):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.get("/")
|
||||
request.session = {}
|
||||
sociallogin = _oauth_sociallogin(
|
||||
User(name="Frank", email="frank-routing@example.com")
|
||||
)
|
||||
|
||||
def fail_after_checking_write_route(*_args, **_kwargs):
|
||||
assert (
|
||||
MainRouter().db_for_write(User, instance=sociallogin.user)
|
||||
== MainRouter.admin_db
|
||||
)
|
||||
raise RuntimeError("Stop after checking the write route.")
|
||||
|
||||
with (
|
||||
patch("api.adapters.super") as mock_super,
|
||||
patch("api.adapters.transaction.atomic"),
|
||||
patch.object(MainRouter, "admin_db", "admin"),
|
||||
pytest.raises(RuntimeError, match="Stop after checking the write route"),
|
||||
):
|
||||
mock_super.return_value.save_user.side_effect = (
|
||||
fail_after_checking_write_route
|
||||
)
|
||||
adapter.save_user(request, sociallogin)
|
||||
|
||||
assert get_write_db_alias() is None
|
||||
|
||||
def test_save_user_rolls_back_all_signup_records_on_downstream_error(self, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.post("/")
|
||||
request.session = {}
|
||||
email = "frank-rollback@example.com"
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
User(name="Frank", email=email),
|
||||
uid="frank-rollback-google-account",
|
||||
)
|
||||
tenants_before = Tenant.objects.count()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.adapters.rls_transaction",
|
||||
side_effect=RuntimeError("Simulated downstream failure."),
|
||||
),
|
||||
pytest.raises(RuntimeError, match="Simulated downstream failure"),
|
||||
):
|
||||
adapter.save_user(request, sociallogin)
|
||||
|
||||
assert not User.objects.filter(email=email).exists()
|
||||
assert not SocialAccount.objects.filter(
|
||||
provider="google",
|
||||
uid="frank-rollback-google-account",
|
||||
).exists()
|
||||
assert not EmailAddress.objects.filter(email=email).exists()
|
||||
assert Tenant.objects.count() == tenants_before
|
||||
assert get_write_db_alias() is None
|
||||
|
||||
def test_save_user_saml_sets_session_flag(self, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.get("/")
|
||||
@@ -246,3 +463,104 @@ class TestProwlerSocialAccountAdapter:
|
||||
mock_super.return_value.save_user.return_value = mock_user
|
||||
adapter.save_user(request, sociallogin)
|
||||
assert request.session["saml_user_created"] == "123"
|
||||
|
||||
|
||||
@pytest.mark.requires_test_admin_alias
|
||||
@pytest.mark.django_db(transaction=True, databases=["default", "admin"])
|
||||
class TestProwlerSocialAccountAdapterMultiDatabase:
|
||||
@staticmethod
|
||||
def _production_router():
|
||||
return patch.object(django_router, "routers", [MainRouter()])
|
||||
|
||||
def test_save_user_rolls_back_across_production_database_aliases(self, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.post("/")
|
||||
request.session = {}
|
||||
email = "frank-multidb-rollback@example.com"
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
User(name="Frank", email=email),
|
||||
uid="frank-multidb-rollback-google-account",
|
||||
)
|
||||
tenants_before = Tenant.objects.using("admin").count()
|
||||
|
||||
assert connections["default"] is not connections["admin"]
|
||||
assert (
|
||||
connections["default"].settings_dict["NAME"]
|
||||
== connections["admin"].settings_dict["NAME"]
|
||||
)
|
||||
|
||||
def fail_after_allauth_save(*_args, **_kwargs):
|
||||
assert sociallogin.user._state.db == MainRouter.admin_db
|
||||
assert connections["default"].get_autocommit()
|
||||
assert not connections["admin"].get_autocommit()
|
||||
raise RuntimeError("Simulated downstream failure.")
|
||||
|
||||
with (
|
||||
patch.object(MainRouter, "admin_db", "admin"),
|
||||
self._production_router(),
|
||||
patch("api.adapters.rls_transaction", side_effect=fail_after_allauth_save),
|
||||
pytest.raises(RuntimeError, match="Simulated downstream failure"),
|
||||
):
|
||||
adapter.save_user(request, sociallogin)
|
||||
|
||||
assert connections["default"].get_autocommit()
|
||||
assert connections["admin"].get_autocommit()
|
||||
assert not User.objects.using("default").filter(email=email).exists()
|
||||
assert not User.objects.using("admin").filter(email=email).exists()
|
||||
assert (
|
||||
not SocialAccount.objects.using("admin")
|
||||
.filter(
|
||||
provider="google",
|
||||
uid="frank-multidb-rollback-google-account",
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
assert not EmailAddress.objects.using("admin").filter(email=email).exists()
|
||||
assert Tenant.objects.using("admin").count() == tenants_before
|
||||
assert get_write_db_alias() is None
|
||||
|
||||
def test_save_user_commits_complete_signup_across_production_aliases(self, rf):
|
||||
adapter = ProwlerSocialAccountAdapter()
|
||||
request = rf.post("/")
|
||||
request.session = {}
|
||||
email = "frank-multidb-success@example.com"
|
||||
sociallogin = _real_oauth_sociallogin(
|
||||
User(name="Frank", email=email),
|
||||
uid="frank-multidb-success-google-account",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(MainRouter, "admin_db", "admin"),
|
||||
self._production_router(),
|
||||
):
|
||||
user = adapter.save_user(request, sociallogin)
|
||||
|
||||
user = User.objects.using("admin").get(id=user.id)
|
||||
assert user.email == email
|
||||
assert (
|
||||
SocialAccount.objects.using("admin")
|
||||
.filter(
|
||||
user_id=user.id,
|
||||
provider="google",
|
||||
uid="frank-multidb-success-google-account",
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
assert (
|
||||
EmailAddress.objects.using("admin")
|
||||
.filter(
|
||||
user_id=user.id,
|
||||
email=email,
|
||||
verified=True,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
assert (
|
||||
Membership.objects.using("admin")
|
||||
.filter(
|
||||
user_id=user.id,
|
||||
role=Membership.RoleChoices.OWNER,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
assert get_write_db_alias() is None
|
||||
|
||||
@@ -154,6 +154,88 @@ def test_execute_query_serializes_graph(
|
||||
assert result["relationships"][0]["label"] == "OWNS"
|
||||
|
||||
|
||||
def test_execute_query_injects_provider_label_when_migrated(
|
||||
attack_paths_query_definition_factory,
|
||||
sink_backend_stub,
|
||||
):
|
||||
# On migrated graphs the predefined cypher must be scoped with the
|
||||
# provider label so the planner seeds from the label index instead of a
|
||||
# global label scan (the Neptune cartesian/timeout fix).
|
||||
definition = attack_paths_query_definition_factory(
|
||||
id="aws-iam",
|
||||
name="IAM",
|
||||
short_description="Short desc",
|
||||
description="",
|
||||
cypher="MATCH (aws:AWSAccount)--(target_role:AWSRole) RETURN target_role",
|
||||
parameters=[],
|
||||
)
|
||||
provider_id = "test-provider-123"
|
||||
plabel = get_provider_label(provider_id)
|
||||
parameters = {"provider_uid": "123"}
|
||||
|
||||
graph_result = MagicMock()
|
||||
graph_result.nodes = []
|
||||
graph_result.relationships = []
|
||||
sink_backend_stub.execute_read_query.return_value = graph_result
|
||||
|
||||
# Injection is gated on `is_migrated`, not the sink (it is a pure string
|
||||
# transform), so `neo4j` exercises the same code path as Neptune here.
|
||||
views_helpers.execute_query(
|
||||
"db-tenant-test",
|
||||
definition,
|
||||
parameters,
|
||||
provider_id=provider_id,
|
||||
scan=MagicMock(is_migrated=True, sink_backend="neo4j"),
|
||||
)
|
||||
|
||||
executed_cypher = sink_backend_stub.execute_read_query.call_args[0][1]
|
||||
assert executed_cypher != definition.cypher
|
||||
# Both node patterns are scoped - not just one. Asserting the exact rewrite
|
||||
# (rather than `f":{plabel}" in executed_cypher`, which a partial injection
|
||||
# would still satisfy) proves every node got the label and that injection
|
||||
# inserted labels and nothing else.
|
||||
assert executed_cypher == (
|
||||
f"MATCH (aws:AWSAccount:{plabel})--(target_role:AWSRole:{plabel}) "
|
||||
"RETURN target_role"
|
||||
)
|
||||
# Parameters are passed through untouched.
|
||||
assert sink_backend_stub.execute_read_query.call_args[0][2] == parameters
|
||||
|
||||
|
||||
def test_execute_query_does_not_inject_label_when_deprecated(
|
||||
attack_paths_query_definition_factory,
|
||||
sink_backend_stub,
|
||||
):
|
||||
# The pre-cutover legacy catalog runs on the old sink and is removed after
|
||||
# the Neptune cutover, so it must run verbatim (no injection).
|
||||
definition = attack_paths_query_definition_factory(
|
||||
id="aws-iam",
|
||||
name="IAM",
|
||||
short_description="Short desc",
|
||||
description="",
|
||||
cypher="MATCH (aws:AWSAccount)--(target_role:AWSRole) RETURN target_role",
|
||||
parameters=[],
|
||||
)
|
||||
parameters = {"provider_uid": "123"}
|
||||
|
||||
graph_result = MagicMock()
|
||||
graph_result.nodes = []
|
||||
graph_result.relationships = []
|
||||
sink_backend_stub.execute_read_query.return_value = graph_result
|
||||
|
||||
views_helpers.execute_query(
|
||||
"db-tenant-test",
|
||||
definition,
|
||||
parameters,
|
||||
provider_id="test-provider-123",
|
||||
scan=MagicMock(is_migrated=False, sink_backend="neo4j"),
|
||||
)
|
||||
|
||||
sink_backend_stub.execute_read_query.assert_called_once_with(
|
||||
"db-tenant-test", definition.cypher, parameters
|
||||
)
|
||||
|
||||
|
||||
def test_execute_query_wraps_graph_errors(
|
||||
attack_paths_query_definition_factory,
|
||||
sink_backend_stub,
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Structural validation tests for Attack Paths query definitions.
|
||||
|
||||
These tests verify that each query in the AWS_QUERIES registry meets the
|
||||
schema and convention requirements documented in
|
||||
`docs/developer-guide/attack-paths-queries.mdx` without requiring a live
|
||||
graph connection. They deliberately assert the conventions that keep queries
|
||||
functional and Neptune-compatible: list-typed policy properties are reached
|
||||
through `HAS_*` child-item traversals (never read as node fields), predicate
|
||||
functions unsupported on Neptune (`any`/`all`/`none`, regex `=~`) are absent,
|
||||
the finding probe is typed and filters only on `status`, and the `RETURN`
|
||||
shape preserves the `paths, dpf, dpfr` contract.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from api.attack_paths.queries.aws import (
|
||||
AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY,
|
||||
AWS_QUERIES,
|
||||
AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION,
|
||||
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST,
|
||||
AWS_STS_PRIVESC_WILDCARD_TRUST,
|
||||
)
|
||||
from api.attack_paths.queries.types import AttackPathsQueryDefinition
|
||||
|
||||
# The pathfinding.cloud privilege-escalation queries added for PROWLER-2278.
|
||||
NEW_PATHFINDING_QUERIES = [
|
||||
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST,
|
||||
AWS_STS_PRIVESC_WILDCARD_TRUST,
|
||||
AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY,
|
||||
AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION,
|
||||
]
|
||||
|
||||
# Cypher keywords that indicate a mutating query (not allowed; queries are read-only).
|
||||
MUTATING_KEYWORDS = re.compile(
|
||||
r"\b(CREATE|MERGE|SET|DELETE|REMOVE|DETACH)\b", re.IGNORECASE
|
||||
)
|
||||
|
||||
# CALL subquery: unsupported by Neptune openCypher.
|
||||
CALL_SUBQUERY_PATTERN = re.compile(r"\bCALL\s*\{", re.IGNORECASE)
|
||||
|
||||
# Predicate functions that are not part of the openCypher spec and fail on Neptune.
|
||||
NEPTUNE_UNSUPPORTED_PREDICATES = re.compile(r"\b(any|all|none)\s*\(", re.IGNORECASE)
|
||||
|
||||
# The list-typed policy properties that are exploded into child item nodes at sync
|
||||
# time and popped off the parent, so reading them as a field always yields null.
|
||||
NORMALIZED_STATEMENT_FIELDS = ("action", "resource", "notaction", "notresource")
|
||||
|
||||
|
||||
class TestNewPathfindingQueriesRegistered:
|
||||
"""Every new query is present in the AWS_QUERIES registry."""
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_query_in_registry(self, query):
|
||||
assert query in AWS_QUERIES
|
||||
|
||||
|
||||
class TestNewPathfindingQueriesSchema:
|
||||
"""Required fields and naming conventions for each new query."""
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_is_query_definition_instance(self, query):
|
||||
assert isinstance(query, AttackPathsQueryDefinition)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_id_is_kebab_case(self, query):
|
||||
assert re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", query.id), (
|
||||
f"Query id '{query.id}' is not kebab-case"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_id_starts_with_aws(self, query):
|
||||
assert query.id.startswith("aws-")
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_provider_is_aws(self, query):
|
||||
assert query.provider == "aws"
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_has_name(self, query):
|
||||
assert query.name and len(query.name) > 5
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_has_short_description(self, query):
|
||||
assert query.short_description and len(query.short_description) > 10
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_has_description(self, query):
|
||||
assert query.description and len(query.description) > 20
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_has_attribution(self, query):
|
||||
assert query.attribution is not None
|
||||
assert "pathfinding.cloud" in query.attribution.text
|
||||
assert query.attribution.link.startswith("https://pathfinding.cloud/paths/")
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_parameters_is_list(self, query):
|
||||
assert isinstance(query.parameters, list)
|
||||
|
||||
|
||||
class TestNewPathfindingQueriesCypher:
|
||||
"""Cypher content, conventions, and Neptune compatibility."""
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_not_empty(self, query):
|
||||
assert query.cypher and len(query.cypher.strip()) > 0
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_under_10000_chars(self, query):
|
||||
assert len(query.cypher) < 10000, (
|
||||
f"Query {query.id} exceeds 10,000 character limit "
|
||||
f"({len(query.cypher)} chars)"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_uses_provider_uid_parameter(self, query):
|
||||
assert "$provider_uid" in query.cypher, (
|
||||
f"Query {query.id} missing $provider_uid parameter"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_finding_label_interpolated(self, query):
|
||||
# The f-string should have interpolated PROWLER_FINDING_LABEL already.
|
||||
assert "PROWLER_FINDING_LABEL" not in query.cypher, (
|
||||
f"Query {query.id} has unresolved PROWLER_FINDING_LABEL "
|
||||
"(f-string not applied)"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_finding_probe_is_typed_and_status_scoped(self, query):
|
||||
# The finding probe must be typed HAS_FINDING (so Neptune applies an inline
|
||||
# edge filter) and gate on FAIL status only. ProwlerFinding nodes carry no
|
||||
# provider_uid property, so a probe that filters on it never matches.
|
||||
assert re.search(
|
||||
r"-\[pfr:HAS_FINDING\]-\(pf:ProwlerFinding \{status: 'FAIL'\}\)",
|
||||
query.cypher,
|
||||
), f"Query {query.id} does not use the typed, status-scoped finding probe"
|
||||
assert "provider_uid:$provider_uid}" not in query.cypher.replace(" ", ""), (
|
||||
f"Query {query.id} filters the finding node on a non-existent "
|
||||
"provider_uid property"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_is_read_only(self, query):
|
||||
cypher_no_comments = _strip_comment_lines(query.cypher)
|
||||
match = MUTATING_KEYWORDS.search(cypher_no_comments)
|
||||
assert match is None, (
|
||||
f"Query {query.id} contains mutating keyword: '{match.group()}'"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_no_call_subquery(self, query):
|
||||
assert not CALL_SUBQUERY_PATTERN.search(query.cypher), (
|
||||
f"Query {query.id} uses a CALL subquery (not Neptune-compatible)"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_no_neptune_unsupported_predicates(self, query):
|
||||
match = NEPTUNE_UNSUPPORTED_PREDICATES.search(query.cypher)
|
||||
assert match is None, (
|
||||
f"Query {query.id} uses '{match.group().strip()}' predicate function; "
|
||||
"use size([x IN list WHERE pred]) > 0 for Neptune compatibility"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_no_regex_operator(self, query):
|
||||
assert "=~" not in query.cypher, (
|
||||
f"Query {query.id} uses the regex operator '=~'; "
|
||||
"use CONTAINS / STARTS WITH for Neptune compatibility"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_does_not_read_normalized_list_fields(self, query):
|
||||
# action/resource/notaction/notresource are materialized as child item nodes
|
||||
# and popped off AWSPolicyStatement, so `stmt.action` etc. are always null.
|
||||
for field in NORMALIZED_STATEMENT_FIELDS:
|
||||
assert not re.search(rf"\.{field}\b", query.cypher), (
|
||||
f"Query {query.id} reads the normalized list field "
|
||||
f"'.{field}' as a node property; traverse the HAS_"
|
||||
f"{field.upper()} edge to the child item node instead"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_preserves_return_contract(self, query):
|
||||
assert re.search(
|
||||
r"RETURN paths, collect\(DISTINCT pf\) as dpf, "
|
||||
r"collect\(DISTINCT pfr\) as dpfr",
|
||||
query.cypher,
|
||||
), f"Query {query.id} does not preserve the 'paths, dpf, dpfr' RETURN contract"
|
||||
|
||||
@pytest.mark.parametrize("query", NEW_PATHFINDING_QUERIES, ids=lambda q: q.id)
|
||||
def test_cypher_anchored_on_account(self, query):
|
||||
assert "(aws:AWSAccount {id: $provider_uid})" in query.cypher, (
|
||||
f"Query {query.id} is not anchored on the AWSAccount node"
|
||||
)
|
||||
|
||||
|
||||
class TestNewPathfindingQueriesAccuracy:
|
||||
"""Query-specific contracts that prevent known false positives."""
|
||||
|
||||
def test_wildcard_trust_is_presented_as_a_manual_review_candidate(self):
|
||||
query = AWS_STS_PRIVESC_WILDCARD_TRUST
|
||||
text = f"{query.name} {query.short_description} {query.description}".lower()
|
||||
assert all(
|
||||
word in text
|
||||
for word in ("potential", "effect", "condition", "manual review")
|
||||
)
|
||||
|
||||
def test_permissions_boundary_removal_is_scoped_to_the_same_user(self):
|
||||
query = AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY
|
||||
assert "(principal:AWSUser)" in query.cypher
|
||||
assert (
|
||||
"(stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)"
|
||||
in query.cypher
|
||||
)
|
||||
assert "principal.arn" in query.cypher
|
||||
assert "manual review" in query.description.lower()
|
||||
|
||||
def test_permission_set_escalation_requires_global_resources(self):
|
||||
query = AWS_SSO_PRIVESC_PERMISSION_SET_ESCALATION
|
||||
for suffix in ("", "2", "3"):
|
||||
resource_match = (
|
||||
f"(stmt{suffix})-[:HAS_RESOURCE]->"
|
||||
f"(res{suffix}:AWSPolicyStatementResourceItem)"
|
||||
)
|
||||
assert resource_match in query.cypher
|
||||
assert f"WHERE res{suffix}.value = '*'" in query.cypher
|
||||
|
||||
|
||||
class TestAllQueriesUniqueIds:
|
||||
"""No duplicate IDs in the full registry."""
|
||||
|
||||
def test_no_duplicate_ids_in_aws_queries(self):
|
||||
ids = [q.id for q in AWS_QUERIES]
|
||||
duplicates = sorted({qid for qid in ids if ids.count(qid) > 1})
|
||||
assert not duplicates, f"Duplicate query IDs found: {duplicates}"
|
||||
|
||||
|
||||
def _strip_comment_lines(cypher: str) -> str:
|
||||
"""Drop `//` comment lines so keyword scans ignore prose in comments."""
|
||||
return "\n".join(
|
||||
line for line in cypher.split("\n") if not line.strip().startswith("//")
|
||||
)
|
||||
@@ -4,11 +4,17 @@ from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.authentication import SSEAuthentication, TenantAPIKeyAuthentication
|
||||
from api.authentication import (
|
||||
OrphanedAPIKeyError,
|
||||
SSEAuthentication,
|
||||
TenantAPIKeyAuthentication,
|
||||
)
|
||||
from api.db_router import MainRouter
|
||||
from api.models import TenantAPIKey
|
||||
from django.db import connections
|
||||
from django.db.models.query import QuerySet
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
|
||||
@@ -38,13 +44,12 @@ class TestTenantAPIKeyAuthentication:
|
||||
request = request_factory.get("/")
|
||||
|
||||
# Call the method
|
||||
entity, auth_dict = auth_backend._authenticate_credentials(
|
||||
request, encrypted_key
|
||||
)
|
||||
validated_key = auth_backend._authenticate_credentials(request, encrypted_key)
|
||||
|
||||
# Verify that the entity is the user associated with the API key
|
||||
assert entity == api_key.entity
|
||||
assert entity.id == api_key.entity.id
|
||||
assert validated_key.id == api_key.id
|
||||
assert validated_key.entity == api_key.entity
|
||||
assert validated_key.entity.id == api_key.entity.id
|
||||
|
||||
def test_authenticate_credentials_restores_manager_on_success(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
@@ -231,6 +236,120 @@ class TestTenantAPIKeyAuthentication:
|
||||
|
||||
assert str(exc_info.value.detail) == "This API Key has been revoked."
|
||||
|
||||
def test_authenticate_credentials_orphaned_api_key(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test credential validation fails when the owning user no longer exists."""
|
||||
api_key = api_keys_fixture[0]
|
||||
_, encrypted_key = api_key._raw_key.split(TenantAPIKey.objects.separator, 1)
|
||||
|
||||
# `entity` is what `on_delete=SET_NULL` leaves behind when the owner is deleted
|
||||
TenantAPIKey.objects.filter(id=api_key.id).update(entity=None)
|
||||
|
||||
request = request_factory.get("/")
|
||||
|
||||
with pytest.raises(OrphanedAPIKeyError):
|
||||
auth_backend._authenticate_credentials(request, encrypted_key)
|
||||
|
||||
# The orphaned key is revoked on use, so it stops showing up as active
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.revoked is True
|
||||
|
||||
def test_authenticate_orphaned_api_key(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test authentication fails with a key whose owning user was deleted.
|
||||
|
||||
Regression test: this used to raise `AttributeError: 'NoneType' object has no
|
||||
attribute 'id'` while building the auth dict, which DRF re-raises as
|
||||
`WrappedAttributeError` and turns into a 500 instead of a 401.
|
||||
"""
|
||||
api_key = api_keys_fixture[0]
|
||||
raw_key = api_key._raw_key
|
||||
|
||||
TenantAPIKey.objects.filter(id=api_key.id).update(entity=None)
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
|
||||
|
||||
with pytest.raises(AuthenticationFailed) as exc_info:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
assert str(exc_info.value.detail) == "No entity matching this api key."
|
||||
|
||||
# The orphaned key is revoked on use; retries fail the regular revoked check
|
||||
api_key.refresh_from_db()
|
||||
assert api_key.revoked is True
|
||||
|
||||
with pytest.raises(AuthenticationFailed) as exc_info:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
assert str(exc_info.value.detail) == "This API Key has been revoked."
|
||||
|
||||
def test_authenticate_reads_the_api_key_once_under_a_row_lock(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test the API key is read a single time and the row is locked.
|
||||
|
||||
Validation, the `last_used_at` update and the claims must all come from the
|
||||
same authoritative row: a second, unlocked lookup would reopen the window
|
||||
where a key revoked in between still authenticates.
|
||||
"""
|
||||
api_key = api_keys_fixture[0]
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
|
||||
|
||||
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
api_key_selects = [
|
||||
query["sql"]
|
||||
for query in captured.captured_queries
|
||||
if query["sql"].startswith("SELECT") and '"api_keys"' in query["sql"]
|
||||
]
|
||||
|
||||
assert len(api_key_selects) == 1
|
||||
assert "FOR UPDATE" in api_key_selects[0]
|
||||
|
||||
def test_authenticate_ignores_revocation_after_the_locked_read(
|
||||
self, auth_backend, api_keys_fixture, request_factory
|
||||
):
|
||||
"""Test the claims describe the row that was validated, not a later state.
|
||||
|
||||
Regression test: the key used to be looked up again to build the auth dict,
|
||||
without rechecking `revoked` or `entity`. A key revoked or orphaned between
|
||||
both reads still authenticated, and the claims came from that stale row. With
|
||||
a single locked read the write below cannot land mid-authentication, and the
|
||||
revocation only takes effect on the next request.
|
||||
"""
|
||||
api_key = api_keys_fixture[0]
|
||||
entity_at_validation = api_key.entity
|
||||
original_save = TenantAPIKey.save
|
||||
|
||||
def revoke_and_orphan_before_saving(instance, *args, **kwargs):
|
||||
# Runs after validation, right before the claims are built: the exact
|
||||
# window a concurrent revocation or user deletion used to slip into
|
||||
TenantAPIKey.objects.filter(id=api_key.id).update(revoked=True, entity=None)
|
||||
return original_save(instance, *args, **kwargs)
|
||||
|
||||
request = request_factory.get("/")
|
||||
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
|
||||
|
||||
with patch.object(TenantAPIKey, "save", revoke_and_orphan_before_saving):
|
||||
entity, auth_dict = auth_backend.authenticate(request)
|
||||
|
||||
assert entity == entity_at_validation
|
||||
assert auth_dict["sub"] == str(entity_at_validation.id)
|
||||
assert auth_dict["tenant_id"] == str(api_key.tenant_id)
|
||||
assert auth_dict["api_key_prefix"] == api_key.prefix
|
||||
|
||||
# The revoked key is rejected from the next request on
|
||||
with pytest.raises(AuthenticationFailed) as exc_info:
|
||||
auth_backend.authenticate(request)
|
||||
|
||||
assert str(exc_info.value.detail) == "This API Key has been revoked."
|
||||
|
||||
def test_authenticate_expired_api_key(
|
||||
self, auth_backend, create_test_user, tenants_fixture, request_factory
|
||||
):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for the Cypher sanitizer (validation + provider-label injection)."""
|
||||
|
||||
import re
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -22,6 +23,38 @@ def _inject(cypher: str) -> str:
|
||||
return inject_provider_label(cypher, PROVIDER_ID)
|
||||
|
||||
|
||||
# String literals and line comments can contain parentheses that look like node
|
||||
# patterns; strip them first. Implemented here independently of the sanitizer so
|
||||
# the node count is an oracle for the injector rather than a copy of its regexes.
|
||||
_STRING_OR_COMMENT_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*")
|
||||
|
||||
# A node pattern is `(`, not preceded by a word char (which would make it a
|
||||
# function call), wrapping an optional variable, zero or more `:Label`s and an
|
||||
# optional `{property map}` - and nothing else, which excludes parenthesized
|
||||
# expressions such as `(a OR b)` in a WHERE clause.
|
||||
_NODE_PATTERN_RE = re.compile(
|
||||
r"(?<![\w`])\("
|
||||
r"\s*(?:[a-zA-Z_]\w*)?"
|
||||
r"(?:\s*:\s*(?:`[^`]*`|[a-zA-Z_]\w*))*"
|
||||
r"(?:\s*\{[^{}]*\})?"
|
||||
r"\s*\)"
|
||||
)
|
||||
|
||||
|
||||
def _count_node_patterns(cypher: str) -> int:
|
||||
"""Count node patterns in a query, independently of the injector.
|
||||
|
||||
Injection appends exactly one provider label per node pattern, so the
|
||||
number of injected labels must equal this count - proving *every* node is
|
||||
scoped, not just one."""
|
||||
stripped = _STRING_OR_COMMENT_RE.sub("", cypher)
|
||||
return sum(
|
||||
1
|
||||
for match in _NODE_PATTERN_RE.finditer(stripped)
|
||||
if match.group(0)[1:-1].strip()
|
||||
)
|
||||
|
||||
|
||||
def test_generic_inject_label_reuses_provider_injection_pipeline():
|
||||
result = inject_label("MATCH (n:AWSRole)--(m) RETURN n, m", "_Tenant_test")
|
||||
|
||||
@@ -427,3 +460,66 @@ class TestValidation:
|
||||
)
|
||||
def test_allows_clean_queries(self, cypher):
|
||||
validate_custom_query(cypher)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Predefined-catalog injection (Option 1: label-scoped predefined queries)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _all_predefined_queries():
|
||||
"""Every predefined query in the migrated catalog, as (id, cypher)."""
|
||||
from api.attack_paths.queries.registry import _QUERY_DEFINITIONS
|
||||
|
||||
return [
|
||||
(definition.id, definition.cypher)
|
||||
for definitions in _QUERY_DEFINITIONS.values()
|
||||
for definition in definitions
|
||||
]
|
||||
|
||||
|
||||
_PREDEFINED_QUERIES = _all_predefined_queries()
|
||||
|
||||
|
||||
class TestPredefinedCatalogInjection:
|
||||
"""`execute_query` injects the provider label into predefined queries on
|
||||
migrated graphs. The injection must be *lossless* for every catalog query:
|
||||
it may only insert `:_Provider_{uuid}` tokens and must not otherwise alter
|
||||
the cypher (which would corrupt a hand-authored query). This runs over the
|
||||
whole catalog so a regex regression is caught for all queries at once.
|
||||
|
||||
Injection is a pure string transform, so it is sink-independent (the same
|
||||
result is sent to Neo4j and Neptune)."""
|
||||
|
||||
def test_catalog_is_not_empty(self):
|
||||
# Guard against the parametrized tests silently covering nothing.
|
||||
assert len(_PREDEFINED_QUERIES) > 0
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cypher",
|
||||
[cypher for _, cypher in _PREDEFINED_QUERIES],
|
||||
ids=[query_id for query_id, _ in _PREDEFINED_QUERIES],
|
||||
)
|
||||
def test_injection_is_lossless(self, cypher):
|
||||
injected = _inject(cypher)
|
||||
|
||||
# Every node pattern is scoped - not just one. A partial-injection
|
||||
# regression that missed some nodes would still satisfy a bare
|
||||
# `f":{LABEL}" in injected` check, so assert the label count matches the
|
||||
# number of node patterns.
|
||||
assert injected.count(f":{LABEL}") == _count_node_patterns(cypher)
|
||||
# Stripping the injected tokens restores the query verbatim, proving
|
||||
# injection changed nothing but the labels.
|
||||
assert injected.replace(f":{LABEL}", "") == cypher
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cypher",
|
||||
[cypher for _, cypher in _PREDEFINED_QUERIES],
|
||||
ids=[query_id for query_id, _ in _PREDEFINED_QUERIES],
|
||||
)
|
||||
def test_injection_preserves_parameter_placeholders(self, cypher):
|
||||
# Label injection must never touch `$param` bindings.
|
||||
original_params = sorted(set(re.findall(r"\$\w+", cypher)))
|
||||
injected_params = sorted(set(re.findall(r"\$\w+", _inject(cypher))))
|
||||
|
||||
assert injected_params == original_params
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from api.db_router import MainRouter
|
||||
from api.db_router import (
|
||||
MainRouter,
|
||||
get_write_db_alias,
|
||||
reset_write_db_alias,
|
||||
set_write_db_alias,
|
||||
write_db_alias,
|
||||
)
|
||||
from api.rls import Tenant
|
||||
from config.django.base import DATABASE_ROUTERS as PROD_DATABASE_ROUTERS
|
||||
from django.conf import settings
|
||||
@@ -26,6 +32,66 @@ class TestMainDatabaseRouter:
|
||||
assert router.allow_migrate_model(MainRouter.admin_db, api_model)
|
||||
assert not router.allow_migrate_model("default", api_model)
|
||||
|
||||
def test_scoped_write_alias_routes_api_models(self, router):
|
||||
token = set_write_db_alias(MainRouter.admin_db)
|
||||
try:
|
||||
assert get_write_db_alias() == MainRouter.admin_db
|
||||
assert router.db_for_write(Tenant) == MainRouter.admin_db
|
||||
finally:
|
||||
reset_write_db_alias(token)
|
||||
|
||||
assert get_write_db_alias() is None
|
||||
assert router.db_for_write(Tenant) == "default"
|
||||
|
||||
def test_scoped_write_alias_restores_nested_context(self, router):
|
||||
outer_token = set_write_db_alias("outer")
|
||||
try:
|
||||
assert router.db_for_write(Tenant) == "outer"
|
||||
|
||||
inner_token = set_write_db_alias(MainRouter.admin_db)
|
||||
try:
|
||||
assert router.db_for_write(Tenant) == MainRouter.admin_db
|
||||
finally:
|
||||
reset_write_db_alias(inner_token)
|
||||
|
||||
assert router.db_for_write(Tenant) == "outer"
|
||||
finally:
|
||||
reset_write_db_alias(outer_token)
|
||||
|
||||
assert get_write_db_alias() is None
|
||||
assert router.db_for_write(Tenant) == "default"
|
||||
|
||||
def test_scoped_write_alias_does_not_override_admin_models(self, router):
|
||||
token = set_write_db_alias("other")
|
||||
try:
|
||||
assert (
|
||||
router.db_for_write(MigrationRecorder.Migration) == MainRouter.admin_db
|
||||
)
|
||||
finally:
|
||||
reset_write_db_alias(token)
|
||||
|
||||
assert get_write_db_alias() is None
|
||||
|
||||
def test_write_db_alias_context_manager_resets_after_error(self, router):
|
||||
fail = Mock(side_effect=RuntimeError("Simulated failure"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="Simulated failure"):
|
||||
with write_db_alias(MainRouter.admin_db):
|
||||
assert get_write_db_alias() == MainRouter.admin_db
|
||||
assert router.db_for_write(Tenant) == MainRouter.admin_db
|
||||
fail()
|
||||
|
||||
fail.assert_called_once_with()
|
||||
assert get_write_db_alias() is None
|
||||
assert router.db_for_write(Tenant) == "default"
|
||||
|
||||
def test_write_db_alias_context_manager_ignores_empty_alias(self, router):
|
||||
with write_db_alias(None):
|
||||
assert get_write_db_alias() is None
|
||||
assert router.db_for_write(Tenant) == "default"
|
||||
|
||||
assert get_write_db_alias() is None
|
||||
|
||||
def test_router_django_models(self, router):
|
||||
assert router.db_for_read(MigrationRecorder.Migration) == MainRouter.admin_db
|
||||
assert not router.db_for_read(MigrationRecorder.Migration) == "default"
|
||||
|
||||
@@ -2,11 +2,12 @@ import uuid
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from api.db_utils import POSTGRES_TENANT_VAR, SET_CONFIG_QUERY
|
||||
from api.decorators import handle_provider_deletion, set_tenant
|
||||
from api.exceptions import ProviderDeletedException
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import DatabaseError, IntegrityError
|
||||
from django.db import DEFAULT_DB_ALIAS, DatabaseError, IntegrityError
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -204,6 +205,106 @@ class TestHandleProviderDeletionDecorator:
|
||||
with pytest.raises(DatabaseError):
|
||||
task_func(tenant_id=str(tenant.id), provider_id=str(provider.id))
|
||||
|
||||
@patch("api.decorators.rls_transaction")
|
||||
@patch("api.decorators.Provider.objects.filter")
|
||||
def test_graph_database_error_provider_missing_or_soft_deleted(
|
||||
self, mock_provider_filter, mock_rls, tenants_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider_id = str(uuid.uuid4())
|
||||
|
||||
mock_rls.return_value.__enter__ = lambda s: None
|
||||
mock_rls.return_value.__exit__ = lambda s, *args: None
|
||||
mock_provider_filter.return_value.exists.return_value = False
|
||||
|
||||
@handle_provider_deletion
|
||||
def task_func(**kwargs):
|
||||
raise GraphDatabaseQueryException("Temporary database not found")
|
||||
|
||||
with pytest.raises(ProviderDeletedException):
|
||||
task_func(tenant_id=str(tenant.id), provider_id=provider_id)
|
||||
|
||||
@patch("api.decorators.rls_transaction")
|
||||
@patch("api.decorators.Tenant.objects.filter")
|
||||
@patch("api.decorators.Provider.objects.filter")
|
||||
def test_graph_database_error_tenant_missing(
|
||||
self, mock_provider_filter, mock_tenant_filter, mock_rls, tenants_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider_id = str(uuid.uuid4())
|
||||
|
||||
mock_rls.return_value.__enter__ = lambda s: None
|
||||
mock_rls.return_value.__exit__ = lambda s, *args: None
|
||||
mock_provider_filter.return_value.exists.return_value = True
|
||||
mock_tenant_filter.return_value.exists.return_value = False
|
||||
|
||||
@handle_provider_deletion
|
||||
def task_func(**kwargs):
|
||||
raise GraphDatabaseQueryException("Temporary database not found")
|
||||
|
||||
with pytest.raises(ProviderDeletedException):
|
||||
task_func(tenant_id=str(tenant.id), provider_id=provider_id)
|
||||
|
||||
@patch("api.decorators.rls_transaction")
|
||||
@patch("api.decorators.Membership.objects.filter")
|
||||
@patch("api.decorators.Tenant.objects.filter")
|
||||
@patch("api.decorators.Provider.objects.filter")
|
||||
def test_graph_database_error_tenant_without_memberships(
|
||||
self,
|
||||
mock_provider_filter,
|
||||
mock_tenant_filter,
|
||||
mock_membership_filter,
|
||||
mock_rls,
|
||||
tenants_fixture,
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider_id = str(uuid.uuid4())
|
||||
|
||||
mock_rls.return_value.__enter__ = lambda s: None
|
||||
mock_rls.return_value.__exit__ = lambda s, *args: None
|
||||
mock_provider_filter.return_value.exists.return_value = True
|
||||
mock_tenant_filter.return_value.exists.return_value = True
|
||||
mock_membership_filter.return_value.exists.return_value = False
|
||||
|
||||
@handle_provider_deletion
|
||||
def task_func(**kwargs):
|
||||
raise GraphDatabaseQueryException("Temporary database not found")
|
||||
|
||||
with pytest.raises(ProviderDeletedException):
|
||||
task_func(tenant_id=str(tenant.id), provider_id=provider_id)
|
||||
|
||||
@patch("api.decorators.rls_transaction")
|
||||
@patch("api.decorators.Membership.objects.filter")
|
||||
@patch("api.decorators.Tenant.objects.filter")
|
||||
@patch("api.decorators.Provider.objects.filter")
|
||||
def test_graph_database_error_active_provider_and_tenant_reraises(
|
||||
self,
|
||||
mock_provider_filter,
|
||||
mock_tenant_filter,
|
||||
mock_membership_filter,
|
||||
mock_rls,
|
||||
tenants_fixture,
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider_id = str(uuid.uuid4())
|
||||
graph_error = GraphDatabaseQueryException("Temporary database not found")
|
||||
|
||||
mock_rls.return_value.__enter__ = lambda s: None
|
||||
mock_rls.return_value.__exit__ = lambda s, *args: None
|
||||
mock_provider_filter.return_value.exists.return_value = True
|
||||
mock_tenant_filter.return_value.exists.return_value = True
|
||||
mock_membership_filter.return_value.exists.return_value = True
|
||||
|
||||
@handle_provider_deletion
|
||||
def task_func(**kwargs):
|
||||
raise graph_error
|
||||
|
||||
with pytest.raises(GraphDatabaseQueryException) as exc_info:
|
||||
task_func(tenant_id=str(tenant.id), provider_id=provider_id)
|
||||
|
||||
assert exc_info.value is graph_error
|
||||
mock_rls.assert_called_once_with(str(tenant.id), using=DEFAULT_DB_ALIAS)
|
||||
|
||||
def test_missing_provider_and_scan_raises_assertion(self, tenants_fixture):
|
||||
"""Raises AssertionError when neither provider_id nor scan_id in kwargs."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -2,12 +2,17 @@ import json
|
||||
from unittest.mock import ANY, Mock, patch
|
||||
|
||||
import pytest
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import (
|
||||
Integration,
|
||||
IntegrationProviderRelationship,
|
||||
Membership,
|
||||
ProviderGroup,
|
||||
ProviderGroupMembership,
|
||||
ProviderSecret,
|
||||
Role,
|
||||
RoleProviderGroupRelationship,
|
||||
Scan,
|
||||
User,
|
||||
UserRoleRelationship,
|
||||
)
|
||||
@@ -664,6 +669,612 @@ class TestLimitedVisibility:
|
||||
limited_admin_user, tenants_fixture[0]
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def hidden_provider_secret(self, aws_provider_pair):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
return ProviderSecret.objects.create(
|
||||
tenant_id=hidden_provider.tenant_id,
|
||||
provider=hidden_provider,
|
||||
secret_type=ProviderSecret.TypeChoices.STATIC,
|
||||
secret={
|
||||
"aws_access_key_id": "hidden-key",
|
||||
"aws_secret_access_key": "hidden-secret",
|
||||
},
|
||||
name="Hidden provider secret",
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def limited_provider_group(self, limited_admin_user):
|
||||
return ProviderGroup.objects.get(name="limited_visibility_group")
|
||||
|
||||
@patch("api.v1.views.enqueue_scan_execution_on_commit")
|
||||
def test_scan_create_out_of_scope_provider_is_rejected(
|
||||
self,
|
||||
mock_enqueue_scan,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider_pair,
|
||||
):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("scan-list"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "scans",
|
||||
"attributes": {"name": "Out of scope scan"},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": str(hidden_provider.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not Scan.objects.filter(
|
||||
provider=hidden_provider, name="Out of scope scan"
|
||||
).exists()
|
||||
mock_enqueue_scan.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.enqueue_scan_execution_on_commit")
|
||||
def test_scan_create_in_scope_provider_is_accepted(
|
||||
self,
|
||||
mock_enqueue_scan,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider,
|
||||
):
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("scan-list"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "scans",
|
||||
"attributes": {"name": "In scope scan"},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": str(aws_provider.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
assert Scan.objects.filter(provider=aws_provider, name="In scope scan").exists()
|
||||
mock_enqueue_scan.assert_called_once()
|
||||
|
||||
def test_provider_secret_retrieve_out_of_scope_returns_404(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
hidden_provider_secret,
|
||||
):
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse(
|
||||
"providersecret-detail",
|
||||
kwargs={"pk": hidden_provider_secret.id},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_provider_secret_list_excludes_out_of_scope_provider(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
hidden_provider_secret,
|
||||
):
|
||||
response = authenticated_client_rbac_limited.get(reverse("providersecret-list"))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert str(hidden_provider_secret.id) not in {
|
||||
item["id"] for item in response.json()["data"]
|
||||
}
|
||||
|
||||
def test_provider_secret_create_out_of_scope_provider_is_rejected(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider_pair,
|
||||
):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("providersecret-list"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"attributes": {
|
||||
"name": "Out of scope secret",
|
||||
"secret_type": ProviderSecret.TypeChoices.STATIC,
|
||||
"secret": {
|
||||
"aws_access_key_id": "hidden-key",
|
||||
"aws_secret_access_key": "hidden-secret",
|
||||
},
|
||||
},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": str(hidden_provider.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not ProviderSecret.objects.filter(provider=hidden_provider).exists()
|
||||
|
||||
def test_provider_secret_create_in_scope_provider_is_accepted(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider,
|
||||
):
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("providersecret-list"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"attributes": {
|
||||
"name": "In scope secret",
|
||||
"secret_type": ProviderSecret.TypeChoices.STATIC,
|
||||
"secret": {
|
||||
"aws_access_key_id": "visible-key",
|
||||
"aws_secret_access_key": "visible-secret",
|
||||
},
|
||||
},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": str(aws_provider.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert ProviderSecret.objects.filter(provider=aws_provider).exists()
|
||||
|
||||
def test_provider_secret_update_out_of_scope_returns_404(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
hidden_provider_secret,
|
||||
):
|
||||
response = authenticated_client_rbac_limited.patch(
|
||||
reverse(
|
||||
"providersecret-detail",
|
||||
kwargs={"pk": hidden_provider_secret.id},
|
||||
),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"id": str(hidden_provider_secret.id),
|
||||
"attributes": {"name": "Updated hidden secret"},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
hidden_provider_secret.refresh_from_db()
|
||||
assert hidden_provider_secret.name == "Hidden provider secret"
|
||||
|
||||
def test_provider_secret_delete_out_of_scope_returns_404(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
hidden_provider_secret,
|
||||
):
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse(
|
||||
"providersecret-detail",
|
||||
kwargs={"pk": hidden_provider_secret.id},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert ProviderSecret.objects.filter(id=hidden_provider_secret.id).exists()
|
||||
|
||||
def test_provider_group_create_out_of_scope_provider_is_rejected(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider_pair,
|
||||
):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("providergroup-list"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-groups",
|
||||
"attributes": {"name": "Out of scope group"},
|
||||
"relationships": {
|
||||
"providers": {
|
||||
"data": [
|
||||
{
|
||||
"type": "providers",
|
||||
"id": str(hidden_provider.id),
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not ProviderGroup.objects.filter(name="Out of scope group").exists()
|
||||
|
||||
def test_provider_group_create_in_scope_provider_is_accepted(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider,
|
||||
):
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("providergroup-list"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-groups",
|
||||
"attributes": {"name": "In scope group"},
|
||||
"relationships": {
|
||||
"providers": {
|
||||
"data": [
|
||||
{
|
||||
"type": "providers",
|
||||
"id": str(aws_provider.id),
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
provider_group = ProviderGroup.objects.get(name="In scope group")
|
||||
assert set(provider_group.providers.all()) == {aws_provider}
|
||||
|
||||
def test_provider_group_update_out_of_scope_provider_is_rejected(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
limited_provider_group,
|
||||
aws_provider_pair,
|
||||
):
|
||||
visible_provider, hidden_provider = aws_provider_pair
|
||||
response = authenticated_client_rbac_limited.patch(
|
||||
reverse(
|
||||
"providergroup-detail",
|
||||
kwargs={"pk": limited_provider_group.id},
|
||||
),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "provider-groups",
|
||||
"id": str(limited_provider_group.id),
|
||||
"relationships": {
|
||||
"providers": {
|
||||
"data": [
|
||||
{
|
||||
"type": "providers",
|
||||
"id": str(hidden_provider.id),
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert set(limited_provider_group.providers.all()) == {visible_provider}
|
||||
|
||||
def test_provider_group_relationship_create_out_of_scope_provider_is_rejected(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
limited_provider_group,
|
||||
aws_provider_pair,
|
||||
):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse(
|
||||
"provider_group-providers-relationship",
|
||||
kwargs={"pk": limited_provider_group.id},
|
||||
),
|
||||
data={
|
||||
"data": [
|
||||
{"type": "providers", "id": str(hidden_provider.id)},
|
||||
]
|
||||
},
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not ProviderGroupMembership.objects.filter(
|
||||
provider_group=limited_provider_group,
|
||||
provider=hidden_provider,
|
||||
).exists()
|
||||
|
||||
def test_provider_group_relationship_update_out_of_scope_provider_is_rejected(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
limited_provider_group,
|
||||
aws_provider_pair,
|
||||
):
|
||||
visible_provider, hidden_provider = aws_provider_pair
|
||||
response = authenticated_client_rbac_limited.patch(
|
||||
reverse(
|
||||
"provider_group-providers-relationship",
|
||||
kwargs={"pk": limited_provider_group.id},
|
||||
),
|
||||
data={
|
||||
"data": [
|
||||
{"type": "providers", "id": str(hidden_provider.id)},
|
||||
]
|
||||
},
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert set(limited_provider_group.providers.all()) == {visible_provider}
|
||||
|
||||
def test_provider_group_relationship_create_in_scope_provider_is_accepted(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
limited_provider_group,
|
||||
aws_provider_pair,
|
||||
):
|
||||
additional_provider = aws_provider_pair[1]
|
||||
additional_group = ProviderGroup.objects.create(
|
||||
tenant_id=additional_provider.tenant_id,
|
||||
name="Additional visible group",
|
||||
)
|
||||
ProviderGroupMembership.objects.create(
|
||||
tenant_id=additional_provider.tenant_id,
|
||||
provider_group=additional_group,
|
||||
provider=additional_provider,
|
||||
)
|
||||
RoleProviderGroupRelationship.objects.create(
|
||||
tenant_id=additional_provider.tenant_id,
|
||||
role=limited_provider_group.roles.get(),
|
||||
provider_group=additional_group,
|
||||
)
|
||||
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse(
|
||||
"provider_group-providers-relationship",
|
||||
kwargs={"pk": limited_provider_group.id},
|
||||
),
|
||||
data={
|
||||
"data": [
|
||||
{"type": "providers", "id": str(additional_provider.id)},
|
||||
]
|
||||
},
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert ProviderGroupMembership.objects.filter(
|
||||
provider_group=limited_provider_group,
|
||||
provider=additional_provider,
|
||||
).exists()
|
||||
|
||||
def test_provider_group_relationship_delete_out_of_scope_group_returns_404(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider_pair,
|
||||
):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
hidden_group = ProviderGroup.objects.create(
|
||||
tenant_id=hidden_provider.tenant_id,
|
||||
name="Unassigned provider group",
|
||||
)
|
||||
ProviderGroupMembership.objects.create(
|
||||
tenant_id=hidden_provider.tenant_id,
|
||||
provider_group=hidden_group,
|
||||
provider=hidden_provider,
|
||||
)
|
||||
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse(
|
||||
"provider_group-providers-relationship",
|
||||
kwargs={"pk": hidden_group.id},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert ProviderGroupMembership.objects.filter(
|
||||
provider_group=hidden_group,
|
||||
provider=hidden_provider,
|
||||
).exists()
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.delete_provider_task.delay")
|
||||
def test_provider_delete_out_of_scope_returns_404(
|
||||
self,
|
||||
mock_delete_task,
|
||||
mock_task_get,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider_pair,
|
||||
tasks_fixture,
|
||||
):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
prowler_task = tasks_fixture[0]
|
||||
mock_delete_task.return_value.id = prowler_task.id
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse("provider-detail", kwargs={"pk": hidden_provider.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
hidden_provider.refresh_from_db()
|
||||
assert hidden_provider.is_deleted is False
|
||||
mock_delete_task.assert_not_called()
|
||||
mock_task_get.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.delete_provider_task.delay")
|
||||
def test_provider_delete_in_scope_returns_202(
|
||||
self,
|
||||
mock_delete_task,
|
||||
mock_task_get,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider,
|
||||
tasks_fixture,
|
||||
):
|
||||
prowler_task = tasks_fixture[0]
|
||||
mock_delete_task.return_value.id = prowler_task.id
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse("provider-detail", kwargs={"pk": aws_provider.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
mock_delete_task.assert_called_once_with(
|
||||
provider_id=str(aws_provider.id), tenant_id=ANY
|
||||
)
|
||||
mock_task_get.assert_called_once_with(id=prowler_task.id)
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.check_provider_connection_task.delay")
|
||||
def test_provider_connection_out_of_scope_returns_404(
|
||||
self,
|
||||
mock_provider_connection,
|
||||
mock_task_get,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider_pair,
|
||||
tasks_fixture,
|
||||
):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
prowler_task = tasks_fixture[0]
|
||||
mock_provider_connection.return_value.id = prowler_task.id
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("provider-connection", kwargs={"pk": hidden_provider.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
mock_provider_connection.assert_not_called()
|
||||
mock_task_get.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.check_provider_connection_task.delay")
|
||||
def test_provider_connection_in_scope_returns_202(
|
||||
self,
|
||||
mock_provider_connection,
|
||||
mock_task_get,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider,
|
||||
tasks_fixture,
|
||||
):
|
||||
prowler_task = tasks_fixture[0]
|
||||
mock_provider_connection.return_value.id = prowler_task.id
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("provider-connection", kwargs={"pk": aws_provider.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
mock_provider_connection.assert_called_once_with(
|
||||
provider_id=str(aws_provider.id), tenant_id=ANY
|
||||
)
|
||||
mock_task_get.assert_called_once_with(id=prowler_task.id)
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.schedule_provider_scan")
|
||||
def test_schedule_daily_out_of_scope_returns_404(
|
||||
self,
|
||||
mock_schedule_scan,
|
||||
mock_task_get,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider_pair,
|
||||
tasks_fixture,
|
||||
):
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
prowler_task = tasks_fixture[0]
|
||||
mock_schedule_scan.return_value.id = prowler_task.id
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("schedule-daily"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "daily-schedules",
|
||||
"attributes": {"provider_id": str(hidden_provider.id)},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.wsgi_request.content_type == "application/vnd.api+json"
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
mock_schedule_scan.assert_not_called()
|
||||
mock_task_get.assert_not_called()
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.schedule_provider_scan")
|
||||
def test_schedule_daily_in_scope_returns_202(
|
||||
self,
|
||||
mock_schedule_scan,
|
||||
mock_task_get,
|
||||
authenticated_client_rbac_limited,
|
||||
aws_provider,
|
||||
tasks_fixture,
|
||||
):
|
||||
prowler_task = tasks_fixture[0]
|
||||
mock_schedule_scan.return_value.id = prowler_task.id
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("schedule-daily"),
|
||||
data=json.dumps(
|
||||
{
|
||||
"data": {
|
||||
"type": "daily-schedules",
|
||||
"attributes": {"provider_id": str(aws_provider.id)},
|
||||
}
|
||||
}
|
||||
),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.wsgi_request.content_type == "application/vnd.api+json"
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
mock_schedule_scan.assert_called_once_with(aws_provider)
|
||||
mock_task_get.assert_called_once_with(id=prowler_task.id)
|
||||
|
||||
def test_integrations(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture
|
||||
):
|
||||
@@ -681,6 +1292,363 @@ class TestLimitedVisibility:
|
||||
response.json()["data"]["relationships"]["providers"]["meta"]["count"] == 1
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def out_of_scope_integration(self, tenants_fixture, provider_factory):
|
||||
tenant_id = tenants_fixture[0].id
|
||||
integration = Integration.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
enabled=True,
|
||||
connected=True,
|
||||
integration_type=Integration.IntegrationChoices.AMAZON_S3,
|
||||
configuration={
|
||||
"bucket_name": "bucket",
|
||||
"output_directory": "output",
|
||||
},
|
||||
credentials={"aws_access_key_id": "key"},
|
||||
)
|
||||
IntegrationProviderRelationship.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
integration=integration,
|
||||
provider=provider_factory(),
|
||||
)
|
||||
return integration
|
||||
|
||||
def test_integrations_list_includes_tenant_wide_integration(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
integrations_fixture,
|
||||
jira_integration_fixture,
|
||||
aws_provider_pair,
|
||||
):
|
||||
# Integration 2 is attached to both providers, so make both visible to the role
|
||||
# to assert the provider join does not duplicate it in the listing
|
||||
ProviderGroupMembership.objects.create(
|
||||
tenant_id=aws_provider_pair[1].tenant_id,
|
||||
provider=aws_provider_pair[1],
|
||||
provider_group=ProviderGroup.objects.get(name="limited_visibility_group"),
|
||||
)
|
||||
|
||||
response = authenticated_client_rbac_limited.get(reverse("integration-list"))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
integration_ids = [item["id"] for item in response.json()["data"]]
|
||||
# The tenant-wide Jira integration is visible without unlimited visibility
|
||||
assert str(jira_integration_fixture.id) in integration_ids
|
||||
# Integrations attached to more than one visible provider are not duplicated
|
||||
assert integration_ids.count(str(integrations_fixture[1].id)) == 1
|
||||
assert response.json()["meta"]["pagination"]["count"] == len(integration_ids)
|
||||
|
||||
def test_integrations_list_without_provider_groups_keeps_tenant_wide_integration(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
integrations_fixture,
|
||||
jira_integration_fixture,
|
||||
):
|
||||
# A role with no provider group at all sees no provider, but still needs Jira
|
||||
RoleProviderGroupRelationship.objects.all().delete()
|
||||
|
||||
response = authenticated_client_rbac_limited.get(reverse("integration-list"))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
integration_ids = [item["id"] for item in response.json()["data"]]
|
||||
assert integration_ids == [str(jira_integration_fixture.id)]
|
||||
|
||||
def test_integrations_include_providers_hides_out_of_scope_providers(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture, aws_provider_pair
|
||||
):
|
||||
# Integration 2 is related to provider1 (visible) and provider2 (not visible)
|
||||
hidden_provider = aws_provider_pair[1]
|
||||
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse("integration-list"), {"include": "providers"}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
included_ids = {item["id"] for item in response.json().get("included", [])}
|
||||
assert str(aws_provider_pair[0].id) in included_ids
|
||||
# Sideloaded resources must not disclose the provider the role cannot see
|
||||
assert str(hidden_provider.id) not in included_ids
|
||||
|
||||
def test_integrations_list_with_sparse_fields(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
integrations_fixture,
|
||||
jira_integration_fixture,
|
||||
):
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse("integration-list"), {"fields[integrations]": "enabled"}
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert str(jira_integration_fixture.id) in [
|
||||
item["id"] for item in response.json()["data"]
|
||||
]
|
||||
assert all(
|
||||
list(item["attributes"].keys()) == ["enabled"]
|
||||
for item in response.json()["data"]
|
||||
)
|
||||
|
||||
def test_integrations_list_excludes_out_of_scope_integration(
|
||||
self, authenticated_client_rbac_limited, out_of_scope_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.get(reverse("integration-list"))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
integration_ids = [item["id"] for item in response.json()["data"]]
|
||||
assert str(out_of_scope_integration.id) not in integration_ids
|
||||
|
||||
def test_integration_detail_out_of_scope_returns_404(
|
||||
self, authenticated_client_rbac_limited, out_of_scope_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse("integration-detail", kwargs={"pk": out_of_scope_integration.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_integration_connection_out_of_scope_returns_404(
|
||||
self, authenticated_client_rbac_limited, out_of_scope_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse(
|
||||
"integration-connection", kwargs={"pk": out_of_scope_integration.id}
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_integration_update_allowed_when_fully_visible(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
integrations_fixture,
|
||||
jira_integration_fixture,
|
||||
):
|
||||
# Integration 1 is only related to provider1, which the role can access
|
||||
integration = integrations_fixture[0]
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"id": str(integration.id),
|
||||
"attributes": {
|
||||
"enabled": False,
|
||||
# integration_type is `amazon_s3`
|
||||
"credentials": {"aws_access_key_id": "new_value"},
|
||||
"configuration": {
|
||||
"bucket_name": "new_bucket_name",
|
||||
"output_directory": "new_output_directory",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client_rbac_limited.patch(
|
||||
reverse("integration-detail", kwargs={"pk": integration.id}),
|
||||
data=json.dumps(payload),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
integration.refresh_from_db()
|
||||
assert integration.enabled is False
|
||||
|
||||
# Tenant-wide integrations have no provider restricting the role
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"id": str(jira_integration_fixture.id),
|
||||
"attributes": {"enabled": False},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client_rbac_limited.patch(
|
||||
reverse("integration-detail", kwargs={"pk": jira_integration_fixture.id}),
|
||||
data=json.dumps(payload),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
with rls_transaction(str(jira_integration_fixture.tenant_id)):
|
||||
jira_integration_fixture.refresh_from_db()
|
||||
assert jira_integration_fixture.enabled is False
|
||||
|
||||
def test_integration_create_rejects_out_of_scope_provider(
|
||||
self, authenticated_client_rbac_limited, aws_provider_pair
|
||||
):
|
||||
# provider2 is not in any provider group assigned to the role
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"attributes": {
|
||||
"integration_type": "amazon_s3",
|
||||
"configuration": {
|
||||
"bucket_name": "attacker_bucket",
|
||||
"output_directory": "output",
|
||||
},
|
||||
"credentials": {"aws_access_key_id": "key"},
|
||||
},
|
||||
"relationships": {
|
||||
"providers": {
|
||||
"data": [
|
||||
{"type": "providers", "id": str(aws_provider_pair[1].id)}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse("integration-list"),
|
||||
data=json.dumps(payload),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert not Integration.objects.filter(
|
||||
integrationproviderrelationship__provider=aws_provider_pair[1],
|
||||
configuration__bucket_name="attacker_bucket",
|
||||
).exists()
|
||||
|
||||
@pytest.mark.parametrize("submitted_providers", [True, False])
|
||||
def test_integration_update_denied_when_shared_with_hidden_provider(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
integrations_fixture,
|
||||
aws_provider_pair,
|
||||
submitted_providers,
|
||||
):
|
||||
# Integration 2 is related to provider1 (visible) and provider2 (not visible).
|
||||
# Editing it would reach beyond the visibility of the role, just like deleting
|
||||
# it, so both are rejected consistently
|
||||
integration = integrations_fixture[1]
|
||||
visible_provider, hidden_provider = aws_provider_pair
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"id": str(integration.id),
|
||||
"attributes": {
|
||||
"enabled": False,
|
||||
# integration_type is `amazon_s3`
|
||||
"credentials": {"aws_access_key_id": "new_value"},
|
||||
"configuration": {
|
||||
"bucket_name": "new_bucket_name",
|
||||
"output_directory": "new_output_directory",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
if submitted_providers:
|
||||
payload["data"]["relationships"] = {
|
||||
"providers": {
|
||||
"data": [{"type": "providers", "id": str(visible_provider.id)}]
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client_rbac_limited.patch(
|
||||
reverse("integration-detail", kwargs={"pk": integration.id}),
|
||||
data=json.dumps(payload),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
integration.refresh_from_db()
|
||||
assert integration.enabled is True
|
||||
assert integration.providers.filter(id=hidden_provider.id).exists()
|
||||
assert integration.providers.filter(id=visible_provider.id).exists()
|
||||
|
||||
def test_integration_delete_denied_when_shared_with_hidden_provider(
|
||||
self, authenticated_client_rbac_limited, integrations_fixture
|
||||
):
|
||||
# Integration 2 is related to provider1 (visible) and provider2 (not visible)
|
||||
integration = integrations_fixture[1]
|
||||
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse("integration-detail", kwargs={"pk": integration.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert Integration.objects.filter(id=integration.id).exists()
|
||||
|
||||
def test_integration_delete_allowed_when_fully_visible(
|
||||
self,
|
||||
authenticated_client_rbac_limited,
|
||||
integrations_fixture,
|
||||
jira_integration_fixture,
|
||||
):
|
||||
# Integration 1 is only related to provider1, which the role can access
|
||||
integration = integrations_fixture[0]
|
||||
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse("integration-detail", kwargs={"pk": integration.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Integration.objects.filter(id=integration.id).exists()
|
||||
|
||||
# Tenant-wide integrations have no provider restricting the role
|
||||
response = authenticated_client_rbac_limited.delete(
|
||||
reverse("integration-detail", kwargs={"pk": jira_integration_fixture.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
def test_jira_issue_types_allowed_without_unlimited_visibility(
|
||||
self, authenticated_client_rbac_limited, jira_integration_fixture
|
||||
):
|
||||
with patch("api.v1.views.initialize_prowler_integration") as mock_jira:
|
||||
mock_jira.return_value.get_available_issue_types.return_value = ["Task"]
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse(
|
||||
"integration-jira-issue-types",
|
||||
kwargs={"integration_pk": jira_integration_fixture.id},
|
||||
),
|
||||
{"project_key": "TEST"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["data"]["attributes"]["issue_types"] == ["Task"]
|
||||
|
||||
def test_jira_issue_types_out_of_scope_returns_404(
|
||||
self, authenticated_client_rbac_limited, out_of_scope_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.get(
|
||||
reverse(
|
||||
"integration-jira-issue-types",
|
||||
kwargs={"integration_pk": out_of_scope_integration.id},
|
||||
),
|
||||
{"project_key": "TEST"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_jira_dispatches_out_of_scope_returns_404(
|
||||
self, authenticated_client_rbac_limited, out_of_scope_integration
|
||||
):
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse(
|
||||
"integration-jira-dispatches",
|
||||
kwargs={"integration_pk": out_of_scope_integration.id},
|
||||
),
|
||||
data=json.dumps({}),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
def test_jira_dispatches_allowed_without_unlimited_visibility(
|
||||
self, authenticated_client_rbac_limited, jira_integration_fixture
|
||||
):
|
||||
response = authenticated_client_rbac_limited.post(
|
||||
reverse(
|
||||
"integration-jira-dispatches",
|
||||
kwargs={"integration_pk": jira_integration_fixture.id},
|
||||
),
|
||||
data=json.dumps({}),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
# The integration is reachable: the request fails on payload validation, not RBAC
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
@pytest.mark.usefixtures("scan_summaries_fixture")
|
||||
def test_overviews_providers(
|
||||
self,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from api.attack_paths.retryable_session import RetryableSession
|
||||
from api.attack_paths.retryable_session import RetryableSession, RetryExhaustedError
|
||||
from neo4j.exceptions import ServiceUnavailable
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class TestRetryableSession:
|
||||
max_retries=3,
|
||||
retry_if=lambda exc: exc is retryable_error,
|
||||
initial_retry_delay_seconds=2,
|
||||
retry_context="Neptune write",
|
||||
)
|
||||
|
||||
assert session.execute_write(work) == "success"
|
||||
@@ -54,6 +55,7 @@ class TestRetryableSession:
|
||||
max_retries=3,
|
||||
retry_if=lambda _: False,
|
||||
initial_retry_delay_seconds=2,
|
||||
retry_context="Neptune write",
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
@@ -83,3 +85,81 @@ class TestRetryableSession:
|
||||
driver_sessions[0].close.assert_called_once_with()
|
||||
driver_sessions[1].close.assert_called_once_with()
|
||||
driver_sessions[2].close.assert_not_called()
|
||||
|
||||
def test_retry_exhaustion_with_context_reports_attempts_and_elapsed_time(self):
|
||||
error = RuntimeError("still retryable")
|
||||
driver_sessions = [MagicMock() for _ in range(3)]
|
||||
for driver_session in driver_sessions:
|
||||
driver_session.execute_write.side_effect = error
|
||||
session = RetryableSession(
|
||||
session_factory=MagicMock(side_effect=driver_sessions),
|
||||
max_retries=2,
|
||||
retry_if=lambda _: True,
|
||||
retry_context="Neptune write",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.attack_paths.retryable_session.time.monotonic",
|
||||
side_effect=[100.0, 127.1234],
|
||||
),
|
||||
pytest.raises(RetryExhaustedError) as exc_info,
|
||||
):
|
||||
session.execute_write(MagicMock())
|
||||
|
||||
assert exc_info.value.method_name == "execute_write"
|
||||
assert exc_info.value.attempts == 3
|
||||
assert exc_info.value.elapsed_seconds == pytest.approx(27.1234)
|
||||
assert exc_info.value.last_error is error
|
||||
assert exc_info.value.__cause__ is error
|
||||
assert str(exc_info.value) == (
|
||||
"Neptune write execute_write failed after 3 attempts over 27.123s. "
|
||||
"Last error: still retryable"
|
||||
)
|
||||
|
||||
def test_retry_exhaustion_with_zero_retries_reports_one_attempt(self):
|
||||
error = ServiceUnavailable("still unavailable")
|
||||
driver_session = MagicMock()
|
||||
driver_session.execute_write.side_effect = error
|
||||
session = RetryableSession(
|
||||
session_factory=MagicMock(return_value=driver_session),
|
||||
max_retries=0,
|
||||
retry_context="Neptune write",
|
||||
)
|
||||
|
||||
with pytest.raises(RetryExhaustedError) as exc_info:
|
||||
session.execute_write(MagicMock())
|
||||
|
||||
assert exc_info.value.attempts == 1
|
||||
|
||||
@patch("api.attack_paths.retryable_session.time.sleep")
|
||||
@patch("api.attack_paths.retryable_session.random.uniform", return_value=3.0)
|
||||
def test_contextual_retry_warning_includes_original_error(
|
||||
self, _mock_uniform, _mock_sleep
|
||||
):
|
||||
error = RuntimeError("retryable detail")
|
||||
first_session = MagicMock()
|
||||
first_session.execute_write.side_effect = error
|
||||
second_session = MagicMock()
|
||||
second_session.execute_write.return_value = "success"
|
||||
session = RetryableSession(
|
||||
session_factory=MagicMock(side_effect=[first_session, second_session]),
|
||||
max_retries=1,
|
||||
retry_if=lambda _: True,
|
||||
initial_retry_delay_seconds=2,
|
||||
retry_context="Neptune write",
|
||||
)
|
||||
|
||||
with patch("api.attack_paths.retryable_session.logger.warning") as mock_warning:
|
||||
assert session.execute_write(MagicMock()) == "success"
|
||||
|
||||
mock_warning.assert_called_once_with(
|
||||
"%s %s failed with %s: %s; retry %s/%s in %.3fs",
|
||||
"Neptune write",
|
||||
"execute_write",
|
||||
"RuntimeError",
|
||||
"retryable detail",
|
||||
1,
|
||||
1,
|
||||
3.0,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from config.settings import sentry as sentry_settings
|
||||
from config.settings.sentry import before_send
|
||||
|
||||
@@ -82,6 +83,45 @@ def test_before_send_passes_through_non_ignored_log():
|
||||
assert result == event
|
||||
|
||||
|
||||
def test_before_send_ignores_cartography_missing_temporary_database_log():
|
||||
log_record = _make_log_record(
|
||||
msg="Cartography job failed with %s for database %s",
|
||||
name="cartography.graph.job",
|
||||
args=(
|
||||
"Neo.ClientError.Database.DatabaseNotFound",
|
||||
"db-tmp-scan-12345678",
|
||||
),
|
||||
)
|
||||
|
||||
event = MagicMock()
|
||||
|
||||
assert before_send(event, {"log_record": log_record}) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("logger_name", "message"),
|
||||
[
|
||||
(
|
||||
"cartography.graph.job.worker",
|
||||
"Neo.ClientError.Database.DatabaseNotFound for db-tmp-scan-12345678",
|
||||
),
|
||||
(
|
||||
"cartography.graph.job",
|
||||
"DatabaseNotFound for db-tmp-scan-12345678",
|
||||
),
|
||||
(
|
||||
"cartography.graph.job",
|
||||
"Neo.ClientError.Database.DatabaseNotFound for db-tenant-12345678",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_before_send_passes_through_similar_cartography_logs(logger_name, message):
|
||||
log_record = _make_log_record(msg=message, name=logger_name)
|
||||
event = MagicMock()
|
||||
|
||||
assert before_send(event, {"log_record": log_record}) is event
|
||||
|
||||
|
||||
def test_before_send_passes_through_non_ignored_exception():
|
||||
"""Test that before_send passes through exceptions that don't contain ignored exceptions."""
|
||||
exc_info = (Exception, Exception("Some other error message"), None)
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import pytest
|
||||
from api.v1.serializer_utils.integrations import S3ConfigSerializer
|
||||
from api.v1.serializers import ImageProviderSecret, KubernetesProviderSecret
|
||||
from api.v1.serializer_utils.integrations import (
|
||||
JiraCredentialSerializer,
|
||||
S3ConfigSerializer,
|
||||
)
|
||||
from api.v1.serializer_utils.providers import ProviderSecretField
|
||||
from api.v1.serializers import (
|
||||
ImageProviderSecret,
|
||||
IntegrationSerializer,
|
||||
IntegrationUpdateSerializer,
|
||||
KubernetesProviderSecret,
|
||||
OracleCloudProviderSecret,
|
||||
)
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.test import APIRequestFactory
|
||||
|
||||
|
||||
class TestS3ConfigSerializer:
|
||||
@@ -100,6 +111,59 @@ class TestS3ConfigSerializer:
|
||||
assert "output_directory" in serializer.errors
|
||||
|
||||
|
||||
class TestJiraCredentialSerializer:
|
||||
@pytest.mark.parametrize(
|
||||
"domain",
|
||||
(
|
||||
"a",
|
||||
"prowler",
|
||||
"prowler-domain",
|
||||
"A1-b2-C3",
|
||||
"a" * 63,
|
||||
),
|
||||
)
|
||||
def test_valid_site_name(self, domain):
|
||||
serializer = JiraCredentialSerializer(
|
||||
data={
|
||||
"user_mail": "testing@prowler.com",
|
||||
"api_token": "fake-api-token",
|
||||
"domain": domain,
|
||||
}
|
||||
)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"domain",
|
||||
(
|
||||
"169.254.169.254#",
|
||||
"internal/service",
|
||||
"internal?target",
|
||||
"internal\\target",
|
||||
"internal:8000",
|
||||
"user@internal",
|
||||
"example.atlassian.net",
|
||||
"-prowler",
|
||||
"prowler-",
|
||||
"a" * 64,
|
||||
" prowler",
|
||||
"prowler ",
|
||||
"prowler\n",
|
||||
),
|
||||
)
|
||||
def test_invalid_site_name(self, domain):
|
||||
serializer = JiraCredentialSerializer(
|
||||
data={
|
||||
"user_mail": "testing@prowler.com",
|
||||
"api_token": "fake-api-token",
|
||||
"domain": domain,
|
||||
}
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "domain" in serializer.errors
|
||||
|
||||
|
||||
class TestImageProviderSecret:
|
||||
"""Test cases for ImageProviderSecret validation."""
|
||||
|
||||
@@ -134,6 +198,64 @@ class TestImageProviderSecret:
|
||||
assert "non_field_errors" in serializer.errors
|
||||
|
||||
|
||||
class TestOracleCloudProviderSecret:
|
||||
def valid_secret(self, **overrides):
|
||||
secret = {
|
||||
"user": "ocid1.user.oc1..aaaaaaaexample",
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
}
|
||||
secret.update(overrides)
|
||||
return secret
|
||||
|
||||
def test_accepts_regionless_secret(self):
|
||||
serializer = OracleCloudProviderSecret(data=self.valid_secret())
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
assert "region" not in serializer.validated_data
|
||||
|
||||
def test_accepts_and_ignores_region_field(self):
|
||||
secret = self.valid_secret(region="us-phoenix-1")
|
||||
serializer = OracleCloudProviderSecret(data=secret)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
assert "region" not in serializer.validated_data
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"legacy_field, legacy_value",
|
||||
[
|
||||
("region", None),
|
||||
("region", ""),
|
||||
("region", {"name": "us-ashburn-1"}),
|
||||
],
|
||||
)
|
||||
def test_accepts_and_ignores_any_legacy_region_value(
|
||||
self, legacy_field, legacy_value
|
||||
):
|
||||
serializer = OracleCloudProviderSecret(
|
||||
data=self.valid_secret(**{legacy_field: legacy_value})
|
||||
)
|
||||
|
||||
assert serializer.is_valid(), serializer.errors
|
||||
|
||||
assert legacy_field not in serializer.validated_data
|
||||
|
||||
|
||||
class TestProviderSecretFieldSchema:
|
||||
def test_oraclecloud_schema_includes_legacy_region_field(self):
|
||||
schema = ProviderSecretField._spectacular_annotation["field"]
|
||||
oraclecloud_schema = next(
|
||||
credential_schema
|
||||
for credential_schema in schema["oneOf"]
|
||||
if credential_schema["title"]
|
||||
== "Oracle Cloud Infrastructure (OCI) API Key Credentials"
|
||||
)
|
||||
|
||||
assert oraclecloud_schema["properties"]["region"]["deprecated"] is True
|
||||
|
||||
|
||||
class TestKubernetesProviderSecret:
|
||||
def test_valid_static_kubeconfig_is_accepted(self):
|
||||
kubeconfig_content = """
|
||||
@@ -190,6 +312,36 @@ current-context: test-context
|
||||
assert not serializer.is_valid()
|
||||
assert "kubeconfig_content" in serializer.errors
|
||||
|
||||
def test_kubeconfig_with_auth_provider_cmd_path_is_rejected(self):
|
||||
kubeconfig_content = """
|
||||
apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
- name: test-cluster
|
||||
cluster:
|
||||
server: https://kubernetes.example.test
|
||||
users:
|
||||
- name: test-user
|
||||
user:
|
||||
auth-provider:
|
||||
name: gcp
|
||||
config:
|
||||
cmd-path: /bin/sh
|
||||
contexts:
|
||||
- name: test-context
|
||||
context:
|
||||
cluster: test-cluster
|
||||
user: test-user
|
||||
current-context: test-context
|
||||
"""
|
||||
|
||||
serializer = KubernetesProviderSecret(
|
||||
data={"kubeconfig_content": kubeconfig_content}
|
||||
)
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "kubeconfig_content" in serializer.errors
|
||||
|
||||
def test_malformed_kubeconfig_is_rejected(self):
|
||||
serializer = KubernetesProviderSecret(
|
||||
data={"kubeconfig_content": "apiVersion: ["}
|
||||
@@ -203,3 +355,25 @@ current-context: test-context
|
||||
|
||||
assert not serializer.is_valid()
|
||||
assert "kubeconfig_content" in serializer.errors
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestIntegrationSerializerJiraDomain:
|
||||
"""The serialized Jira `domain` must not reach the model instance."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"serializer_class", [IntegrationSerializer, IntegrationUpdateSerializer]
|
||||
)
|
||||
def test_to_representation_does_not_mutate_configuration(
|
||||
self, serializer_class, jira_integration_fixture
|
||||
):
|
||||
# `IntegrationUpdateSerializer` exposes a `HyperlinkedIdentityField`
|
||||
context = {"request": APIRequestFactory().get("/")}
|
||||
representation = serializer_class(
|
||||
jira_integration_fixture, context=context
|
||||
).data
|
||||
|
||||
assert representation["configuration"]["domain"] == "test"
|
||||
assert jira_integration_fixture.configuration == {
|
||||
"projects": {"TEST": "Test project"}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ from unittest.mock import MagicMock, patch
|
||||
import neo4j
|
||||
import pytest
|
||||
from api.attack_paths import sink as sink_module
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from api.attack_paths.database import (
|
||||
GraphDatabaseQueryException,
|
||||
NeptuneWriteRetryExhaustedException,
|
||||
)
|
||||
from api.attack_paths.retryable_session import RetryExhaustedError
|
||||
from api.attack_paths.sink import factory
|
||||
from api.attack_paths.sink.neo4j import DATABASE_NOT_FOUND_CODE, Neo4jSink
|
||||
from api.attack_paths.sink.neptune import (
|
||||
@@ -123,6 +127,14 @@ class TestSinkFactory:
|
||||
assert mock_driver.call_count == 1
|
||||
|
||||
|
||||
def test_neo4j_sync_batch_size_defaults_to_1000():
|
||||
assert Neo4jSink.sync_batch_size == 1000
|
||||
|
||||
|
||||
def test_neptune_sync_batch_size_defaults_to_500():
|
||||
assert NeptuneSink.sync_batch_size == 500
|
||||
|
||||
|
||||
class TestGetBackendForScan:
|
||||
"""``get_backend_for_scan`` routes by the row's recorded sink backend."""
|
||||
|
||||
@@ -372,6 +384,7 @@ class TestNeptuneRetryPolicy:
|
||||
assert (
|
||||
kwargs["initial_retry_delay_seconds"] == NEPTUNE_WRITE_RETRY_DELAY_SECONDS
|
||||
)
|
||||
assert kwargs["retry_context"] == "Neptune write"
|
||||
|
||||
@patch("api.attack_paths.sink.neptune.RetryableSession")
|
||||
def test_reader_session_does_not_enable_write_retry_policy(self, retryable_session):
|
||||
@@ -384,6 +397,48 @@ class TestNeptuneRetryPolicy:
|
||||
kwargs = retryable_session.call_args.kwargs
|
||||
assert kwargs["retry_if"] is None
|
||||
assert kwargs["initial_retry_delay_seconds"] == 0
|
||||
assert kwargs["retry_context"] is None
|
||||
|
||||
def test_writer_retry_exhaustion_preserves_neptune_error_details(self):
|
||||
message = (
|
||||
"Unexpected server exception 'Operation failed due to conflicting "
|
||||
"concurrent operations (please retry), 0 transactions are currently "
|
||||
"rolling back.'"
|
||||
)
|
||||
error = neo4j.exceptions.Neo4jError._hydrate_neo4j(
|
||||
code="BoltProtocol.unexpectedException",
|
||||
message=message,
|
||||
)
|
||||
retry_error = RetryExhaustedError(
|
||||
retry_context="Neptune write",
|
||||
method_name="execute_write",
|
||||
attempts=4,
|
||||
elapsed_seconds=27.1234,
|
||||
last_error=error,
|
||||
)
|
||||
sink = NeptuneSink()
|
||||
driver = MagicMock()
|
||||
retryable_session = MagicMock()
|
||||
retryable_session.execute_write.side_effect = retry_error
|
||||
|
||||
with (
|
||||
patch.object(sink, "_get_writer", return_value=driver),
|
||||
patch(
|
||||
"api.attack_paths.sink.neptune.RetryableSession",
|
||||
return_value=retryable_session,
|
||||
),
|
||||
pytest.raises(NeptuneWriteRetryExhaustedException) as exc_info,
|
||||
):
|
||||
with sink.get_session() as session:
|
||||
session.execute_write(MagicMock())
|
||||
|
||||
assert exc_info.value.code == "BoltProtocol.unexpectedException"
|
||||
assert str(exc_info.value) == (
|
||||
"BoltProtocol.unexpectedException: Neptune write execute_write failed "
|
||||
"after 4 attempts over 27.123s. Last error: "
|
||||
f"{message}"
|
||||
)
|
||||
assert exc_info.value.__cause__ is error
|
||||
|
||||
|
||||
class TestNeptuneSinkDropSubgraph:
|
||||
|
||||
@@ -171,6 +171,53 @@ class TestInitializeProwlerProvider:
|
||||
key="value", mutelist_content={"key": "value"}
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_initialize_oraclecloud_provider_removes_region_string(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
|
||||
provider.secret.secret = {
|
||||
"user": "ocid1.user.oc1..fake",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..fake",
|
||||
"region": "us-ashburn-1",
|
||||
}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
initialize_prowler_provider(provider)
|
||||
|
||||
mock_return_prowler_provider.return_value.assert_called_once_with(
|
||||
user="ocid1.user.oc1..fake",
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..fake",
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_initialize_oraclecloud_provider_without_region_omits_scan_filter(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
|
||||
provider.secret.secret = {
|
||||
"user": "ocid1.user.oc1..fake",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..fake",
|
||||
}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
initialize_prowler_provider(provider)
|
||||
|
||||
mock_return_prowler_provider.return_value.assert_called_once_with(
|
||||
user="ocid1.user.oc1..fake",
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..fake",
|
||||
)
|
||||
|
||||
|
||||
class TestProwlerProviderConnectionTest:
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
@@ -185,6 +232,37 @@ class TestProwlerProviderConnectionTest:
|
||||
key="value", provider_id="1234567890", raise_on_exception=False
|
||||
)
|
||||
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_oraclecloud_connection_test_uses_direct_credentials_without_region(
|
||||
self, mock_return_prowler_provider
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.uid = "ocid1.tenancy.oc1..aaaaaaaexample"
|
||||
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
|
||||
provider.secret.secret = {
|
||||
"user": "ocid1.user.oc1..aaaaaaaexample",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "fake-base64-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
}
|
||||
mock_return_prowler_provider.return_value = MagicMock()
|
||||
|
||||
prowler_provider_connection_test(provider)
|
||||
|
||||
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
|
||||
user="ocid1.user.oc1..aaaaaaaexample",
|
||||
fingerprint="00:11:22:33:44:55:66:77",
|
||||
key_content="fake-base64-key-content",
|
||||
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
region=getattr(
|
||||
OraclecloudProvider,
|
||||
"_bootstrap_region",
|
||||
OraclecloudProvider._home_region,
|
||||
),
|
||||
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_prowler_provider_connection_test_without_secret(
|
||||
@@ -356,7 +434,7 @@ class TestGetProwlerProviderKwargs:
|
||||
expected_result = {**secret_dict, **expected_extra_kwargs}
|
||||
assert result == expected_result
|
||||
|
||||
def test_get_prowler_provider_kwargs_oraclecloud_converts_region_string_to_set(
|
||||
def test_get_prowler_provider_kwargs_oraclecloud_removes_region(
|
||||
self,
|
||||
):
|
||||
secret_dict = {
|
||||
@@ -377,8 +455,13 @@ class TestGetProwlerProviderKwargs:
|
||||
|
||||
result = get_prowler_provider_kwargs(provider)
|
||||
|
||||
expected_result = {**secret_dict, "region": {"us-ashburn-1"}}
|
||||
assert result == expected_result
|
||||
assert result == {
|
||||
"user": "ocid1.user.oc1..fake",
|
||||
"fingerprint": "00:11:22:33:44:55:66:77",
|
||||
"key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
|
||||
"tenancy": "ocid1.tenancy.oc1..fake",
|
||||
"pass_phrase": "fake-passphrase",
|
||||
}
|
||||
|
||||
def test_get_prowler_provider_kwargs_with_mutelist(self):
|
||||
provider_uid = "provider_uid"
|
||||
@@ -856,7 +939,7 @@ class TestProwlerIntegrationConnectionTest:
|
||||
integration.credentials = {
|
||||
"user_mail": "test@example.com",
|
||||
"api_token": "test_api_token",
|
||||
"domain": "example.atlassian.net",
|
||||
"domain": "example",
|
||||
}
|
||||
integration.configuration = {}
|
||||
|
||||
@@ -884,7 +967,7 @@ class TestProwlerIntegrationConnectionTest:
|
||||
mock_jira_class.test_connection.assert_called_once_with(
|
||||
user_mail="test@example.com",
|
||||
api_token="test_api_token",
|
||||
domain="example.atlassian.net",
|
||||
domain="example",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
@@ -917,7 +1000,7 @@ class TestProwlerIntegrationConnectionTest:
|
||||
integration.credentials = {
|
||||
"user_mail": "invalid@example.com",
|
||||
"api_token": "invalid_token",
|
||||
"domain": "invalid.atlassian.net",
|
||||
"domain": "invalid",
|
||||
}
|
||||
integration.configuration = {}
|
||||
|
||||
@@ -942,7 +1025,7 @@ class TestProwlerIntegrationConnectionTest:
|
||||
mock_jira_class.test_connection.assert_called_once_with(
|
||||
user_mail="invalid@example.com",
|
||||
api_token="invalid_token",
|
||||
domain="invalid.atlassian.net",
|
||||
domain="invalid",
|
||||
raise_on_exception=False,
|
||||
)
|
||||
|
||||
@@ -970,7 +1053,7 @@ class TestProwlerIntegrationConnectionTest:
|
||||
integration.credentials = {
|
||||
"user_mail": "test@example.com",
|
||||
"api_token": "test_api_token",
|
||||
"domain": "example.atlassian.net",
|
||||
"domain": "example",
|
||||
}
|
||||
integration.configuration = {
|
||||
"issue_types": {"OLD_PROJ": ["Task"]}, # Existing configuration
|
||||
|
||||
@@ -3,9 +3,11 @@ import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from threading import Event, Lock
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, MagicMock, Mock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
@@ -21,6 +23,7 @@ from api.attack_paths import (
|
||||
)
|
||||
from api.compliance import get_compliance_frameworks
|
||||
from api.db_router import MainRouter
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import (
|
||||
AttackSurfaceOverview,
|
||||
ComplianceOverviewSummary,
|
||||
@@ -65,6 +68,7 @@ from api.v1.views import (
|
||||
)
|
||||
from botocore.exceptions import ClientError, NoCredentialsError
|
||||
from celery import states
|
||||
from celery.utils.saferepr import saferepr
|
||||
from conftest import (
|
||||
API_JSON_CONTENT_TYPE,
|
||||
TEST_PASSWORD,
|
||||
@@ -73,7 +77,7 @@ from conftest import (
|
||||
today_after_n_days,
|
||||
)
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
from django.db import close_old_connections, connection
|
||||
from django.db.models import Count
|
||||
from django.http import JsonResponse
|
||||
from django.test import RequestFactory
|
||||
@@ -2917,6 +2921,48 @@ class TestProviderGroupViewSet:
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestProviderSecretViewSet:
|
||||
@staticmethod
|
||||
def _oraclecloud_secret(**overrides):
|
||||
secret = {
|
||||
"user": "ocid1.user.oc1..aaaaaaaakldibrbov4ubh25aqdeiroklxjngwka7u6w7no3glmdq3n5sxtkq",
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_content": "test-key-content",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
}
|
||||
secret.update(overrides)
|
||||
return secret
|
||||
|
||||
def _create_oraclecloud_secret(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
secret,
|
||||
name="OCI Secret",
|
||||
):
|
||||
data = {
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"attributes": {
|
||||
"name": name,
|
||||
"secret_type": ProviderSecret.TypeChoices.STATIC,
|
||||
"secret": secret,
|
||||
},
|
||||
"relationships": {
|
||||
"provider": {
|
||||
"data": {
|
||||
"type": "providers",
|
||||
"id": str(oraclecloud_provider.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
return authenticated_client.post(
|
||||
reverse("providersecret-list"),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
def test_provider_secrets_list(self, authenticated_client, provider_secret_fixture):
|
||||
response = authenticated_client.get(reverse("providersecret-list"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
@@ -3076,7 +3122,6 @@ current-context: test-context
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-key-content\n-----END RSA PRIVATE KEY-----",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
"region": "us-ashburn-1",
|
||||
},
|
||||
),
|
||||
# OCI with API key credentials (with key_file)
|
||||
@@ -3088,7 +3133,6 @@ current-context: test-context
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_file": "/path/to/oci_api_key.pem",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
"region": "us-ashburn-1",
|
||||
},
|
||||
),
|
||||
# OCI with API key credentials (with passphrase)
|
||||
@@ -3100,7 +3144,6 @@ current-context: test-context
|
||||
"fingerprint": "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99",
|
||||
"key_content": "-----BEGIN RSA PRIVATE KEY-----\ntest-encrypted-key\n-----END RSA PRIVATE KEY-----",
|
||||
"tenancy": "ocid1.tenancy.oc1..aaaaaaaa3dwoazoox4q7wrvriywpokp5grlhgnkwtyt6dmwyou7no6mdmzda",
|
||||
"region": "us-ashburn-1",
|
||||
"pass_phrase": "my-secure-passphrase",
|
||||
},
|
||||
),
|
||||
@@ -3258,6 +3301,103 @@ current-context: test-context
|
||||
== data["data"]["relationships"]["provider"]["data"]["id"]
|
||||
)
|
||||
|
||||
def test_provider_secrets_create_oraclecloud_without_region_stores_no_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
):
|
||||
response = self._create_oraclecloud_secret(
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
provider_secret = ProviderSecret.objects.get()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_create_oraclecloud_accepts_and_ignores_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
):
|
||||
response = self._create_oraclecloud_secret(
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(
|
||||
key_content=" test-key-content ", region=" us-ashburn-1 "
|
||||
),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
provider_secret = ProviderSecret.objects.get()
|
||||
assert provider_secret.secret["key_content"] == "test-key-content"
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_update_oraclecloud_without_region_stores_no_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
):
|
||||
create_response = self._create_oraclecloud_secret(
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(),
|
||||
)
|
||||
provider_secret = ProviderSecret.objects.get(
|
||||
id=create_response.json()["data"]["id"]
|
||||
)
|
||||
data = {
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"id": str(provider_secret.id),
|
||||
"attributes": {"secret": self._oraclecloud_secret()},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.patch(
|
||||
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
provider_secret.refresh_from_db()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
def test_provider_secrets_update_oraclecloud_accepts_and_ignores_region(
|
||||
self,
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
):
|
||||
create_response = self._create_oraclecloud_secret(
|
||||
authenticated_client,
|
||||
oraclecloud_provider,
|
||||
self._oraclecloud_secret(),
|
||||
)
|
||||
provider_secret = ProviderSecret.objects.get(
|
||||
id=create_response.json()["data"]["id"]
|
||||
)
|
||||
data = {
|
||||
"data": {
|
||||
"type": "provider-secrets",
|
||||
"id": str(provider_secret.id),
|
||||
"attributes": {
|
||||
"secret": self._oraclecloud_secret(region=" us-ashburn-1 ")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.patch(
|
||||
reverse("providersecret-detail", kwargs={"pk": provider_secret.id}),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
provider_secret.refresh_from_db()
|
||||
assert "region" not in provider_secret.secret
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"attributes, error_code, error_pointer",
|
||||
(
|
||||
@@ -4907,11 +5047,60 @@ class TestTaskViewSet:
|
||||
reverse("task-detail", kwargs={"pk": task1.id}),
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["data"]["attributes"]["task_args"] == {
|
||||
"kwarg1": "value1"
|
||||
}
|
||||
assert (
|
||||
response.json()["data"]["attributes"]["name"]
|
||||
== task1.task_runner_task.task_name
|
||||
)
|
||||
|
||||
def test_tasks_retrieve_hides_tenant_id(
|
||||
self, authenticated_client, tasks_fixture, tenants_fixture
|
||||
):
|
||||
task, *_ = tasks_fixture
|
||||
task.task_runner_task.task_kwargs = json.dumps(
|
||||
repr(
|
||||
{
|
||||
"tenant_id": str(tenants_fixture[0].id),
|
||||
"enabled": True,
|
||||
"scan_id": None,
|
||||
"label": "True North",
|
||||
}
|
||||
)
|
||||
)
|
||||
task.task_runner_task.save(update_fields=["task_kwargs"])
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("task-detail", kwargs={"pk": task.id}),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["data"]["attributes"]["task_args"] == {
|
||||
"enabled": True,
|
||||
"scan_id": None,
|
||||
"label": "True North",
|
||||
}
|
||||
|
||||
def test_tasks_retrieve_with_truncated_kwargs_returns_empty_task_args(
|
||||
self, authenticated_client, tasks_fixture
|
||||
):
|
||||
task, *_ = tasks_fixture
|
||||
kwargs_repr = saferepr(
|
||||
{"finding_ids": [str(uuid4()) for _ in range(30)]}, maxlen=1024
|
||||
)
|
||||
assert "..." in kwargs_repr
|
||||
task.task_runner_task.task_kwargs = json.dumps(kwargs_repr)
|
||||
task.task_runner_task.save(update_fields=["task_kwargs"])
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("task-detail", kwargs={"pk": task.id}),
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.headers["Content-Type"] == API_JSON_CONTENT_TYPE
|
||||
assert response.json()["data"]["attributes"]["task_args"] == {}
|
||||
|
||||
def test_tasks_invalid_retrieve(self, authenticated_client):
|
||||
response = authenticated_client.get(
|
||||
reverse("task-detail", kwargs={"pk": "invalid_id"})
|
||||
@@ -13310,6 +13499,73 @@ class TestIntegrationViewSet:
|
||||
f"Expected type '{expected_type}' not found in included data"
|
||||
)
|
||||
|
||||
# Serializing a Jira integration reads `configuration` to add the domain from the
|
||||
# credentials, and a sparse fieldset can leave that field out of the representation
|
||||
|
||||
def test_integrations_list_sparse_fields_without_configuration(
|
||||
self, authenticated_client, jira_integration_fixture
|
||||
):
|
||||
response = authenticated_client.get(
|
||||
reverse("integration-list"),
|
||||
{"fields[integrations]": "enabled,integration_type"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
attributes = response.json()["data"][0]["attributes"]
|
||||
assert sorted(attributes.keys()) == ["enabled", "integration_type"]
|
||||
|
||||
def test_integrations_retrieve_sparse_fields_without_configuration(
|
||||
self, authenticated_client, jira_integration_fixture
|
||||
):
|
||||
response = authenticated_client.get(
|
||||
reverse("integration-detail", kwargs={"pk": jira_integration_fixture.id}),
|
||||
{"fields[integrations]": "enabled,integration_type"},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "configuration" not in response.json()["data"]["attributes"]
|
||||
|
||||
def test_integrations_partial_update_sparse_fields_without_configuration(
|
||||
self, authenticated_client, jira_integration_fixture
|
||||
):
|
||||
data = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"id": str(jira_integration_fixture.id),
|
||||
"attributes": {"enabled": False},
|
||||
}
|
||||
}
|
||||
|
||||
url = reverse("integration-detail", kwargs={"pk": jira_integration_fixture.id})
|
||||
response = authenticated_client.patch(
|
||||
f"{url}?fields[integrations]=enabled,integration_type",
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "configuration" not in response.json()["data"]["attributes"]
|
||||
with rls_transaction(str(jira_integration_fixture.tenant_id)):
|
||||
jira_integration_fixture.refresh_from_db()
|
||||
assert jira_integration_fixture.enabled is False
|
||||
# Omitting `configuration` from the fieldset must not rewrite it, and the
|
||||
# serialized `domain` must not leak into the stored value
|
||||
assert jira_integration_fixture.configuration == {
|
||||
"projects": {"TEST": "Test project"}
|
||||
}
|
||||
|
||||
def test_integrations_retrieve_jira_keeps_domain_in_configuration(
|
||||
self, authenticated_client, jira_integration_fixture
|
||||
):
|
||||
response = authenticated_client.get(
|
||||
reverse("integration-detail", kwargs={"pk": jira_integration_fixture.id})
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
configuration = response.json()["data"]["attributes"]["configuration"]
|
||||
assert configuration["domain"] == "test"
|
||||
assert configuration["projects"] == {"TEST": "Test project"}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"integration_type, configuration, credentials",
|
||||
[
|
||||
@@ -13423,6 +13679,45 @@ class TestIntegrationViewSet:
|
||||
)
|
||||
assert "credentials" not in response.json()["data"]["attributes"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"domain",
|
||||
(
|
||||
"169.254.169.254#",
|
||||
"internal/service",
|
||||
"internal?target",
|
||||
"internal\\target",
|
||||
"internal:8000",
|
||||
"user@internal",
|
||||
),
|
||||
)
|
||||
def test_integrations_create_jira_rejects_invalid_domain(
|
||||
self, authenticated_client, domain
|
||||
):
|
||||
data = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"attributes": {
|
||||
"integration_type": Integration.IntegrationChoices.JIRA,
|
||||
"configuration": {},
|
||||
"credentials": {
|
||||
"domain": domain,
|
||||
"api_token": "fake-api-token",
|
||||
"user_mail": "testing@prowler.com",
|
||||
},
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("integration-list"),
|
||||
data=json.dumps(data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert Integration.objects.count() == 0
|
||||
|
||||
def test_integrations_create_valid_relationships(
|
||||
self,
|
||||
authenticated_client,
|
||||
@@ -13963,6 +14258,55 @@ class TestIntegrationViewSet:
|
||||
assert "projects" in configuration
|
||||
assert "issue_types" in configuration
|
||||
|
||||
def test_integrations_update_jira_rejects_invalid_domain(
|
||||
self, authenticated_client
|
||||
):
|
||||
create_data = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"attributes": {
|
||||
"integration_type": Integration.IntegrationChoices.JIRA,
|
||||
"configuration": {},
|
||||
"credentials": {
|
||||
"user_mail": "test@example.com",
|
||||
"api_token": "fake-api-token",
|
||||
"domain": "original-domain",
|
||||
},
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
}
|
||||
create_response = authenticated_client.post(
|
||||
reverse("integration-list"),
|
||||
data=json.dumps(create_data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert create_response.status_code == status.HTTP_201_CREATED
|
||||
integration_id = create_response.json()["data"]["id"]
|
||||
|
||||
update_data = {
|
||||
"data": {
|
||||
"type": "integrations",
|
||||
"id": integration_id,
|
||||
"attributes": {
|
||||
"credentials": {
|
||||
"user_mail": "test@example.com",
|
||||
"api_token": "fake-api-token",
|
||||
"domain": "169.254.169.254#",
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
response = authenticated_client.patch(
|
||||
reverse("integration-detail", kwargs={"pk": integration_id}),
|
||||
data=json.dumps(update_data),
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
integration = Integration.objects.get(id=integration_id)
|
||||
assert integration.credentials["domain"] == "original-domain"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSAMLTokenValidation:
|
||||
@@ -14569,19 +14913,94 @@ class TestTenantFinishACSView:
|
||||
# Verify no new role was created
|
||||
assert Role.objects.using(MainRouter.admin_db).count() == roles_before
|
||||
|
||||
def test_dispatch_assigns_no_role_to_new_user_when_usertype_missing(
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"existing_role_attributes",
|
||||
"existing_suffixes",
|
||||
"expected_role_name",
|
||||
"expected_role_created",
|
||||
),
|
||||
[
|
||||
(None, (), "read_only", True),
|
||||
({"unlimited_visibility": True}, (), "read_only", False),
|
||||
(
|
||||
{"manage_users": True, "unlimited_visibility": True},
|
||||
(
|
||||
("read_only_0", {"unlimited_visibility": True}),
|
||||
("read_only_1", {"unlimited_visibility": True}),
|
||||
),
|
||||
"read_only_0",
|
||||
False,
|
||||
),
|
||||
(
|
||||
{"manage_users": True, "unlimited_visibility": True},
|
||||
(
|
||||
(
|
||||
"read_only_0",
|
||||
{"manage_users": True, "unlimited_visibility": True},
|
||||
),
|
||||
("read_only_1", {"unlimited_visibility": True}),
|
||||
),
|
||||
"read_only_1",
|
||||
False,
|
||||
),
|
||||
({"unlimited_visibility": False}, (), "read_only_0", True),
|
||||
],
|
||||
ids=[
|
||||
"creates-role",
|
||||
"reuses-safe-role",
|
||||
"reuses-first-safe-suffixed-role",
|
||||
"skips-unsafe-suffixed-role",
|
||||
"avoids-restricted-visibility",
|
||||
],
|
||||
)
|
||||
def test_dispatch_assigns_read_only_role_when_usertype_missing(
|
||||
self,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
saml_setup,
|
||||
settings,
|
||||
monkeypatch,
|
||||
existing_role_attributes,
|
||||
existing_suffixes,
|
||||
expected_role_name,
|
||||
expected_role_created,
|
||||
):
|
||||
"""Test that a user without roles gets none assigned when userType is missing"""
|
||||
"""Test safe fallback role assignment when userType is missing"""
|
||||
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
user = create_test_user
|
||||
tenant = tenants_fixture[0]
|
||||
roles_before = Role.objects.using(MainRouter.admin_db).count()
|
||||
other_tenant = tenants_fixture[1]
|
||||
|
||||
other_tenant_role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name="read_only",
|
||||
tenant=other_tenant,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
other_tenant_relationship = UserRoleRelationship.objects.using(
|
||||
MainRouter.admin_db
|
||||
).create(
|
||||
user=user,
|
||||
role=other_tenant_role,
|
||||
tenant=other_tenant,
|
||||
)
|
||||
|
||||
existing_role = None
|
||||
if existing_role_attributes is not None:
|
||||
existing_role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name="read_only",
|
||||
tenant=tenant,
|
||||
**existing_role_attributes,
|
||||
)
|
||||
for role_name, role_attributes in existing_suffixes:
|
||||
Role.objects.using(MainRouter.admin_db).create(
|
||||
name=role_name,
|
||||
tenant=tenant,
|
||||
**role_attributes,
|
||||
)
|
||||
roles_before = (
|
||||
Role.objects.using(MainRouter.admin_db).filter(tenant=tenant).count()
|
||||
)
|
||||
|
||||
social_account = SocialAccount(
|
||||
user=user,
|
||||
@@ -14634,12 +15053,44 @@ class TestTenantFinishACSView:
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
# Verify no role was created or assigned
|
||||
assert Role.objects.using(MainRouter.admin_db).count() == roles_before
|
||||
assert not (
|
||||
# Verify the fallback role was created or reused with read-only access
|
||||
expected_role_count = roles_before + expected_role_created
|
||||
assert (
|
||||
Role.objects.using(MainRouter.admin_db).filter(tenant=tenant).count()
|
||||
== expected_role_count
|
||||
)
|
||||
role = Role.objects.using(MainRouter.admin_db).get(
|
||||
name=expected_role_name, tenant=tenant
|
||||
)
|
||||
if existing_role is not None and expected_role_name == "read_only":
|
||||
assert role == existing_role
|
||||
assert not role.manage_users
|
||||
assert not role.manage_account
|
||||
assert not role.manage_billing
|
||||
assert not role.manage_providers
|
||||
assert not role.manage_integrations
|
||||
assert not role.manage_scans
|
||||
assert role.unlimited_visibility
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, role=role, tenant_id=tenant.id)
|
||||
.exists()
|
||||
)
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(
|
||||
id=other_tenant_relationship.id,
|
||||
user=user,
|
||||
role=other_tenant_role,
|
||||
tenant_id=other_tenant.id,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, tenant_id=tenant.id)
|
||||
.exists()
|
||||
.count()
|
||||
== 1
|
||||
)
|
||||
|
||||
# Membership is still created so the user belongs to the tenant
|
||||
@@ -14649,6 +15100,131 @@ class TestTenantFinishACSView:
|
||||
.exists()
|
||||
)
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_dispatch_serializes_concurrent_fallback_role_assignment(
|
||||
self,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
saml_setup,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Test concurrent callbacks assign only one fallback role"""
|
||||
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
user = create_test_user
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
Role.objects.using(MainRouter.admin_db).create(
|
||||
name="read_only",
|
||||
tenant=tenant,
|
||||
manage_users=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
|
||||
social_account = SocialAccount(
|
||||
user=user,
|
||||
provider="saml",
|
||||
extra_data={
|
||||
"firstName": ["John"],
|
||||
"lastName": ["Doe"],
|
||||
"organization": ["testing_company"],
|
||||
},
|
||||
)
|
||||
# Without the user lock, both callbacks reach this query before either
|
||||
# creates a fallback. With the lock, the first callback times out here
|
||||
# while the second waits for the transaction to finish.
|
||||
second_role_check_reached = Event()
|
||||
concurrent_role_checks_detected = Event()
|
||||
role_check_count_lock = Lock()
|
||||
role_check_count = 0
|
||||
original_role_check = TenantFinishACSView._user_has_tenant_role
|
||||
|
||||
def synchronize_role_checks(user_id, tenant_id):
|
||||
nonlocal role_check_count
|
||||
with role_check_count_lock:
|
||||
role_check_count += 1
|
||||
is_first_role_check = role_check_count == 1
|
||||
if role_check_count == 2:
|
||||
second_role_check_reached.set()
|
||||
if is_first_role_check and second_role_check_reached.wait(timeout=1):
|
||||
concurrent_role_checks_detected.set()
|
||||
return original_role_check(user_id, tenant_id)
|
||||
|
||||
def dispatch_callback():
|
||||
close_old_connections()
|
||||
try:
|
||||
thread_user = User.objects.using(MainRouter.admin_db).get(pk=user.pk)
|
||||
request = RequestFactory().get(
|
||||
reverse(
|
||||
"saml_finish_acs",
|
||||
kwargs={"organization_slug": saml_setup["domain"]},
|
||||
)
|
||||
)
|
||||
request.user = thread_user
|
||||
request.session = {}
|
||||
response = TenantFinishACSView.as_view()(
|
||||
request, organization_slug=saml_setup["domain"]
|
||||
)
|
||||
return response
|
||||
finally:
|
||||
close_old_connections()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
) as mock_get_app_or_404,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialApp.objects.get"
|
||||
) as mock_socialapp_get,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialAccount.objects.get"
|
||||
) as mock_sa_get,
|
||||
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
|
||||
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
|
||||
patch("api.models.User.objects.get") as mock_user_get,
|
||||
patch.object(
|
||||
TenantFinishACSView,
|
||||
"_user_has_tenant_role",
|
||||
side_effect=synchronize_role_checks,
|
||||
),
|
||||
):
|
||||
mock_get_app_or_404.return_value = MagicMock(
|
||||
provider="saml",
|
||||
client_id=saml_setup["domain"],
|
||||
name="Test App",
|
||||
settings={},
|
||||
)
|
||||
mock_sa_get.return_value = social_account
|
||||
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
|
||||
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
|
||||
mock_saml_config_get.return_value = SimpleNamespace(
|
||||
email_domain=saml_setup["domain"], tenant=tenant
|
||||
)
|
||||
mock_user_get.side_effect = lambda *_args, **_kwargs: User.objects.using(
|
||||
MainRouter.admin_db
|
||||
).get(pk=user.pk)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
responses = list(executor.map(lambda _: dispatch_callback(), range(2)))
|
||||
|
||||
assert role_check_count == 2
|
||||
assert not concurrent_role_checks_detected.is_set()
|
||||
for response in responses:
|
||||
assert response.status_code == status.HTTP_302_FOUND
|
||||
parsed_redirect = urlparse(response.url)
|
||||
assert parsed_redirect.path == "/sso-complete"
|
||||
assert set(parse_qs(parsed_redirect.query)) == {"id"}
|
||||
relationships = UserRoleRelationship.objects.using(MainRouter.admin_db).filter(
|
||||
user=user, tenant_id=tenant.id
|
||||
)
|
||||
assert relationships.count() == 1
|
||||
assert relationships.get().role.name == "read_only_0"
|
||||
assert (
|
||||
Role.objects.using(MainRouter.admin_db)
|
||||
.filter(tenant=tenant, name__startswith="read_only_")
|
||||
.count()
|
||||
== 1
|
||||
)
|
||||
|
||||
def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_new_role(
|
||||
self,
|
||||
create_test_user,
|
||||
@@ -15524,6 +16100,23 @@ class TestTenantApiKeyViewSet:
|
||||
data = response.json()["data"]
|
||||
assert len(data) == len(api_keys_fixture)
|
||||
|
||||
def test_api_keys_list_with_orphaned_key(
|
||||
self, authenticated_client, api_keys_fixture
|
||||
):
|
||||
"""Test listing keys whose owner was deleted: `entity` is serialized as null."""
|
||||
orphaned_key = api_keys_fixture[0]
|
||||
TenantAPIKey.objects.filter(id=orphaned_key.id).update(entity=None)
|
||||
|
||||
response = authenticated_client.get(reverse("api-key-list"))
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) == len(api_keys_fixture)
|
||||
serialized_key = next(
|
||||
item for item in data if item["id"] == str(orphaned_key.id)
|
||||
)
|
||||
assert serialized_key["relationships"]["entity"]["data"] is None
|
||||
|
||||
def test_api_keys_list_empty(self, authenticated_client, tenants_fixture):
|
||||
"""Test listing API keys when none exist returns empty list."""
|
||||
response = authenticated_client.get(reverse("api-key-list"))
|
||||
|
||||
@@ -252,12 +252,6 @@ def get_prowler_provider_kwargs(
|
||||
**prowler_provider_kwargs,
|
||||
"filter_accounts": [provider.uid],
|
||||
}
|
||||
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
if isinstance(prowler_provider_kwargs.get("region"), str):
|
||||
prowler_provider_kwargs = {
|
||||
**prowler_provider_kwargs,
|
||||
"region": {prowler_provider_kwargs["region"]},
|
||||
}
|
||||
elif provider.provider == Provider.ProviderChoices.OPENSTACK.value:
|
||||
# clouds_yaml_content, clouds_yaml_cloud and provider_id are validated
|
||||
# in the provider itself, so it's not needed here.
|
||||
@@ -288,6 +282,11 @@ def get_prowler_provider_kwargs(
|
||||
**{k: v for k, v in prowler_provider_kwargs.items() if v},
|
||||
}
|
||||
|
||||
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
prowler_provider_kwargs = _normalize_oraclecloud_provider_kwargs(
|
||||
prowler_provider_kwargs
|
||||
)
|
||||
|
||||
if mutelist_processor:
|
||||
mutelist_content = mutelist_processor.configuration.get("Mutelist", {})
|
||||
# IaC and Image providers don't support mutelist (both use Trivy's built-in logic)
|
||||
@@ -300,6 +299,40 @@ def get_prowler_provider_kwargs(
|
||||
return prowler_provider_kwargs
|
||||
|
||||
|
||||
def _normalize_oraclecloud_provider_kwargs(secret: dict) -> dict:
|
||||
"""Normalize external OCI secret fields into SDK provider kwargs."""
|
||||
prowler_provider_kwargs = secret.copy()
|
||||
prowler_provider_kwargs.pop("region", None)
|
||||
|
||||
return prowler_provider_kwargs
|
||||
|
||||
|
||||
def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
|
||||
"""Normalize external OCI secret fields into test_connection kwargs."""
|
||||
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
|
||||
|
||||
prowler_provider_kwargs = secret.copy()
|
||||
prowler_provider_kwargs.pop("region", None)
|
||||
|
||||
if (
|
||||
prowler_provider_kwargs.get("user")
|
||||
and prowler_provider_kwargs.get("fingerprint")
|
||||
and prowler_provider_kwargs.get("tenancy")
|
||||
and (
|
||||
prowler_provider_kwargs.get("key_content")
|
||||
or prowler_provider_kwargs.get("key_file")
|
||||
)
|
||||
):
|
||||
# Connection validation needs one OCI endpoint, but scans remain unfiltered.
|
||||
prowler_provider_kwargs["region"] = getattr(
|
||||
OraclecloudProvider,
|
||||
"_bootstrap_region",
|
||||
OraclecloudProvider._home_region,
|
||||
)
|
||||
|
||||
return prowler_provider_kwargs
|
||||
|
||||
|
||||
def initialize_prowler_provider(
|
||||
provider: Provider,
|
||||
mutelist_processor: Processor | None = None,
|
||||
@@ -402,6 +435,15 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
|
||||
if prowler_provider_kwargs.get("registry_token"):
|
||||
image_kwargs["registry_token"] = prowler_provider_kwargs["registry_token"]
|
||||
return prowler_provider.test_connection(**image_kwargs)
|
||||
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
oraclecloud_kwargs = _normalize_oraclecloud_connection_test_kwargs(
|
||||
prowler_provider_kwargs
|
||||
)
|
||||
return prowler_provider.test_connection(
|
||||
**oraclecloud_kwargs,
|
||||
provider_id=provider.uid,
|
||||
raise_on_exception=False,
|
||||
)
|
||||
else:
|
||||
return prowler_provider.test_connection(
|
||||
**prowler_provider_kwargs,
|
||||
|
||||
@@ -6,9 +6,11 @@ from api.exceptions import (
|
||||
TaskNotFoundException,
|
||||
)
|
||||
from api.models import Provider, StateChoices, Task
|
||||
from api.rbac.permissions import get_providers
|
||||
from api.v1.serializers import TaskSerializer
|
||||
from django.http import QueryDict
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django_celery_results.models import TaskResult
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import ValidationError
|
||||
@@ -33,6 +35,22 @@ class DisablePaginationMixin:
|
||||
return super().paginate_queryset(queryset)
|
||||
|
||||
|
||||
class ProviderVisibilityMixin:
|
||||
@cached_property
|
||||
def provider_queryset(self):
|
||||
if self.user_role.unlimited_visibility:
|
||||
return Provider.objects.filter(tenant_id=self.request.tenant_id)
|
||||
return get_providers(self.user_role)
|
||||
|
||||
def get_provider_queryset(self):
|
||||
return self.provider_queryset
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
context["provider_queryset"] = self.get_provider_queryset()
|
||||
return context
|
||||
|
||||
|
||||
class PaginateByPkMixin:
|
||||
"""
|
||||
Mixin to paginate on a list of PKs (cheaper than heavy JOINs),
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
from api.models import Integration, IntegrationProviderRelationship, Provider
|
||||
from api.v1.serializer_utils.base import BaseValidateSerializer
|
||||
from django.db import transaction
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework_json_api import serializers
|
||||
|
||||
ATLASSIAN_SITE_NAME_REGEX = re.compile(
|
||||
r"\A[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\Z"
|
||||
)
|
||||
|
||||
|
||||
def replace_integration_providers(
|
||||
integration: Integration, providers: list[Provider], tenant_id: str
|
||||
) -> None:
|
||||
"""Replace the provider relationships of an integration with the given set."""
|
||||
# Atomic on its own, so callers without an ambient transaction cannot leave the
|
||||
# integration with no relationships if the recreation fails halfway
|
||||
with transaction.atomic():
|
||||
IntegrationProviderRelationship.objects.filter(integration=integration).delete()
|
||||
IntegrationProviderRelationship.objects.bulk_create(
|
||||
[
|
||||
IntegrationProviderRelationship(
|
||||
integration=integration, provider=provider, tenant_id=tenant_id
|
||||
)
|
||||
for provider in providers
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class S3ConfigSerializer(BaseValidateSerializer):
|
||||
bucket_name = serializers.CharField()
|
||||
@@ -97,7 +121,17 @@ class AWSCredentialSerializer(BaseValidateSerializer):
|
||||
class JiraCredentialSerializer(BaseValidateSerializer):
|
||||
user_mail = serializers.EmailField(required=True)
|
||||
api_token = serializers.CharField(required=True)
|
||||
domain = serializers.CharField(required=True)
|
||||
domain = serializers.RegexField(
|
||||
regex=ATLASSIAN_SITE_NAME_REGEX,
|
||||
required=True,
|
||||
trim_whitespace=False,
|
||||
error_messages={
|
||||
"invalid": (
|
||||
"Domain must be a valid Atlassian site name containing only "
|
||||
"letters, numbers, and hyphens."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
class Meta:
|
||||
resource_name = "integrations"
|
||||
@@ -170,7 +204,10 @@ class JiraCredentialSerializer(BaseValidateSerializer):
|
||||
},
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"description": "The JIRA domain/instance URL (e.g., 'your-domain.atlassian.net').",
|
||||
"description": "The Jira site name without the '.atlassian.net' suffix (e.g., 'your-domain').",
|
||||
"minLength": 1,
|
||||
"maxLength": 63,
|
||||
"pattern": "^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$",
|
||||
},
|
||||
},
|
||||
"required": ["user_mail", "api_token", "domain"],
|
||||
|
||||
@@ -214,7 +214,7 @@ from rest_framework_json_api import serializers
|
||||
"kubeconfig_content": {
|
||||
"type": "string",
|
||||
"description": "The content of the Kubernetes kubeconfig file, encoded as a string. "
|
||||
"Kubeconfig exec authentication is not supported in Prowler Cloud for security reasons.",
|
||||
"Kubeconfig command-based authentication is not supported in Prowler Cloud for security reasons.",
|
||||
}
|
||||
},
|
||||
"required": ["kubeconfig_content"],
|
||||
@@ -295,16 +295,21 @@ from rest_framework_json_api import serializers
|
||||
"type": "string",
|
||||
"description": "The OCID of the tenancy.",
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"description": "The OCI region identifier (e.g., us-ashburn-1, us-phoenix-1).",
|
||||
},
|
||||
"pass_phrase": {
|
||||
"type": "string",
|
||||
"description": "The passphrase for the private key, if encrypted.",
|
||||
},
|
||||
"region": {
|
||||
"type": "string",
|
||||
"deprecated": True,
|
||||
"description": "Legacy OCI region field accepted for backwards compatibility but ignored; OCI scans all regions.",
|
||||
},
|
||||
},
|
||||
"required": ["user", "fingerprint", "tenancy", "region"],
|
||||
"required": ["user", "fingerprint", "tenancy"],
|
||||
"anyOf": [
|
||||
{"required": ["key_file"]},
|
||||
{"required": ["key_content"]},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import yaml
|
||||
from api.celery_utils import decode_celery_field
|
||||
from api.db_router import MainRouter
|
||||
from api.exceptions import ConflictException
|
||||
from api.models import (
|
||||
@@ -47,6 +49,7 @@ from api.v1.serializer_utils.integrations import (
|
||||
JiraCredentialSerializer,
|
||||
S3ConfigSerializer,
|
||||
SecurityHubConfigSerializer,
|
||||
replace_integration_providers,
|
||||
)
|
||||
from api.v1.serializer_utils.lighthouse import (
|
||||
BedrockCredentialsSerializer,
|
||||
@@ -58,6 +61,7 @@ from api.v1.serializer_utils.lighthouse import (
|
||||
from api.v1.serializer_utils.processors import ProcessorConfigField
|
||||
from api.v1.serializer_utils.providers import ProviderSecretField
|
||||
from api.validators import validate_lighthouse_openai_compatible_base_url
|
||||
from config.custom_logging import BackendLogger
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth.models import update_last_login
|
||||
@@ -78,6 +82,8 @@ from rest_framework_simplejwt.settings import api_settings
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
from rest_framework_simplejwt.utils import get_md5_hash_password
|
||||
|
||||
logger = logging.getLogger(BackendLogger.API)
|
||||
|
||||
# Base
|
||||
|
||||
|
||||
@@ -123,6 +129,20 @@ class RLSSerializer(BaseModelSerializerV1):
|
||||
return super().create(validated_data)
|
||||
|
||||
|
||||
class ScopedProviderFieldMixin:
|
||||
provider_field_name = "provider"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
provider_queryset = self.context.get("provider_queryset")
|
||||
provider_field = self.fields.get(self.provider_field_name)
|
||||
if provider_queryset is None or provider_field is None:
|
||||
return
|
||||
|
||||
related_field = getattr(provider_field, "child_relation", provider_field)
|
||||
related_field.queryset = provider_queryset
|
||||
|
||||
|
||||
class StateEnumSerializerField(serializers.ChoiceField):
|
||||
def __init__(self, **kwargs):
|
||||
kwargs["choices"] = StateChoices.choices
|
||||
@@ -606,13 +626,24 @@ class TaskSerializer(RLSSerializer, TaskBase):
|
||||
|
||||
@extend_schema_field(serializers.JSONField())
|
||||
def get_task_args(self, obj):
|
||||
task_args = self.get_json_field(obj, "task_kwargs")
|
||||
# Celery task_kwargs are stored as a double string JSON in the database when not empty
|
||||
if isinstance(task_args, str):
|
||||
task_args = json.loads(task_args.replace("'", '"').replace("None", "null"))
|
||||
# Remove tenant_id from task_kwargs if present
|
||||
task_args.pop("tenant_id", None)
|
||||
task_kwargs = (
|
||||
getattr(obj.task_runner_task, "task_kwargs", None)
|
||||
if obj.task_runner_task
|
||||
else None
|
||||
)
|
||||
try:
|
||||
task_args = decode_celery_field(task_kwargs, {})
|
||||
if not isinstance(task_args, dict):
|
||||
raise ValueError("Decoded task kwargs must be a dictionary")
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Unable to decode task kwargs for task %s; returning empty task_args.",
|
||||
obj.id,
|
||||
)
|
||||
return {}
|
||||
|
||||
task_args = task_args.copy()
|
||||
task_args.pop("tenant_id", None)
|
||||
return task_args
|
||||
|
||||
@staticmethod
|
||||
@@ -692,7 +723,10 @@ class MembershipIncludeSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
# Provider Groups
|
||||
class ProviderGroupSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
class ProviderGroupSerializer(
|
||||
ScopedProviderFieldMixin, RLSSerializer, BaseWriteSerializer
|
||||
):
|
||||
provider_field_name = "providers"
|
||||
providers = serializers.ResourceRelatedField(
|
||||
queryset=Provider.objects.all(), many=True, required=False
|
||||
)
|
||||
@@ -850,9 +884,27 @@ class ProviderGroupMembershipSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
help_text="List of resource identifier objects representing providers.",
|
||||
)
|
||||
|
||||
def get_providers(self, validated_data):
|
||||
provider_ids = {item["id"] for item in validated_data["providers"]}
|
||||
provider_queryset = self.context.get("provider_queryset")
|
||||
if provider_queryset is None:
|
||||
provider_queryset = Provider.objects.filter(
|
||||
tenant_id=self.context.get("tenant_id")
|
||||
)
|
||||
|
||||
providers = list(provider_queryset.filter(id__in=provider_ids))
|
||||
if {provider.id for provider in providers} != provider_ids:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"providers": (
|
||||
"One or more providers do not exist or are not accessible."
|
||||
)
|
||||
}
|
||||
)
|
||||
return providers
|
||||
|
||||
def create(self, validated_data):
|
||||
provider_ids = [item["id"] for item in validated_data["providers"]]
|
||||
providers = Provider.objects.filter(id__in=provider_ids)
|
||||
providers = self.get_providers(validated_data)
|
||||
tenant_id = self.context.get("tenant_id")
|
||||
|
||||
new_relationships = [
|
||||
@@ -868,8 +920,7 @@ class ProviderGroupMembershipSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
return self.context.get("provider_group")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
provider_ids = [item["id"] for item in validated_data["providers"]]
|
||||
providers = Provider.objects.filter(id__in=provider_ids)
|
||||
providers = self.get_providers(validated_data)
|
||||
tenant_id = self.context.get("tenant_id")
|
||||
|
||||
instance.providers.clear()
|
||||
@@ -1108,7 +1159,9 @@ class ScanIncludeSerializer(RLSSerializer):
|
||||
}
|
||||
|
||||
|
||||
class ScanCreateSerializer(RLSSerializer, BaseWriteSerializer):
|
||||
class ScanCreateSerializer(
|
||||
ScopedProviderFieldMixin, RLSSerializer, BaseWriteSerializer
|
||||
):
|
||||
class Meta:
|
||||
model = Scan
|
||||
# TODO: add mutelist when implemented
|
||||
@@ -1568,14 +1621,14 @@ class FindingMetadataSerializer(BaseSerializerV1):
|
||||
|
||||
|
||||
# Provider secrets
|
||||
KUBERNETES_KUBECONFIG_EXEC_ERROR = (
|
||||
"Kubernetes kubeconfig exec authentication is not supported in Prowler Cloud "
|
||||
"for security reasons."
|
||||
KUBERNETES_KUBECONFIG_UNSUPPORTED_COMMAND_AUTH_ERROR = (
|
||||
"Kubernetes kubeconfig command-based authentication is not supported in "
|
||||
"Prowler Cloud for security reasons."
|
||||
)
|
||||
KUBERNETES_KUBECONFIG_INVALID_ERROR = "Invalid Kubernetes kubeconfig content."
|
||||
|
||||
|
||||
def kubeconfig_contains_exec_auth(kubeconfig: dict) -> bool:
|
||||
def kubeconfig_contains_unsupported_command_auth(kubeconfig: dict) -> bool:
|
||||
users = kubeconfig.get("users", [])
|
||||
if not isinstance(users, list):
|
||||
raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
|
||||
@@ -1591,6 +1644,17 @@ def kubeconfig_contains_exec_auth(kubeconfig: dict) -> bool:
|
||||
if "exec" in user:
|
||||
return True
|
||||
|
||||
auth_provider = user.get("auth-provider", {})
|
||||
if not isinstance(auth_provider, dict):
|
||||
continue
|
||||
|
||||
auth_provider_config = auth_provider.get("config", {})
|
||||
if not isinstance(auth_provider_config, dict):
|
||||
continue
|
||||
|
||||
if "cmd-path" in auth_provider_config:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@@ -1672,6 +1736,7 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
|
||||
validation_error.detail[f"secret/{key}"] = value
|
||||
del validation_error.detail[key]
|
||||
raise validation_error
|
||||
return serializer.validated_data
|
||||
|
||||
|
||||
class AwsProviderSecret(serializers.Serializer):
|
||||
@@ -1786,8 +1851,10 @@ class KubernetesProviderSecret(serializers.Serializer):
|
||||
if not isinstance(kubeconfig, dict):
|
||||
raise serializers.ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
|
||||
|
||||
if kubeconfig_contains_exec_auth(kubeconfig):
|
||||
raise serializers.ValidationError(KUBERNETES_KUBECONFIG_EXEC_ERROR)
|
||||
if kubeconfig_contains_unsupported_command_auth(kubeconfig):
|
||||
raise serializers.ValidationError(
|
||||
KUBERNETES_KUBECONFIG_UNSUPPORTED_COMMAND_AUTH_ERROR
|
||||
)
|
||||
|
||||
return kubeconfig_content
|
||||
|
||||
@@ -1813,14 +1880,32 @@ class IacProviderSecret(serializers.Serializer):
|
||||
resource_name = "provider-secrets"
|
||||
|
||||
|
||||
class LegacyOCIRegionField(serializers.Field):
|
||||
def to_internal_value(self, data):
|
||||
return data
|
||||
|
||||
def to_representation(self, value):
|
||||
return value
|
||||
|
||||
|
||||
class OracleCloudProviderSecret(serializers.Serializer):
|
||||
user = serializers.CharField()
|
||||
fingerprint = serializers.CharField()
|
||||
key_file = serializers.CharField(required=False)
|
||||
key_content = serializers.CharField(required=False)
|
||||
tenancy = serializers.CharField()
|
||||
region = serializers.CharField()
|
||||
pass_phrase = serializers.CharField(required=False)
|
||||
region = LegacyOCIRegionField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs.pop("region", None)
|
||||
|
||||
if "key_file" not in attrs and "key_content" not in attrs:
|
||||
raise serializers.ValidationError(
|
||||
{"key_file": "Either key_file or key_content must be provided."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
class Meta:
|
||||
resource_name = "provider-secrets"
|
||||
@@ -1941,7 +2026,9 @@ class ProviderSecretSerializer(RLSSerializer):
|
||||
]
|
||||
|
||||
|
||||
class ProviderSecretCreateSerializer(RLSSerializer, BaseWriteProviderSecretSerializer):
|
||||
class ProviderSecretCreateSerializer(
|
||||
ScopedProviderFieldMixin, RLSSerializer, BaseWriteProviderSecretSerializer
|
||||
):
|
||||
secret = ProviderSecretField(write_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -1965,7 +2052,11 @@ class ProviderSecretCreateSerializer(RLSSerializer, BaseWriteProviderSecretSeria
|
||||
secret = attrs.get("secret")
|
||||
|
||||
validated_attrs = super().validate(attrs)
|
||||
self.validate_secret_based_on_provider(provider.provider, secret_type, secret)
|
||||
validated_secret = self.validate_secret_based_on_provider(
|
||||
provider.provider, secret_type, secret
|
||||
)
|
||||
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
validated_attrs["secret"] = validated_secret
|
||||
return validated_attrs
|
||||
|
||||
|
||||
@@ -1997,7 +2088,11 @@ class ProviderSecretUpdateSerializer(BaseWriteProviderSecretSerializer):
|
||||
secret = attrs.get("secret")
|
||||
|
||||
validated_attrs = super().validate(attrs)
|
||||
self.validate_secret_based_on_provider(provider.provider, secret_type, secret)
|
||||
validated_secret = self.validate_secret_based_on_provider(
|
||||
provider.provider, secret_type, secret
|
||||
)
|
||||
if provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
|
||||
validated_attrs["secret"] = validated_secret
|
||||
return validated_attrs
|
||||
|
||||
|
||||
@@ -2716,6 +2811,37 @@ class ScheduleDailyCreateSerializer(BaseSerializerV1):
|
||||
# Integrations
|
||||
|
||||
|
||||
class IntegrationProviderVisibilityMixin:
|
||||
"""
|
||||
Keep the `providers` relationship within the provider visibility of the role.
|
||||
|
||||
The view injects `allowed_providers` in the serializer context: `None` when the role
|
||||
has unlimited visibility, and the queryset of visible providers otherwise. Roles with
|
||||
limited visibility can neither attach providers they cannot see nor discover, through
|
||||
the serialized output, the ones already attached.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
allowed_providers = self.context.get("allowed_providers")
|
||||
if allowed_providers is not None:
|
||||
self.fields["providers"].child_relation.queryset = allowed_providers
|
||||
|
||||
def hide_restricted_providers(self, representation: dict) -> dict:
|
||||
allowed_providers = self.context.get("allowed_providers")
|
||||
# `providers` is missing when the request asks for a subset of the fields
|
||||
if allowed_providers is None or "providers" not in representation:
|
||||
return representation
|
||||
|
||||
allowed_provider_ids = {str(provider.id) for provider in allowed_providers}
|
||||
representation["providers"] = [
|
||||
provider
|
||||
for provider in representation["providers"]
|
||||
if provider["id"] in allowed_provider_ids
|
||||
]
|
||||
return representation
|
||||
|
||||
|
||||
class BaseWriteIntegrationSerializer(BaseWriteSerializer):
|
||||
def validate(self, attrs):
|
||||
integration_type = attrs.get("integration_type")
|
||||
@@ -2848,7 +2974,7 @@ class BaseWriteIntegrationSerializer(BaseWriteSerializer):
|
||||
)
|
||||
|
||||
|
||||
class IntegrationSerializer(RLSSerializer):
|
||||
class IntegrationSerializer(IntegrationProviderVisibilityMixin, RLSSerializer):
|
||||
"""
|
||||
Serializer for the Integration model.
|
||||
"""
|
||||
@@ -2877,23 +3003,24 @@ class IntegrationSerializer(RLSSerializer):
|
||||
}
|
||||
|
||||
def to_representation(self, instance):
|
||||
representation = super().to_representation(instance)
|
||||
allowed_providers = self.context.get("allowed_providers")
|
||||
if allowed_providers:
|
||||
allowed_provider_ids = {str(provider.id) for provider in allowed_providers}
|
||||
representation["providers"] = [
|
||||
provider
|
||||
for provider in representation["providers"]
|
||||
if provider["id"] in allowed_provider_ids
|
||||
]
|
||||
if instance.integration_type == Integration.IntegrationChoices.JIRA:
|
||||
representation["configuration"].update(
|
||||
{"domain": instance.credentials.get("domain")}
|
||||
)
|
||||
representation = self.hide_restricted_providers(
|
||||
super().to_representation(instance)
|
||||
)
|
||||
# `configuration` is missing when the request asks for a subset of the fields
|
||||
if (
|
||||
instance.integration_type == Integration.IntegrationChoices.JIRA
|
||||
and "configuration" in representation
|
||||
):
|
||||
representation["configuration"] = {
|
||||
**representation["configuration"],
|
||||
"domain": instance.credentials.get("domain"),
|
||||
}
|
||||
return representation
|
||||
|
||||
|
||||
class IntegrationCreateSerializer(BaseWriteIntegrationSerializer):
|
||||
class IntegrationCreateSerializer(
|
||||
IntegrationProviderVisibilityMixin, BaseWriteIntegrationSerializer
|
||||
):
|
||||
credentials = IntegrationCredentialField(write_only=True)
|
||||
configuration = IntegrationConfigField()
|
||||
providers = serializers.ResourceRelatedField(
|
||||
@@ -2944,22 +3071,18 @@ class IntegrationCreateSerializer(BaseWriteIntegrationSerializer):
|
||||
tenant_id = self.context.get("tenant_id")
|
||||
|
||||
providers = validated_data.pop("providers", [])
|
||||
integration = Integration.objects.create(tenant_id=tenant_id, **validated_data)
|
||||
|
||||
through_model_instances = [
|
||||
IntegrationProviderRelationship(
|
||||
integration=integration,
|
||||
provider=provider,
|
||||
tenant_id=tenant_id,
|
||||
with transaction.atomic():
|
||||
integration = Integration.objects.create(
|
||||
tenant_id=tenant_id, **validated_data
|
||||
)
|
||||
for provider in providers
|
||||
]
|
||||
IntegrationProviderRelationship.objects.bulk_create(through_model_instances)
|
||||
replace_integration_providers(integration, providers, tenant_id)
|
||||
|
||||
return integration
|
||||
|
||||
|
||||
class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
|
||||
class IntegrationUpdateSerializer(
|
||||
IntegrationProviderVisibilityMixin, BaseWriteIntegrationSerializer
|
||||
):
|
||||
credentials = IntegrationCredentialField(write_only=True, required=False)
|
||||
configuration = IntegrationConfigField(required=False)
|
||||
providers = serializers.ResourceRelatedField(
|
||||
@@ -3004,15 +3127,13 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
tenant_id = self.context.get("tenant_id")
|
||||
if validated_data.get("providers") is not None:
|
||||
instance.providers.clear()
|
||||
new_relationships = [
|
||||
IntegrationProviderRelationship(
|
||||
integration=instance, provider=provider, tenant_id=tenant_id
|
||||
)
|
||||
for provider in validated_data["providers"]
|
||||
]
|
||||
IntegrationProviderRelationship.objects.bulk_create(new_relationships)
|
||||
# Relationships are replaced here, so they are kept out of the default
|
||||
# `ModelSerializer.update()`, which would otherwise reset them all. The view
|
||||
# rejects updates on integrations shared with providers hidden to the role, so
|
||||
# every existing relationship is visible to the requester at this point
|
||||
providers = validated_data.pop("providers", None)
|
||||
if providers is not None:
|
||||
replace_integration_providers(instance, providers, tenant_id)
|
||||
|
||||
# Preserve regions field for Security Hub integrations
|
||||
if instance.integration_type == Integration.IntegrationChoices.AWS_SECURITY_HUB:
|
||||
@@ -3024,12 +3145,19 @@ class IntegrationUpdateSerializer(BaseWriteIntegrationSerializer):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def to_representation(self, instance):
|
||||
representation = super().to_representation(instance)
|
||||
# Ensure JIRA integrations show updated domain in configuration from credentials
|
||||
if instance.integration_type == Integration.IntegrationChoices.JIRA:
|
||||
representation["configuration"].update(
|
||||
{"domain": instance.credentials.get("domain")}
|
||||
)
|
||||
representation = self.hide_restricted_providers(
|
||||
super().to_representation(instance)
|
||||
)
|
||||
# Ensure JIRA integrations show updated domain in configuration from credentials.
|
||||
# `configuration` is missing when the request asks for a subset of the fields
|
||||
if (
|
||||
instance.integration_type == Integration.IntegrationChoices.JIRA
|
||||
and "configuration" in representation
|
||||
):
|
||||
representation["configuration"] = {
|
||||
**representation["configuration"],
|
||||
"domain": instance.credentials.get("domain"),
|
||||
}
|
||||
return representation
|
||||
|
||||
|
||||
|
||||
+186
-67
@@ -124,7 +124,12 @@ from api.models import (
|
||||
UserRoleRelationship,
|
||||
)
|
||||
from api.pagination import ComplianceOverviewPagination
|
||||
from api.rbac.permissions import Permissions, get_providers, get_role
|
||||
from api.rbac.permissions import (
|
||||
Permissions,
|
||||
get_integrations,
|
||||
get_providers,
|
||||
get_role,
|
||||
)
|
||||
from api.renderers import APIJSONRenderer, PlainTextRenderer
|
||||
from api.rls import Tenant
|
||||
from api.utils import (
|
||||
@@ -141,6 +146,7 @@ from api.v1.mixins import (
|
||||
JsonApiFilterMixin,
|
||||
PaginateByPkMixin,
|
||||
ProviderFilterParamsMixin,
|
||||
ProviderVisibilityMixin,
|
||||
TaskManagementMixin,
|
||||
)
|
||||
from api.v1.serializers import (
|
||||
@@ -281,6 +287,7 @@ from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.functional import cached_property
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django_celery_beat.models import PeriodicTask
|
||||
from drf_spectacular.settings import spectacular_settings
|
||||
@@ -807,6 +814,21 @@ class TenantFinishACSView(FinishACSView):
|
||||
User.objects.using(MainRouter.admin_db).filter(id=saml_user_id).delete()
|
||||
request.session.pop("saml_user_created", None)
|
||||
|
||||
@staticmethod
|
||||
def _user_has_tenant_role(user_id, tenant_id):
|
||||
return (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user_id=user_id, tenant_id=tenant_id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_read_only_fallback_role(role):
|
||||
return (
|
||||
not any(getattr(role, permission) for permission in Role.PERMISSION_FIELDS)
|
||||
and role.unlimited_visibility
|
||||
)
|
||||
|
||||
def dispatch(self, request, organization_slug):
|
||||
try:
|
||||
super().dispatch(request, organization_slug)
|
||||
@@ -872,11 +894,56 @@ class TenantFinishACSView(FinishACSView):
|
||||
user.name = "N/A"
|
||||
user.save()
|
||||
|
||||
# Only remap roles when the IdP provides a userType attribute.
|
||||
# Without it, the user's current roles are left untouched.
|
||||
# Only remap existing roles when the IdP provides a userType attribute.
|
||||
# Without it, preserve current roles or assign a read-only fallback.
|
||||
role_name = (
|
||||
extra.get("userType", [""])[0].strip() if extra.get("userType") else ""
|
||||
)
|
||||
if not role_name:
|
||||
with rls_transaction(str(tenant.id), using=MainRouter.admin_db):
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
# Serialize concurrent ACS callbacks for the same user.
|
||||
(
|
||||
User.objects.using(MainRouter.admin_db)
|
||||
.select_for_update()
|
||||
.only("id")
|
||||
.get(pk=user_id)
|
||||
)
|
||||
user_has_roles = self._user_has_tenant_role(user_id, tenant.id)
|
||||
if not user_has_roles:
|
||||
read_only_defaults = dict.fromkeys(
|
||||
Role.PERMISSION_FIELDS, False
|
||||
)
|
||||
read_only_defaults["unlimited_visibility"] = True
|
||||
role, role_created = Role.objects.using(
|
||||
MainRouter.admin_db
|
||||
).get_or_create(
|
||||
name="read_only",
|
||||
tenant=tenant,
|
||||
defaults=read_only_defaults,
|
||||
)
|
||||
role_is_read_only = self._is_read_only_fallback_role(role)
|
||||
if not role_created and not role_is_read_only:
|
||||
suffix = 0
|
||||
while not role_created and not role_is_read_only:
|
||||
role, role_created = Role.objects.using(
|
||||
MainRouter.admin_db
|
||||
).get_or_create(
|
||||
name=f"read_only_{suffix}",
|
||||
tenant=tenant,
|
||||
defaults=read_only_defaults,
|
||||
)
|
||||
role_is_read_only = self._is_read_only_fallback_role(
|
||||
role
|
||||
)
|
||||
suffix += 1
|
||||
UserRoleRelationship.objects.using(
|
||||
MainRouter.admin_db
|
||||
).get_or_create(
|
||||
user=user,
|
||||
role=role,
|
||||
defaults={"tenant": tenant},
|
||||
)
|
||||
if role_name:
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
role = (
|
||||
@@ -1626,7 +1693,7 @@ class TenantMembersViewSet(BaseTenantViewset):
|
||||
),
|
||||
update=extend_schema(exclude=True),
|
||||
)
|
||||
class ProviderGroupViewSet(BaseRLSViewSet):
|
||||
class ProviderGroupViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
|
||||
queryset = ProviderGroup.objects.all()
|
||||
serializer_class = ProviderGroupSerializer
|
||||
filterset_class = ProviderGroupFilter
|
||||
@@ -1647,14 +1714,13 @@ class ProviderGroupViewSet(BaseRLSViewSet):
|
||||
self.required_permissions = [Permissions.MANAGE_PROVIDERS]
|
||||
|
||||
def get_queryset(self):
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
# Check if any of the user's roles have UNLIMITED_VISIBILITY
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all provider groups
|
||||
return ProviderGroup.objects.prefetch_related("providers", "roles")
|
||||
|
||||
# Collect provider groups associated with the user's roles
|
||||
return user_roles.provider_groups.all().prefetch_related("providers", "roles")
|
||||
if self.user_role.unlimited_visibility:
|
||||
queryset = ProviderGroup.objects.filter(tenant_id=self.request.tenant_id)
|
||||
else:
|
||||
queryset = self.user_role.provider_groups.filter(
|
||||
tenant_id=self.request.tenant_id
|
||||
)
|
||||
return queryset.prefetch_related("providers", "roles")
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
@@ -1695,7 +1761,9 @@ class ProviderGroupViewSet(BaseRLSViewSet):
|
||||
},
|
||||
),
|
||||
)
|
||||
class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
|
||||
class ProviderGroupProvidersRelationshipView(
|
||||
ProviderVisibilityMixin, RelationshipView, BaseRLSViewSet
|
||||
):
|
||||
queryset = ProviderGroup.objects.all()
|
||||
serializer_class = ProviderGroupMembershipSerializer
|
||||
resource_name = "providers"
|
||||
@@ -1705,7 +1773,9 @@ class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
|
||||
required_permissions = [Permissions.MANAGE_PROVIDERS]
|
||||
|
||||
def get_queryset(self):
|
||||
return ProviderGroup.objects.filter(tenant_id=self.request.tenant_id)
|
||||
if self.user_role.unlimited_visibility:
|
||||
return ProviderGroup.objects.filter(tenant_id=self.request.tenant_id)
|
||||
return self.user_role.provider_groups.filter(tenant_id=self.request.tenant_id)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
provider_group = self.get_object()
|
||||
@@ -1727,6 +1797,7 @@ class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
|
||||
data={"providers": request.data},
|
||||
context={
|
||||
"provider_group": provider_group,
|
||||
"provider_queryset": self.get_provider_queryset(),
|
||||
"tenant_id": self.request.tenant_id,
|
||||
"request": request,
|
||||
},
|
||||
@@ -1741,7 +1812,11 @@ class ProviderGroupProvidersRelationshipView(RelationshipView, BaseRLSViewSet):
|
||||
serializer = self.get_serializer(
|
||||
instance=provider_group,
|
||||
data={"providers": request.data},
|
||||
context={"tenant_id": self.request.tenant_id, "request": request},
|
||||
context={
|
||||
"provider_queryset": self.get_provider_queryset(),
|
||||
"tenant_id": self.request.tenant_id,
|
||||
"request": request,
|
||||
},
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
@@ -1858,7 +1933,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
|
||||
)
|
||||
@action(detail=True, methods=["post"], url_name="connection")
|
||||
def connection(self, request, pk=None):
|
||||
get_object_or_404(Provider, pk=pk)
|
||||
self.get_object()
|
||||
with transaction.atomic():
|
||||
task = check_provider_connection_task.delay(
|
||||
provider_id=pk, tenant_id=self.request.tenant_id
|
||||
@@ -1876,7 +1951,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
|
||||
)
|
||||
|
||||
def destroy(self, request, *args, pk=None, **kwargs):
|
||||
provider = get_object_or_404(Provider, pk=pk)
|
||||
provider = self.get_object()
|
||||
provider.is_deleted = True
|
||||
provider.save()
|
||||
task_name = f"scan-perform-scheduled-{pk}"
|
||||
@@ -2098,7 +2173,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
|
||||
)
|
||||
@method_decorator(CACHE_DECORATOR, name="list")
|
||||
@method_decorator(CACHE_DECORATOR, name="retrieve")
|
||||
class ScanViewSet(BaseRLSViewSet):
|
||||
class ScanViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
|
||||
queryset = Scan.objects.all()
|
||||
serializer_class = ScanSerializer
|
||||
http_method_names = ["get", "post", "patch"]
|
||||
@@ -2127,13 +2202,7 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
self.required_permissions = [Permissions.MANAGE_SCANS]
|
||||
|
||||
def get_queryset(self):
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all scans
|
||||
queryset = Scan.objects.filter(tenant_id=self.request.tenant_id)
|
||||
else:
|
||||
# User lacks permission, filter providers based on provider groups associated with the role
|
||||
queryset = Scan.objects.filter(provider__in=get_providers(user_roles))
|
||||
queryset = Scan.objects.filter(provider__in=self.get_provider_queryset())
|
||||
return queryset.select_related("provider", "task")
|
||||
|
||||
def get_serializer_class(self):
|
||||
@@ -2731,6 +2800,7 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
provider = Provider.objects.select_for_update().get(
|
||||
id=provider.id,
|
||||
tenant_id=self.request.tenant_id,
|
||||
id__in=self.get_provider_queryset().values("id"),
|
||||
)
|
||||
active_scan = get_active_provider_scan(
|
||||
self.request.tenant_id, provider.id
|
||||
@@ -4305,7 +4375,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
)
|
||||
@method_decorator(CACHE_DECORATOR, name="list")
|
||||
@method_decorator(CACHE_DECORATOR, name="retrieve")
|
||||
class ProviderSecretViewSet(BaseRLSViewSet):
|
||||
class ProviderSecretViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
|
||||
queryset = ProviderSecret.objects.all()
|
||||
serializer_class = ProviderSecretSerializer
|
||||
filterset_class = ProviderSecretFilter
|
||||
@@ -4321,7 +4391,7 @@ class ProviderSecretViewSet(BaseRLSViewSet):
|
||||
required_permissions = [Permissions.MANAGE_PROVIDERS]
|
||||
|
||||
def get_queryset(self):
|
||||
return ProviderSecret.objects.filter(tenant_id=self.request.tenant_id)
|
||||
return ProviderSecret.objects.filter(provider__in=self.get_provider_queryset())
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
@@ -6602,7 +6672,7 @@ class OverviewViewSet(ProviderFilterParamsMixin, BaseRLSViewSet):
|
||||
responses={202: OpenApiResponse(response=TaskSerializer)},
|
||||
)
|
||||
)
|
||||
class ScheduleViewSet(BaseRLSViewSet):
|
||||
class ScheduleViewSet(ProviderVisibilityMixin, BaseRLSViewSet):
|
||||
# TODO: change to Schedule when implemented
|
||||
queryset = Task.objects.none()
|
||||
http_method_names = ["post"]
|
||||
@@ -6629,7 +6699,9 @@ class ScheduleViewSet(BaseRLSViewSet):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
provider_id = serializer.validated_data["provider_id"]
|
||||
|
||||
provider_instance = get_object_or_404(Provider, pk=provider_id)
|
||||
provider_instance = get_object_or_404(
|
||||
self.get_provider_queryset(), pk=provider_id
|
||||
)
|
||||
with transaction.atomic():
|
||||
task = schedule_provider_scan(provider_instance)
|
||||
|
||||
@@ -6652,27 +6724,34 @@ class ScheduleViewSet(BaseRLSViewSet):
|
||||
list=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="List all integrations",
|
||||
description="Retrieve a list of all configured integrations with options for filtering by various criteria.",
|
||||
description="Retrieve a list of all configured integrations with options for filtering by various criteria.\n\n"
|
||||
"Integrations attached to one or more providers are only returned when the role can access at least one of "
|
||||
"those providers, and each integration lists only the providers visible to the role. Integrations not "
|
||||
"attached to any provider, such as Jira, are tenant-wide and are returned for every role.",
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Retrieve integration details",
|
||||
description="Fetch detailed information about a specific integration by its ID.",
|
||||
description="Fetch detailed information about a specific integration by its ID. Integrations outside the "
|
||||
"provider visibility of the role are reported the same way as one that does not exist.",
|
||||
),
|
||||
create=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Create a new integration",
|
||||
description="Register a new integration with the system, providing necessary configuration details.",
|
||||
description="Register a new integration with the system, providing necessary configuration details. Only "
|
||||
"providers visible to the role can be attached to the integration.",
|
||||
),
|
||||
partial_update=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Partially update an integration",
|
||||
description="Modify certain fields of an existing integration without affecting other settings.",
|
||||
description="Modify certain fields of an existing integration without affecting other settings. Integrations "
|
||||
"attached to providers outside the visibility of the role cannot be modified by it.",
|
||||
),
|
||||
destroy=extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Delete an integration",
|
||||
description="Remove an integration from the system by its ID.",
|
||||
description="Remove an integration from the system by its ID. Integrations attached to providers outside "
|
||||
"the visibility of the role cannot be deleted by it.",
|
||||
),
|
||||
)
|
||||
@method_decorator(CACHE_DECORATOR, name="list")
|
||||
@@ -6685,18 +6764,27 @@ class IntegrationViewSet(BaseRLSViewSet):
|
||||
ordering = ["integration_type", "-inserted_at"]
|
||||
# RBAC required permissions
|
||||
required_permissions = [Permissions.MANAGE_INTEGRATIONS]
|
||||
allowed_providers = None
|
||||
|
||||
@cached_property
|
||||
def allowed_providers(self):
|
||||
"""
|
||||
Providers the role can access, or None when it has unlimited visibility.
|
||||
|
||||
Resolved per request and independently of the action, so that writes are scoped
|
||||
as tightly as reads.
|
||||
"""
|
||||
if self.user_role.unlimited_visibility:
|
||||
return None
|
||||
return get_providers(self.user_role)
|
||||
|
||||
def get_queryset(self):
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all integrations
|
||||
queryset = Integration.objects.filter(tenant_id=self.request.tenant_id)
|
||||
else:
|
||||
# User lacks permission, filter providers based on provider groups associated with the role
|
||||
allowed_providers = get_providers(user_roles)
|
||||
queryset = Integration.objects.filter(providers__in=allowed_providers)
|
||||
self.allowed_providers = allowed_providers
|
||||
queryset = get_integrations(self.user_role, providers=self.allowed_providers)
|
||||
if self.allowed_providers is not None and self.action in ("list", "retrieve"):
|
||||
# Restrict the relationship itself, so that the providers hidden to the role
|
||||
# are left out of the sideloaded resources of `?include=providers` too
|
||||
queryset = queryset.prefetch_related(
|
||||
Prefetch("providers", queryset=self.allowed_providers)
|
||||
)
|
||||
return queryset
|
||||
|
||||
def get_serializer_class(self):
|
||||
@@ -6711,16 +6799,33 @@ class IntegrationViewSet(BaseRLSViewSet):
|
||||
context["allowed_providers"] = self.allowed_providers
|
||||
return context
|
||||
|
||||
def get_object(self):
|
||||
instance = super().get_object()
|
||||
# Writes on an integration shared with providers hidden to the role would reach
|
||||
# beyond its visibility, so both editing and deleting are rejected consistently
|
||||
if (
|
||||
self.action in ("partial_update", "destroy")
|
||||
and self.allowed_providers is not None
|
||||
and instance.providers.exclude(
|
||||
id__in=self.allowed_providers.values("id")
|
||||
).exists()
|
||||
):
|
||||
raise PermissionDenied(
|
||||
"The integration is attached to providers outside the visibility of your role."
|
||||
)
|
||||
return instance
|
||||
|
||||
@extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Check integration connection",
|
||||
description="Try to verify integration connection",
|
||||
description="Try to verify integration connection. Integrations outside the provider visibility of the role "
|
||||
"are reported the same way as one that does not exist.",
|
||||
request=None,
|
||||
responses={202: OpenApiResponse(response=TaskSerializer)},
|
||||
)
|
||||
@action(detail=True, methods=["post"], url_name="connection")
|
||||
def connection(self, request, pk=None):
|
||||
get_object_or_404(Integration, pk=pk)
|
||||
get_object_or_404(self.get_queryset(), pk=pk)
|
||||
with transaction.atomic():
|
||||
task = check_integration_connection_task.delay(
|
||||
integration_id=pk, tenant_id=self.request.tenant_id
|
||||
@@ -6743,7 +6848,8 @@ class IntegrationViewSet(BaseRLSViewSet):
|
||||
tags=["Integration"],
|
||||
summary="Send findings to a Jira integration",
|
||||
description="Send a set of filtered findings to the given integration. At least one finding filter must be "
|
||||
"provided.\n\n"
|
||||
"provided. Jira integrations are tenant-wide and do not require unlimited visibility, while the findings "
|
||||
"sent are limited to the providers the role can access.\n\n"
|
||||
"## Known Limitations\n\n"
|
||||
"### Issue Types with Required Custom Fields\n\n"
|
||||
"Certain Jira issue types (such as Epic) may require mandatory custom fields that Prowler does not "
|
||||
@@ -6787,24 +6893,37 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
|
||||
return []
|
||||
return super().get_filter_backends()
|
||||
|
||||
def get_queryset(self):
|
||||
tenant_id = self.request.tenant_id
|
||||
user_roles = get_role(self.request.user, self.request.tenant_id)
|
||||
if user_roles.unlimited_visibility:
|
||||
# User has unlimited visibility, return all findings
|
||||
queryset = Finding.all_objects.filter(tenant_id=tenant_id)
|
||||
else:
|
||||
# User lacks permission, filter findings based on provider groups associated with the role
|
||||
queryset = Finding.all_objects.filter(
|
||||
scan__provider__in=get_providers(user_roles)
|
||||
)
|
||||
@cached_property
|
||||
def allowed_providers(self):
|
||||
"""
|
||||
Providers the role can access, or None when it has unlimited visibility.
|
||||
|
||||
return queryset
|
||||
Resolved once per request and shared between the findings queryset and the
|
||||
integration lookup.
|
||||
"""
|
||||
if self.user_role.unlimited_visibility:
|
||||
return None
|
||||
return get_providers(self.user_role)
|
||||
|
||||
def get_queryset(self):
|
||||
if self.allowed_providers is None:
|
||||
# User has unlimited visibility, return all findings
|
||||
return Finding.all_objects.filter(tenant_id=self.request.tenant_id)
|
||||
# Findings are limited to the providers the role can access
|
||||
return Finding.all_objects.filter(scan__provider__in=self.allowed_providers)
|
||||
|
||||
def get_integration(self, integration_pk):
|
||||
"""Retrieve the integration, honoring the provider visibility of the user's role."""
|
||||
return get_object_or_404(
|
||||
get_integrations(self.user_role, providers=self.allowed_providers),
|
||||
pk=integration_pk,
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
tags=["Integration"],
|
||||
summary="Get available issue types for a Jira project",
|
||||
description="Fetch the available issue types from Jira for a given project key and update the integration configuration.",
|
||||
description="Fetch the available issue types from Jira for a given project key and update the integration "
|
||||
"configuration. Jira integrations are tenant-wide and do not require unlimited visibility.",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="project_key",
|
||||
@@ -6817,7 +6936,7 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
|
||||
)
|
||||
@action(detail=False, methods=["get"], url_name="issue-types")
|
||||
def issue_types(self, request, integration_pk=None):
|
||||
integration = get_object_or_404(Integration, pk=integration_pk)
|
||||
integration = self.get_integration(integration_pk)
|
||||
|
||||
project_key = request.query_params.get("project_key")
|
||||
if not project_key:
|
||||
@@ -6862,23 +6981,23 @@ class IntegrationJiraViewSet(BaseRLSViewSet):
|
||||
|
||||
@action(detail=False, methods=["post"], url_name="dispatches")
|
||||
def dispatches(self, request, integration_pk=None):
|
||||
get_object_or_404(Integration, pk=integration_pk)
|
||||
self.get_integration(integration_pk)
|
||||
serializer = self.get_serializer(
|
||||
data=request.data, context={"integration_id": integration_pk}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
if self.filter_queryset(self.get_queryset()).count() == 0:
|
||||
raise ValidationError(
|
||||
{"findings": "No findings match the provided filters"}
|
||||
)
|
||||
|
||||
finding_ids = [
|
||||
str(finding_id)
|
||||
for finding_id in self.filter_queryset(self.get_queryset()).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
]
|
||||
if not finding_ids:
|
||||
raise ValidationError(
|
||||
{"findings": "No findings match the provided filters"}
|
||||
)
|
||||
|
||||
project_key = serializer.validated_data["project_key"]
|
||||
issue_type = serializer.validated_data["issue_type"]
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ celery_app.conf.task_annotations = {
|
||||
for name in (
|
||||
"scan-perform",
|
||||
"scan-perform-scheduled",
|
||||
"attack-paths-scan-perform",
|
||||
"provider-deletion",
|
||||
"tenant-deletion",
|
||||
)
|
||||
|
||||
@@ -312,8 +312,8 @@ 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
|
||||
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 960
|
||||
) # 16h
|
||||
|
||||
# Selects where the persistent attack-paths graph is stored. The scan
|
||||
# temporary database is always Neo4j; only the sink is configurable.
|
||||
|
||||
@@ -91,6 +91,13 @@ def before_send(event, hint):
|
||||
log_msg = log_record.getMessage()
|
||||
log_lvl = log_record.levelno
|
||||
|
||||
if (
|
||||
getattr(log_record, "name", "") == "cartography.graph.job"
|
||||
and "Neo.ClientError.Database.DatabaseNotFound" in log_msg
|
||||
and "db-tmp-scan-" in log_msg
|
||||
):
|
||||
return None
|
||||
|
||||
# The Neo4j driver logs transient connection errors (defunct
|
||||
# connections, resets) at ERROR level via the `neo4j.io` logger.
|
||||
# `RetryableSession` handles these with retries. If all retries
|
||||
|
||||
@@ -13,16 +13,17 @@ GITHUB_OAUTH_CALLBACK_URL = env("SOCIAL_GITHUB_OAUTH_CALLBACK_URL", default="")
|
||||
ACCOUNT_LOGIN_METHODS = {"email"} # Use Email / Password authentication
|
||||
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
|
||||
ACCOUNT_EMAIL_VERIFICATION = "none" # Do not require email confirmation
|
||||
ACCOUNT_EMAIL_NOTIFICATIONS = False
|
||||
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
|
||||
REST_AUTH = {
|
||||
"TOKEN_MODEL": None,
|
||||
"REST_USE_JWT": True,
|
||||
}
|
||||
# django-allauth (social)
|
||||
# Authenticate if local account with this email address already exists
|
||||
SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
|
||||
# Connect local account and social account if local account with that email address already exists
|
||||
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
|
||||
# Email-based account matching is handled by ProwlerSocialAccountAdapter, which
|
||||
# verifies both the provider email and the existing account email before linking.
|
||||
SOCIALACCOUNT_EMAIL_AUTHENTICATION = False
|
||||
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = False
|
||||
SOCIALACCOUNT_ADAPTER = "api.adapters.ProwlerSocialAccountAdapter"
|
||||
|
||||
|
||||
|
||||
+43
-12
@@ -70,6 +70,7 @@ 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_ADMIN_ALIAS = "admin"
|
||||
TEST_REPLICA_ALIAS = "test_replica"
|
||||
|
||||
|
||||
@@ -1449,6 +1450,26 @@ def integrations_fixture(aws_provider_pair):
|
||||
return integration1, integration2
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jira_integration_fixture(tenants_fixture):
|
||||
# Jira is a tenant-wide integration: it is not attached to any provider, and its
|
||||
# `domain` is read from the credentials when the integration is serialized
|
||||
tenant_id = tenants_fixture[0].id
|
||||
with rls_transaction(str(tenant_id)):
|
||||
return Integration.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
enabled=True,
|
||||
connected=True,
|
||||
integration_type=Integration.IntegrationChoices.JIRA,
|
||||
configuration={"projects": {"TEST": "Test project"}},
|
||||
credentials={
|
||||
"domain": "test",
|
||||
"user_mail": "a@b.com",
|
||||
"api_token": "token",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def backfill_scan_metadata_fixture(scans_fixture, findings_fixture):
|
||||
for scan_instance in scans_fixture:
|
||||
@@ -2539,26 +2560,36 @@ def finding_groups_title_variants_fixture(
|
||||
return findings
|
||||
|
||||
|
||||
def _ensure_mirrored_test_alias(alias: str) -> None:
|
||||
default_database = settings.DATABASES["default"]
|
||||
if alias not in settings.DATABASES:
|
||||
settings.DATABASES[alias] = {
|
||||
**default_database,
|
||||
"TEST": {
|
||||
**default_database.get("TEST", {}),
|
||||
"MIRROR": "default",
|
||||
},
|
||||
}
|
||||
django_connections.databases[alias] = settings.DATABASES[alias]
|
||||
|
||||
|
||||
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_admin_alias") for item in items):
|
||||
_ensure_mirrored_test_alias(TEST_ADMIN_ALIAS)
|
||||
|
||||
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
|
||||
]
|
||||
_ensure_mirrored_test_alias(TEST_REPLICA_ALIAS)
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"requires_test_admin_alias: creates a test-only admin alias mirrored "
|
||||
"to default",
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"requires_test_replica_alias: creates a test-only replica alias mirrored "
|
||||
|
||||
@@ -8,6 +8,8 @@ import aioboto3
|
||||
import boto3
|
||||
import botocore
|
||||
import neo4j
|
||||
import neo4j.exceptions
|
||||
from api.attack_paths.database import DATABASE_NOT_FOUND_CODE
|
||||
from api.models import (
|
||||
AttackPathsScan as ProwlerAPIAttackPathsScan,
|
||||
)
|
||||
@@ -347,6 +349,12 @@ def sync_aws_account(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if (
|
||||
isinstance(e, neo4j.exceptions.Neo4jError)
|
||||
and e.code == DATABASE_NOT_FOUND_CODE
|
||||
):
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
f"Synced function {func_name} for AWS account {prowler_api_provider.uid} in {time.perf_counter() - func_t0:.3f}s (FAILED)"
|
||||
)
|
||||
|
||||
@@ -10,13 +10,10 @@ NormalizedList = _provider_config.NormalizedList
|
||||
PROVIDER_CONFIGS = _provider_config.PROVIDER_CONFIGS
|
||||
ProviderConfig = _provider_config.ProviderConfig
|
||||
|
||||
# Batch size for Neo4j write operations (resource labeling, cleanup)
|
||||
BATCH_SIZE = env.int("ATTACK_PATHS_BATCH_SIZE", 1000)
|
||||
# Batch size for graph mutation operations (resource labeling and subgraph deletion)
|
||||
GRAPH_MUTATION_BATCH_SIZE = env.int("ATTACK_PATHS_GRAPH_MUTATION_BATCH_SIZE", 1000)
|
||||
# Batch size for Postgres findings fetch (keyset pagination page size)
|
||||
FINDINGS_BATCH_SIZE = env.int("ATTACK_PATHS_FINDINGS_BATCH_SIZE", 1000)
|
||||
# Batch size for temp-to-tenant graph sync (nodes and relationships per cursor page)
|
||||
SYNC_BATCH_SIZE = env.int("ATTACK_PATHS_SYNC_BATCH_SIZE", 1000)
|
||||
|
||||
# Neo4j internal labels (Prowler-specific, not provider-specific)
|
||||
# - `Internet`: Singleton node representing external internet access for exposed-resource queries
|
||||
# - `ProwlerFinding`: Label for finding nodes created by Prowler and linked to cloud resources
|
||||
|
||||
@@ -21,8 +21,8 @@ from cartography.config import Config as CartographyConfig
|
||||
from celery.utils.log import get_task_logger
|
||||
from prowler.config import config as ProwlerConfig
|
||||
from tasks.jobs.attack_paths.config import (
|
||||
BATCH_SIZE,
|
||||
FINDINGS_BATCH_SIZE,
|
||||
GRAPH_MUTATION_BATCH_SIZE,
|
||||
get_node_uid_field,
|
||||
get_provider_resource_label,
|
||||
get_root_node_label,
|
||||
@@ -135,7 +135,7 @@ def add_resource_label(
|
||||
while labeled_count > 0:
|
||||
result = neo4j_session.run(
|
||||
query,
|
||||
{"provider_uid": provider_uid, "batch_size": BATCH_SIZE},
|
||||
{"provider_uid": provider_uid, "batch_size": GRAPH_MUTATION_BATCH_SIZE},
|
||||
)
|
||||
labeled_count = result.single().get("labeled_count", 0)
|
||||
total_labeled += labeled_count
|
||||
|
||||
@@ -372,7 +372,19 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
|
||||
except Exception as e:
|
||||
exception_message = utils.stringify_exception(e, "Attack Paths scan failed")
|
||||
logger.exception(exception_message)
|
||||
temporary_database_missing = (
|
||||
isinstance(e, graph_database.GraphDatabaseQueryException)
|
||||
and e.code == graph_database.DATABASE_NOT_FOUND_CODE
|
||||
and tmp_database_name in str(e)
|
||||
)
|
||||
if temporary_database_missing:
|
||||
logger.warning(exception_message)
|
||||
else:
|
||||
logger.exception(exception_message)
|
||||
cleanup_log_level = (
|
||||
logging.WARNING if temporary_database_missing else logging.ERROR
|
||||
)
|
||||
cleanup_exc_info = not temporary_database_missing
|
||||
ingestion_exceptions["global_error"] = exception_message
|
||||
|
||||
# Recover `graph_data_ready` based on how far the swap got
|
||||
@@ -387,19 +399,24 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.error(
|
||||
f"Failed to recover `graph_data_ready` for provider {attack_paths_scan.provider_id}",
|
||||
exc_info=True,
|
||||
logger.log(
|
||||
cleanup_log_level,
|
||||
"Failed to recover `graph_data_ready` for provider "
|
||||
f"{attack_paths_scan.provider_id}",
|
||||
exc_info=cleanup_exc_info,
|
||||
)
|
||||
|
||||
# Dropping the temporary database if it still exists
|
||||
try:
|
||||
graph_database.drop_database(tmp_cartography_config.neo4j_database)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to drop temporary Neo4j database `{tmp_cartography_config.neo4j_database}` during cleanup: {e}",
|
||||
exc_info=True,
|
||||
except Exception as cleanup_error:
|
||||
logger.log(
|
||||
cleanup_log_level,
|
||||
"Failed to drop temporary Neo4j database "
|
||||
f"`{tmp_cartography_config.neo4j_database}` during cleanup: "
|
||||
f"{cleanup_error}",
|
||||
exc_info=cleanup_exc_info,
|
||||
)
|
||||
|
||||
# Set Attack Paths scan state to FAILED
|
||||
@@ -407,10 +424,12 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
db_utils.finish_attack_paths_scan(
|
||||
attack_paths_scan, StateChoices.FAILED, ingestion_exceptions
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` (row may have been deleted): {e}",
|
||||
exc_info=True,
|
||||
except Exception as cleanup_error:
|
||||
logger.log(
|
||||
cleanup_log_level,
|
||||
f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` "
|
||||
f"(row may have been deleted): {cleanup_error}",
|
||||
exc_info=cleanup_exc_info,
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
@@ -30,7 +30,6 @@ from tasks.jobs.attack_paths.config import (
|
||||
PROVIDER_CONFIGS,
|
||||
PROVIDER_ISOLATION_PROPERTIES,
|
||||
PROVIDER_RESOURCE_LABEL,
|
||||
SYNC_BATCH_SIZE,
|
||||
NormalizedList,
|
||||
get_provider_label,
|
||||
get_tenant_label,
|
||||
@@ -116,6 +115,7 @@ def sync_nodes(
|
||||
Source and target sessions are opened sequentially per batch to avoid
|
||||
holding two Bolt connections simultaneously for the entire sync duration.
|
||||
"""
|
||||
batch_size = sink.sync_batch_size
|
||||
t0 = time.perf_counter()
|
||||
last_id = -1
|
||||
parents_synced = 0
|
||||
@@ -137,7 +137,7 @@ def sync_nodes(
|
||||
with graph_database.get_session(source_database) as source_session:
|
||||
result = source_session.run(
|
||||
NODE_FETCH_QUERY,
|
||||
{"last_id": last_id, "batch_size": SYNC_BATCH_SIZE},
|
||||
{"last_id": last_id, "batch_size": batch_size},
|
||||
)
|
||||
for record in result:
|
||||
batch_count += 1
|
||||
@@ -156,17 +156,17 @@ def sync_nodes(
|
||||
|
||||
for labels, batch in parent_groups.items():
|
||||
rendered_labels = _render_labels(labels, extra_labels)
|
||||
for sink_batch in _iter_sink_batches(batch):
|
||||
for sink_batch in _iter_sink_batches(batch, batch_size):
|
||||
sink.write_nodes(target_database, rendered_labels, sink_batch)
|
||||
|
||||
for child_label, batch in child_groups.items():
|
||||
rendered_labels = _render_labels((child_label,), extra_labels)
|
||||
for sink_batch in _iter_sink_batches(batch):
|
||||
for sink_batch in _iter_sink_batches(batch, batch_size):
|
||||
sink.write_nodes(target_database, rendered_labels, sink_batch)
|
||||
children_synced += len(batch)
|
||||
|
||||
for rel_type, batch in rel_groups.items():
|
||||
for sink_batch in _iter_sink_batches(batch):
|
||||
for sink_batch in _iter_sink_batches(batch, batch_size):
|
||||
sink.write_relationships(
|
||||
target_database, rel_type, provider_id, sink_batch
|
||||
)
|
||||
@@ -205,6 +205,7 @@ def sync_relationships(
|
||||
Source and target sessions are opened sequentially per batch to avoid
|
||||
holding two Bolt connections simultaneously for the entire sync duration.
|
||||
"""
|
||||
batch_size = sink.sync_batch_size
|
||||
t0 = time.perf_counter()
|
||||
last_id = -1
|
||||
total_synced = 0
|
||||
@@ -217,7 +218,7 @@ def sync_relationships(
|
||||
with graph_database.get_session(source_database) as source_session:
|
||||
result = source_session.run(
|
||||
RELATIONSHIPS_FETCH_QUERY,
|
||||
{"last_id": last_id, "batch_size": SYNC_BATCH_SIZE},
|
||||
{"last_id": last_id, "batch_size": batch_size},
|
||||
)
|
||||
for record in result:
|
||||
batch_count += 1
|
||||
@@ -229,7 +230,7 @@ def sync_relationships(
|
||||
break
|
||||
|
||||
for rel_type, batch in grouped.items():
|
||||
for sink_batch in _iter_sink_batches(batch):
|
||||
for sink_batch in _iter_sink_batches(batch, batch_size):
|
||||
sink.write_relationships(
|
||||
target_database, rel_type, provider_id, sink_batch
|
||||
)
|
||||
@@ -247,10 +248,9 @@ def sync_relationships(
|
||||
|
||||
def _iter_sink_batches(
|
||||
rows: list[dict[str, Any]],
|
||||
batch_size: int | None = None,
|
||||
batch_size: int,
|
||||
) -> Iterator[list[dict[str, Any]]]:
|
||||
"""Yield final sink write batches after source rows have been transformed."""
|
||||
batch_size = SYNC_BATCH_SIZE if batch_size is None else batch_size
|
||||
if batch_size <= 0:
|
||||
raise ValueError("Sink batch size must be greater than zero")
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from glob import glob
|
||||
|
||||
from api.db_router import READ_REPLICA_ALIAS, MainRouter
|
||||
@@ -214,8 +215,10 @@ def get_security_hub_client_from_integration(
|
||||
for region in set(all_security_hub_regions):
|
||||
regions_status[region] = region in connection.enabled_regions
|
||||
|
||||
# Save regions information in the integration configuration
|
||||
# Persist the successful connection check and regions information
|
||||
with rls_transaction(tenant_id, using=MainRouter.default_db):
|
||||
integration.connected = True
|
||||
integration.connection_last_checked_at = datetime.now(tz=UTC)
|
||||
integration.configuration["regions"] = regions_status
|
||||
integration.save()
|
||||
|
||||
|
||||
@@ -18,12 +18,11 @@ This is the shared engine behind both the periodic Beat watchdog and the
|
||||
`reconcile_orphan_tasks` management command.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from api.celery_utils import decode_celery_field
|
||||
from celery import current_app, states
|
||||
from celery.utils.log import get_task_logger
|
||||
from django.db import connections
|
||||
@@ -138,34 +137,6 @@ def revoke_task(task_result, terminate: bool = True) -> None:
|
||||
logger.exception(f"Failed to revoke task {task_result.task_id}")
|
||||
|
||||
|
||||
def _decode_celery_field(value, default):
|
||||
"""Decode django-celery-results' stored task_args/task_kwargs to a Python object.
|
||||
|
||||
The backend stores them as a (sometimes double-encoded) repr/JSON string. An
|
||||
empty or missing field returns ``default``; a non-empty value that cannot be
|
||||
decoded raises ``ValueError`` so the caller can avoid re-enqueuing a task with
|
||||
the wrong arguments.
|
||||
"""
|
||||
obj = value
|
||||
for _ in range(2): # values can be double-encoded (a string holding a repr)
|
||||
if not isinstance(obj, str):
|
||||
break
|
||||
text = obj.strip()
|
||||
if not text:
|
||||
return default
|
||||
parsed = None
|
||||
for parser in (ast.literal_eval, json.loads):
|
||||
try:
|
||||
parsed = parser(text)
|
||||
break
|
||||
except (ValueError, SyntaxError, TypeError):
|
||||
continue
|
||||
if parsed is None:
|
||||
raise ValueError(f"undecodable celery field: {text[:120]!r}")
|
||||
obj = parsed
|
||||
return default if obj is None else obj
|
||||
|
||||
|
||||
def reconcile_orphans(
|
||||
grace_minutes: int = 2,
|
||||
max_attempts: int = 3,
|
||||
@@ -313,8 +284,10 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
|
||||
return "failed"
|
||||
|
||||
try:
|
||||
args = _decode_celery_field(args_repr, [])
|
||||
kwargs = _decode_celery_field(kwargs_repr, {})
|
||||
args = decode_celery_field(args_repr, [])
|
||||
kwargs = decode_celery_field(kwargs_repr, {})
|
||||
if not isinstance(args, (list, tuple)) or not isinstance(kwargs, dict):
|
||||
raise ValueError("Stored task arguments have invalid types")
|
||||
except ValueError:
|
||||
logger.error(
|
||||
"Orphan %s (%s): could not decode stored args/kwargs, not re-enqueuing",
|
||||
@@ -324,8 +297,8 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
|
||||
return "failed"
|
||||
new_task_id = str(uuid4())
|
||||
task_obj.apply_async(
|
||||
args=list(args) if isinstance(args, (list, tuple)) else [],
|
||||
kwargs=kwargs if isinstance(kwargs, dict) else {},
|
||||
args=list(args),
|
||||
kwargs=kwargs,
|
||||
task_id=new_task_id,
|
||||
)
|
||||
logger.info(
|
||||
|
||||
+327
-103
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
@@ -6,7 +7,7 @@ import re
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -49,6 +50,7 @@ from celery.utils.log import get_task_logger
|
||||
from config.django.base import DJANGO_FINDINGS_BATCH_SIZE
|
||||
from config.env import env
|
||||
from config.settings.celery import CELERY_DEADLOCK_ATTEMPTS
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import DatabaseError, IntegrityError, OperationalError, transaction
|
||||
from django.db.models import (
|
||||
Case,
|
||||
@@ -99,6 +101,16 @@ COMPLIANCE_REQUIREMENT_COPY_COLUMNS = (
|
||||
FINDINGS_MICRO_BATCH_SIZE = env.int("DJANGO_FINDINGS_MICRO_BATCH_SIZE", default=3000)
|
||||
# Controls how many rows each ORM bulk_create/bulk_update call sends to Postgres.
|
||||
SCAN_DB_BATCH_SIZE = env.int("DJANGO_SCAN_DB_BATCH_SIZE", default=1000)
|
||||
# Rows per COPY statement when ingesting compliance requirement overviews. All
|
||||
# batches of a scan share one transaction/commit; the batch size only bounds the
|
||||
# client-side CSV buffer and how long each individual COPY statement runs on the
|
||||
# writer (memory footprint, lock time and slow-statement logging under load).
|
||||
COMPLIANCE_COPY_BATCH_SIZE = env.int("DJANGO_COMPLIANCE_COPY_BATCH_SIZE", default=2000)
|
||||
if COMPLIANCE_COPY_BATCH_SIZE < 1:
|
||||
raise ImproperlyConfigured(
|
||||
"DJANGO_COMPLIANCE_COPY_BATCH_SIZE must be a positive integer, got "
|
||||
f"{COMPLIANCE_COPY_BATCH_SIZE}"
|
||||
)
|
||||
# Throttle scan progress persistence: minimum progress delta (fraction 0-1)
|
||||
# between two persisted progress updates.
|
||||
PROGRESS_THROTTLE_DELTA = env.float("DJANGO_SCAN_PROGRESS_THROTTLE_DELTA", default=0.01)
|
||||
@@ -356,30 +368,36 @@ def _bulk_update_resource_failed_findings_counts(
|
||||
raise
|
||||
|
||||
|
||||
def _copy_compliance_requirement_rows(
|
||||
tenant_id: str, rows: list[dict[str, Any]]
|
||||
) -> None:
|
||||
"""Stream compliance requirement rows into Postgres using COPY.
|
||||
class ComplianceRowScopeError(ValueError):
|
||||
"""A compliance requirement row does not belong to the scan being ingested."""
|
||||
|
||||
We leverage the admin connection (when available) to bypass the COPY + RLS
|
||||
restriction, writing only the fields required by
|
||||
``ComplianceRequirementOverview``.
|
||||
|
||||
Args:
|
||||
tenant_id: Target tenant UUID.
|
||||
rows: List of row dictionaries prepared by
|
||||
:func:`create_compliance_requirements`.
|
||||
def _compliance_requirement_rows_to_csv(
|
||||
rows: list[dict[str, Any]], tenant_id: str, scan_id: str
|
||||
) -> io.StringIO:
|
||||
"""Serialize compliance requirement rows into a CSV buffer for COPY.
|
||||
|
||||
COPY runs on the admin connection, which bypasses RLS, so every row is
|
||||
checked against the expected tenant/scan before it is written: a mismatched
|
||||
row would otherwise be inserted verbatim into another tenant's data.
|
||||
"""
|
||||
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer)
|
||||
|
||||
datetime_now = datetime.now(tz=UTC)
|
||||
for row in rows:
|
||||
row_tenant_id = str(row.get("tenant_id"))
|
||||
row_scan_id = str(row.get("scan_id"))
|
||||
if row_tenant_id != tenant_id or row_scan_id != scan_id:
|
||||
raise ComplianceRowScopeError(
|
||||
"Compliance requirement row does not belong to the scan being "
|
||||
f"ingested (expected tenant {tenant_id} / scan {scan_id}, got "
|
||||
f"tenant {row_tenant_id} / scan {row_scan_id})"
|
||||
)
|
||||
writer.writerow(
|
||||
[
|
||||
str(row.get("id")),
|
||||
str(row.get("tenant_id")),
|
||||
row_tenant_id,
|
||||
(row.get("inserted_at") or datetime_now).isoformat(),
|
||||
row.get("compliance_id") or "",
|
||||
row.get("framework") or "",
|
||||
@@ -393,65 +411,100 @@ def _copy_compliance_requirement_rows(
|
||||
row.get("total_checks", 0),
|
||||
row.get("passed_findings", 0),
|
||||
row.get("total_findings", 0),
|
||||
str(row.get("scan_id")),
|
||||
row_scan_id,
|
||||
]
|
||||
)
|
||||
|
||||
csv_buffer.seek(0)
|
||||
return csv_buffer
|
||||
|
||||
|
||||
def _copy_compliance_requirement_rows(
|
||||
tenant_id: str, scan_id: str, rows: Iterable[dict[str, Any]], batch_size: int
|
||||
) -> int:
|
||||
"""Replace a scan's compliance requirement rows using batched COPY.
|
||||
|
||||
We leverage the admin connection (when available) to bypass the COPY + RLS
|
||||
restriction. The scan's DELETE and every COPY batch run on one connection
|
||||
inside a single transaction with a single commit, so the writer takes one
|
||||
fsync per scan instead of one per batch, and a failed ingest rolls back
|
||||
without committing a partial delete/insert (which a retry would otherwise
|
||||
delete again, feeding dead rows to autovacuum).
|
||||
|
||||
Args:
|
||||
tenant_id: Target tenant UUID.
|
||||
scan_id: Scan whose previous rows are replaced.
|
||||
rows: Iterable of row dictionaries, consumed lazily batch by batch.
|
||||
batch_size: Number of rows per COPY statement.
|
||||
|
||||
Returns:
|
||||
int: total number of rows staged and committed.
|
||||
|
||||
Raises:
|
||||
ComplianceRowScopeError: A row belongs to another tenant or scan.
|
||||
"""
|
||||
# Normalized once so the per-row scope check compares like with like even if
|
||||
# the caller passes UUID instances instead of strings.
|
||||
tenant_id = str(tenant_id)
|
||||
scan_id = str(scan_id)
|
||||
total_rows = 0
|
||||
batch_num = 0
|
||||
copy_sql = (
|
||||
"COPY compliance_requirements_overviews ("
|
||||
+ ", ".join(COMPLIANCE_REQUIREMENT_COPY_COLUMNS)
|
||||
+ ") FROM STDIN WITH (FORMAT CSV, DELIMITER ',', QUOTE '\"', ESCAPE '\"', NULL '\\N')"
|
||||
)
|
||||
|
||||
try:
|
||||
with psycopg_connection(MainRouter.admin_db) as connection:
|
||||
connection.autocommit = False
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id])
|
||||
cursor.copy_expert(copy_sql, csv_buffer)
|
||||
connection.commit()
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
csv_buffer.close()
|
||||
with psycopg_connection(MainRouter.admin_db) as connection:
|
||||
connection.autocommit = False
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(SET_CONFIG_QUERY, [POSTGRES_TENANT_VAR, tenant_id])
|
||||
# Idempotent re-run: clearing this scan's rows inside the same
|
||||
# transaction keeps delete + reinsert atomic.
|
||||
cursor.execute(
|
||||
"DELETE FROM compliance_requirements_overviews "
|
||||
"WHERE tenant_id = %s AND scan_id = %s",
|
||||
[tenant_id, scan_id],
|
||||
)
|
||||
for batch, _is_last in batched(rows, batch_size):
|
||||
if not batch:
|
||||
continue
|
||||
batch_num += 1
|
||||
csv_buffer = _compliance_requirement_rows_to_csv(
|
||||
batch, tenant_id, scan_id
|
||||
)
|
||||
try:
|
||||
cursor.copy_expert(copy_sql, csv_buffer)
|
||||
finally:
|
||||
csv_buffer.close()
|
||||
total_rows += len(batch)
|
||||
logger.info(
|
||||
f"Compliance COPY batch {batch_num}: staged {len(batch)} rows "
|
||||
f"({total_rows} total)"
|
||||
)
|
||||
connection.commit()
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
return total_rows
|
||||
|
||||
|
||||
def _persist_compliance_requirement_rows(
|
||||
tenant_id: str, rows: Iterable[dict[str, Any]], batch_size: int = 10000
|
||||
def _bulk_create_compliance_requirement_rows(
|
||||
tenant_id: str, scan_id: str, rows: Iterable[dict[str, Any]], batch_size: int
|
||||
) -> int:
|
||||
"""Persist compliance requirement rows using batched COPY with ORM fallback.
|
||||
"""Replace a scan's compliance requirement rows via the ORM.
|
||||
|
||||
``rows`` is consumed lazily in batches, so peak memory stays at ~``batch_size``
|
||||
rows instead of the full set. A batch that fails COPY falls back to an ORM
|
||||
``bulk_create`` of just that batch.
|
||||
|
||||
Args:
|
||||
tenant_id: Target tenant UUID.
|
||||
rows: Iterable of row dictionaries reflecting the compliance overview
|
||||
state for a scan.
|
||||
batch_size: Number of rows per COPY batch (default: 10000).
|
||||
|
||||
Returns:
|
||||
int: total number of rows persisted.
|
||||
Fallback for when COPY is unavailable; the delete and every ``bulk_create``
|
||||
share one RLS transaction so the replacement stays atomic.
|
||||
"""
|
||||
total_rows = 0
|
||||
batch_num = 0
|
||||
|
||||
for batch, _is_last in batched(rows, batch_size):
|
||||
if not batch:
|
||||
continue
|
||||
batch_num += 1
|
||||
try:
|
||||
_copy_compliance_requirement_rows(tenant_id, batch)
|
||||
except Exception as error:
|
||||
logger.exception(
|
||||
f"COPY bulk insert for compliance requirements batch {batch_num} "
|
||||
"failed; falling back to ORM bulk_create for this batch",
|
||||
exc_info=error,
|
||||
)
|
||||
with rls_transaction(tenant_id):
|
||||
ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete()
|
||||
for batch, _is_last in batched(rows, batch_size):
|
||||
if not batch:
|
||||
continue
|
||||
fallback_objects = [
|
||||
ComplianceRequirementOverview(
|
||||
id=row["id"],
|
||||
@@ -473,20 +526,58 @@ def _persist_compliance_requirement_rows(
|
||||
)
|
||||
for row in batch
|
||||
]
|
||||
with rls_transaction(tenant_id):
|
||||
ComplianceRequirementOverview.objects.bulk_create(
|
||||
fallback_objects, batch_size=500
|
||||
)
|
||||
|
||||
total_rows += len(batch)
|
||||
logger.info(
|
||||
f"Compliance COPY batch {batch_num}: inserted {len(batch)} rows "
|
||||
f"({total_rows} total)"
|
||||
)
|
||||
|
||||
ComplianceRequirementOverview.objects.bulk_create(
|
||||
fallback_objects, batch_size=500
|
||||
)
|
||||
total_rows += len(batch)
|
||||
return total_rows
|
||||
|
||||
|
||||
def _persist_compliance_requirement_rows(
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
rows_factory: Callable[[], Iterable[dict[str, Any]]],
|
||||
batch_size: int | None = None,
|
||||
) -> int:
|
||||
"""Persist a scan's compliance requirement rows, replacing any previous ones.
|
||||
|
||||
``rows_factory`` must return a fresh row iterator on every call: the COPY
|
||||
path consumes it lazily in batches (peak memory ~``batch_size`` rows), and
|
||||
if COPY fails the whole ingest falls back to a single ORM transaction that
|
||||
re-iterates the rows.
|
||||
|
||||
Args:
|
||||
tenant_id: Target tenant UUID.
|
||||
scan_id: Scan whose compliance overview rows are being replaced.
|
||||
rows_factory: Callable returning an iterable of row dictionaries.
|
||||
batch_size: Rows per COPY/bulk_create batch (default:
|
||||
``COMPLIANCE_COPY_BATCH_SIZE``).
|
||||
|
||||
Returns:
|
||||
int: total number of rows persisted.
|
||||
"""
|
||||
if batch_size is None:
|
||||
batch_size = COMPLIANCE_COPY_BATCH_SIZE
|
||||
|
||||
try:
|
||||
return _copy_compliance_requirement_rows(
|
||||
tenant_id, scan_id, rows_factory(), batch_size
|
||||
)
|
||||
except ComplianceRowScopeError:
|
||||
# Cross-tenant/scan rows are a bug in the caller, not a COPY failure:
|
||||
# retrying through the ORM would persist the very rows we rejected.
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.exception(
|
||||
"COPY bulk insert for compliance requirements failed; "
|
||||
"falling back to ORM bulk_create",
|
||||
exc_info=error,
|
||||
)
|
||||
return _bulk_create_compliance_requirement_rows(
|
||||
tenant_id, scan_id, rows_factory(), batch_size
|
||||
)
|
||||
|
||||
|
||||
def _create_compliance_summaries(
|
||||
tenant_id: str, scan_id: str, requirement_statuses: dict
|
||||
) -> None:
|
||||
@@ -605,6 +696,45 @@ def _process_finding_micro_batch(
|
||||
scan_resource_groups_cache: Dict tracking resource group counts {(resource_group, severity): {"total", "failed", "new_failed"}}.
|
||||
group_resources_cache: Dict tracking unique resources per group {resource_group: set(resource_uids)}.
|
||||
"""
|
||||
|
||||
def build_resource_defaults_from_finding(finding: ProwlerFinding) -> dict[str, Any]:
|
||||
check_metadata = finding.get_metadata()
|
||||
group = check_metadata.get("resourcegroup") or None
|
||||
return {
|
||||
"tenant_id": tenant_id,
|
||||
"provider": provider_instance,
|
||||
"uid": finding.resource_uid,
|
||||
"region": finding.region,
|
||||
"service": finding.service_name,
|
||||
"type": finding.resource_type,
|
||||
"name": finding.resource_name,
|
||||
"groups": [group] if group else None,
|
||||
}
|
||||
|
||||
def recover_resource_after_cache_miss(finding: ProwlerFinding) -> Resource:
|
||||
resource_uid = finding.resource_uid
|
||||
resource_instance = Resource.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_instance.id,
|
||||
uid=resource_uid,
|
||||
).first()
|
||||
if resource_instance is None:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
resource_instance = Resource.objects.create(
|
||||
**build_resource_defaults_from_finding(finding)
|
||||
)
|
||||
except IntegrityError:
|
||||
resource_instance = Resource.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_instance.id,
|
||||
uid=resource_uid,
|
||||
).first()
|
||||
if resource_instance is None:
|
||||
raise
|
||||
|
||||
return cache_resource(resource_uid, resource_instance)
|
||||
|
||||
# Accumulate objects for bulk operations
|
||||
findings_to_create = []
|
||||
dirty_resources = {}
|
||||
@@ -643,7 +773,103 @@ def _process_finding_micro_batch(
|
||||
|
||||
# All DB writes for this micro-batch run inside ONE rls_transaction,
|
||||
# with deadlock-retry at micro-batch granularity instead of per-finding.
|
||||
missing_cache_value = object()
|
||||
for attempt in range(CELERY_DEADLOCK_ATTEMPTS):
|
||||
resource_cache_originals: dict[str, Resource | object] = {}
|
||||
failed_count_originals: dict[str, int | None] = {}
|
||||
resource_field_originals: dict[str, dict[str, Any]] = {}
|
||||
tag_cache_original = dict(tag_cache)
|
||||
scan_resource_cache_original = set(scan_resource_cache)
|
||||
scan_categories_cache_original = {
|
||||
key: value.copy() for key, value in scan_categories_cache.items()
|
||||
}
|
||||
scan_resource_groups_cache_original = {
|
||||
key: value.copy() for key, value in scan_resource_groups_cache.items()
|
||||
}
|
||||
group_resources_cache_original = {
|
||||
key: set(value) for key, value in group_resources_cache.items()
|
||||
}
|
||||
|
||||
def cache_resource(resource_uid: str, resource_instance: Resource) -> Resource:
|
||||
if resource_uid not in resource_cache_originals:
|
||||
resource_cache_originals[resource_uid] = resource_cache.get(
|
||||
resource_uid, missing_cache_value
|
||||
)
|
||||
resource_cache[resource_uid] = resource_instance
|
||||
if resource_uid not in resource_failed_findings_cache:
|
||||
failed_count_originals[resource_uid] = None
|
||||
resource_failed_findings_cache[resource_uid] = 0
|
||||
return resource_instance
|
||||
|
||||
def snapshot_failed_count(resource_uid: str) -> None:
|
||||
if resource_uid not in failed_count_originals:
|
||||
failed_count_originals[resource_uid] = (
|
||||
resource_failed_findings_cache.get(resource_uid)
|
||||
)
|
||||
|
||||
def snapshot_resource_fields(
|
||||
resource_uid: str, resource_instance: Resource
|
||||
) -> None:
|
||||
if resource_uid in resource_field_originals:
|
||||
return
|
||||
resource_field_originals[resource_uid] = {
|
||||
field: copy.deepcopy(getattr(resource_instance, field))
|
||||
for field in (
|
||||
"name",
|
||||
"metadata",
|
||||
"details",
|
||||
"partition",
|
||||
"region",
|
||||
"service",
|
||||
"type",
|
||||
"groups",
|
||||
"updated_at",
|
||||
)
|
||||
}
|
||||
|
||||
def restore_attempt_caches() -> None:
|
||||
for resource_uid, original_fields in resource_field_originals.items():
|
||||
resource_instance = resource_cache.get(resource_uid)
|
||||
if resource_instance is None:
|
||||
continue
|
||||
for field, value in original_fields.items():
|
||||
setattr(resource_instance, field, value)
|
||||
for resource_uid, original_resource in resource_cache_originals.items():
|
||||
if original_resource is missing_cache_value:
|
||||
resource_cache.pop(resource_uid, None)
|
||||
else:
|
||||
resource_cache[resource_uid] = original_resource
|
||||
for resource_uid, original_count in failed_count_originals.items():
|
||||
if original_count is None:
|
||||
resource_failed_findings_cache.pop(resource_uid, None)
|
||||
else:
|
||||
resource_failed_findings_cache[resource_uid] = original_count
|
||||
tag_cache.clear()
|
||||
tag_cache.update(tag_cache_original)
|
||||
scan_resource_cache.clear()
|
||||
scan_resource_cache.update(scan_resource_cache_original)
|
||||
scan_categories_cache.clear()
|
||||
scan_categories_cache.update(
|
||||
{
|
||||
key: value.copy()
|
||||
for key, value in scan_categories_cache_original.items()
|
||||
}
|
||||
)
|
||||
scan_resource_groups_cache.clear()
|
||||
scan_resource_groups_cache.update(
|
||||
{
|
||||
key: value.copy()
|
||||
for key, value in scan_resource_groups_cache_original.items()
|
||||
}
|
||||
)
|
||||
group_resources_cache.clear()
|
||||
group_resources_cache.update(
|
||||
{
|
||||
key: set(value)
|
||||
for key, value in group_resources_cache_original.items()
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
with rls_transaction(tenant_id):
|
||||
# 1) Pre-resolve Resources in bulk
|
||||
@@ -678,19 +904,8 @@ def _process_finding_micro_batch(
|
||||
resources_to_create = []
|
||||
for uid in missing_uids:
|
||||
f = first_finding_per_uid[uid]
|
||||
check_metadata = f.get_metadata()
|
||||
group = check_metadata.get("resourcegroup") or None
|
||||
resources_to_create.append(
|
||||
Resource(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider_instance,
|
||||
uid=uid,
|
||||
region=f.region,
|
||||
service=f.service_name,
|
||||
type=f.resource_type,
|
||||
name=f.resource_name,
|
||||
groups=[group] if group else None,
|
||||
)
|
||||
Resource(**build_resource_defaults_from_finding(f))
|
||||
)
|
||||
Resource.objects.bulk_create(
|
||||
resources_to_create,
|
||||
@@ -711,8 +926,7 @@ def _process_finding_micro_batch(
|
||||
}
|
||||
)
|
||||
for uid, r in existing_resources.items():
|
||||
resource_cache[uid] = r
|
||||
resource_failed_findings_cache.setdefault(uid, 0)
|
||||
cache_resource(uid, r)
|
||||
|
||||
# 2) Pre-resolve ResourceTags in bulk
|
||||
batch_tag_kv: set[tuple[str, str]] = set()
|
||||
@@ -758,47 +972,50 @@ def _process_finding_micro_batch(
|
||||
resource_uid = finding.resource_uid
|
||||
resource_instance = resource_cache.get(resource_uid)
|
||||
if resource_instance is None:
|
||||
# Should be unreachable after the pre-resolve step. Defensive log.
|
||||
logger.error(
|
||||
f"Resource {resource_uid} missing from cache after pre-resolve "
|
||||
f"on scan {scan_instance.id}; skipping finding."
|
||||
)
|
||||
continue
|
||||
resource_instance = recover_resource_after_cache_miss(finding)
|
||||
|
||||
# Detect resource field changes (defer save until end-of-batch bulk_update).
|
||||
check_metadata = finding.get_metadata()
|
||||
group = check_metadata.get("resourcegroup") or None
|
||||
updated = False
|
||||
if finding.region and resource_instance.region != finding.region:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.region = finding.region
|
||||
updated = True
|
||||
if (
|
||||
finding.resource_name
|
||||
and resource_instance.name != finding.resource_name
|
||||
):
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.name = finding.resource_name
|
||||
updated = True
|
||||
if resource_instance.service != finding.service_name:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.service = finding.service_name
|
||||
updated = True
|
||||
if resource_instance.type != finding.resource_type:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.type = finding.resource_type
|
||||
updated = True
|
||||
if resource_instance.metadata != finding.resource_metadata:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.metadata = json.dumps(
|
||||
finding.resource_metadata, cls=CustomEncoder
|
||||
)
|
||||
updated = True
|
||||
if resource_instance.details != finding.resource_details:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.details = finding.resource_details
|
||||
updated = True
|
||||
if resource_instance.partition != finding.partition:
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.partition = finding.partition
|
||||
updated = True
|
||||
if group and (
|
||||
not resource_instance.groups
|
||||
or group not in resource_instance.groups
|
||||
):
|
||||
snapshot_resource_fields(resource_uid, resource_instance)
|
||||
resource_instance.groups = (resource_instance.groups or []) + [
|
||||
group
|
||||
]
|
||||
@@ -860,6 +1077,7 @@ def _process_finding_micro_batch(
|
||||
muted_reason = mute_rules_cache[finding_uid]
|
||||
|
||||
if status == FindingStatus.FAIL and not is_muted:
|
||||
snapshot_failed_count(resource_uid)
|
||||
resource_failed_findings_cache[resource_uid] += 1
|
||||
|
||||
check_metadata["compliance"] = finding.compliance
|
||||
@@ -885,15 +1103,19 @@ def _process_finding_micro_batch(
|
||||
# Denormalized resource arrays populated directly on insert
|
||||
# (was previously a separate bulk_update; saves a CASE WHEN
|
||||
# over thousands of rows per micro-batch).
|
||||
resource_regions=[resource_instance.region]
|
||||
if resource_instance.region
|
||||
else [],
|
||||
resource_services=[resource_instance.service]
|
||||
if resource_instance.service
|
||||
else [],
|
||||
resource_types=[resource_instance.type]
|
||||
if resource_instance.type
|
||||
else [],
|
||||
resource_regions=(
|
||||
[resource_instance.region]
|
||||
if resource_instance.region
|
||||
else []
|
||||
),
|
||||
resource_services=(
|
||||
[resource_instance.service]
|
||||
if resource_instance.service
|
||||
else []
|
||||
),
|
||||
resource_types=(
|
||||
[resource_instance.type] if resource_instance.type else []
|
||||
),
|
||||
)
|
||||
findings_to_create.append(finding_instance)
|
||||
resource_denormalized_data.append(
|
||||
@@ -1013,6 +1235,7 @@ def _process_finding_micro_batch(
|
||||
if r is None:
|
||||
continue
|
||||
# Manually bump updated_at since bulk_update bypasses auto_now.
|
||||
snapshot_resource_fields(uid, r)
|
||||
r.updated_at = now_utc
|
||||
resources_to_bulk_update.append(r)
|
||||
if resources_to_bulk_update:
|
||||
@@ -1034,6 +1257,7 @@ def _process_finding_micro_batch(
|
||||
# Successful execution: leave deadlock retry loop.
|
||||
break
|
||||
except (OperationalError, IntegrityError) as db_err:
|
||||
restore_attempt_caches()
|
||||
if attempt < CELERY_DEADLOCK_ATTEMPTS - 1:
|
||||
logger.warning(
|
||||
f"{'Deadlock error' if isinstance(db_err, OperationalError) else 'Integrity error'} "
|
||||
@@ -1708,8 +1932,10 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
)
|
||||
|
||||
# Yield rows lazily (consumed batch-by-batch by COPY) so peak memory
|
||||
# stays bounded; tally requirement_statuses in the same pass.
|
||||
# stays bounded; tally requirement_statuses in the same pass. The
|
||||
# ORM fallback re-iterates from scratch, so the tally resets first.
|
||||
def _iter_compliance_requirement_rows():
|
||||
requirement_statuses.clear()
|
||||
for region in regions:
|
||||
region_stats = region_requirement_stats.get(region, {})
|
||||
region_findings = findings_count_by_compliance.get(region, {})
|
||||
@@ -1773,12 +1999,10 @@ def create_compliance_requirements(tenant_id: str, scan_id: str):
|
||||
"total_findings": total_findings,
|
||||
}
|
||||
|
||||
# Idempotent re-run: clear this scan's rows before re-inserting.
|
||||
with rls_transaction(tenant_id):
|
||||
ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete()
|
||||
|
||||
# The delete of the scan's previous rows happens inside the same
|
||||
# transaction as the inserts (see _copy_compliance_requirement_rows).
|
||||
requirements_created = _persist_compliance_requirement_rows(
|
||||
tenant_id, _iter_compliance_requirement_rows()
|
||||
tenant_id_str, scan_id_str, _iter_compliance_requirement_rows
|
||||
)
|
||||
|
||||
# Create pre-aggregated summaries for fast compliance overview lookups
|
||||
|
||||
@@ -11,6 +11,7 @@ from api.compliance import (
|
||||
from api.db_router import READ_REPLICA_ALIAS
|
||||
from api.db_utils import delete_related_daily_task, rls_transaction
|
||||
from api.decorators import handle_provider_deletion, set_tenant
|
||||
from api.exceptions import ProviderDeletedException
|
||||
from api.models import (
|
||||
Finding,
|
||||
Integration,
|
||||
@@ -666,7 +667,13 @@ class AttackPathsScanRLSTask(RLSTask):
|
||||
scan_id = kwargs.get("scan_id")
|
||||
|
||||
if tenant_id and scan_id:
|
||||
logger.error(f"Attack paths scan task {task_id} failed: {exc}")
|
||||
if isinstance(exc, ProviderDeletedException):
|
||||
logger.warning(
|
||||
f"Attack paths scan task {task_id} stopped because its provider "
|
||||
f"or tenant was deleted: {exc}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"Attack paths scan task {task_id} failed: {exc}")
|
||||
attack_paths_db_utils.fail_attack_paths_scan(tenant_id, scan_id, str(exc))
|
||||
|
||||
|
||||
@@ -790,12 +797,34 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
|
||||
if name not in frameworks_bulk and universal_bulk[name].outputs
|
||||
}
|
||||
frameworks_avail = get_compliance_frameworks(provider_type)
|
||||
# Idempotency: a previous run of this task for the same scan may have left
|
||||
# output files behind (e.g. broker redelivery after a worker was killed
|
||||
# mid-run with task_acks_late, or a successful run on a deployment without
|
||||
# S3 where the tmp dir is not removed). Output writers open files in append
|
||||
# mode with a deterministic path (derived from scan.started_at), so reusing
|
||||
# them would append every finding row again and duplicate the CSV/output
|
||||
# rows. Start from a clean slate before (re)generating.
|
||||
scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id)
|
||||
if os.path.exists(scan_tmp_dir):
|
||||
rmtree(scan_tmp_dir, ignore_errors=True)
|
||||
# The writers below open output files in append mode with deterministic
|
||||
# paths (derived from scan.started_at). Any stale file that survives the
|
||||
# cleanup would get every finding row appended again, which is the exact
|
||||
# duplication this guards against. Continuing is therefore unsafe: abort
|
||||
# so `ScanReportRLSTask.on_failure` removes the tmp dir and the retry
|
||||
# starts from a clean slate instead of publishing duplicated rows.
|
||||
if os.path.exists(scan_tmp_dir):
|
||||
raise RuntimeError(
|
||||
"Could not remove stale output directory for scan "
|
||||
f"{scan_id} before generating outputs; aborting to avoid "
|
||||
"duplicated rows in appended outputs."
|
||||
)
|
||||
|
||||
out_dir, comp_dir = _generate_output_directory(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id
|
||||
)
|
||||
# Removed on success here and on failure by ScanReportRLSTask.on_failure,
|
||||
# so partial artifacts do not accumulate and fill the disk (ENOSPC).
|
||||
scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id)
|
||||
|
||||
def get_writer(writer_map, name, factory, is_last):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import neo4j.exceptions
|
||||
import pytest
|
||||
from tasks.jobs.attack_paths import aws
|
||||
|
||||
DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
|
||||
|
||||
|
||||
def _make_neo4j_error(code: str) -> neo4j.exceptions.Neo4jError:
|
||||
return neo4j.exceptions.Neo4jError._hydrate_neo4j(
|
||||
code=code,
|
||||
message="graph query failed",
|
||||
)
|
||||
|
||||
|
||||
def _resource_functions(failing_sync, following_sync):
|
||||
return {
|
||||
"failing_sync": failing_sync,
|
||||
"following_sync": following_sync,
|
||||
"permission_relationships": MagicMock(),
|
||||
"resourcegroupstaggingapi": MagicMock(),
|
||||
}
|
||||
|
||||
|
||||
def test_sync_aws_account_reraises_database_not_found_immediately():
|
||||
error = _make_neo4j_error(DATABASE_NOT_FOUND_CODE)
|
||||
failing_sync = MagicMock(side_effect=error)
|
||||
following_sync = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
aws.cartography_aws,
|
||||
"RESOURCE_FUNCTIONS",
|
||||
_resource_functions(failing_sync, following_sync),
|
||||
),
|
||||
patch.object(aws.db_utils, "update_attack_paths_scan_progress"),
|
||||
patch.object(aws.utils, "stringify_exception") as stringify_exception,
|
||||
patch.object(aws.logger, "warning") as warning,
|
||||
pytest.raises(neo4j.exceptions.Neo4jError) as exc_info,
|
||||
):
|
||||
aws.sync_aws_account(
|
||||
SimpleNamespace(uid="123456789012"),
|
||||
[
|
||||
"failing_sync",
|
||||
"following_sync",
|
||||
"permission_relationships",
|
||||
"resourcegroupstaggingapi",
|
||||
],
|
||||
{},
|
||||
MagicMock(),
|
||||
)
|
||||
|
||||
assert exc_info.value is error
|
||||
following_sync.assert_not_called()
|
||||
stringify_exception.assert_not_called()
|
||||
warning.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
_make_neo4j_error("Neo.ClientError.Statement.SyntaxError"),
|
||||
RuntimeError("resource sync failed"),
|
||||
],
|
||||
ids=["different-neo4j-error", "non-neo4j-error"],
|
||||
)
|
||||
def test_sync_aws_account_warns_and_continues_for_other_exceptions(error):
|
||||
failing_sync = MagicMock(side_effect=error)
|
||||
following_sync = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
aws.cartography_aws,
|
||||
"RESOURCE_FUNCTIONS",
|
||||
_resource_functions(failing_sync, following_sync),
|
||||
),
|
||||
patch.object(aws.db_utils, "update_attack_paths_scan_progress"),
|
||||
patch.object(
|
||||
aws.utils,
|
||||
"stringify_exception",
|
||||
return_value="formatted failure",
|
||||
),
|
||||
patch.object(aws.logger, "warning") as warning,
|
||||
):
|
||||
failed_syncs = aws.sync_aws_account(
|
||||
SimpleNamespace(uid="123456789012"),
|
||||
[
|
||||
"failing_sync",
|
||||
"following_sync",
|
||||
"permission_relationships",
|
||||
"resourcegroupstaggingapi",
|
||||
],
|
||||
{},
|
||||
MagicMock(),
|
||||
)
|
||||
|
||||
assert failed_syncs == {"failing_sync": "formatted failure"}
|
||||
following_sync.assert_called_once_with()
|
||||
warning.assert_called_once()
|
||||
assert "Continuing to the next AWS sync function" in warning.call_args.args[0]
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from contextlib import nullcontext
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
@@ -5,7 +6,9 @@ from unittest.mock import MagicMock, call, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.attack_paths.database import GraphDatabaseQueryException
|
||||
from api.db_utils import rls_transaction
|
||||
from api.exceptions import ProviderDeletedException
|
||||
from api.models import (
|
||||
AttackPathsScan,
|
||||
Finding,
|
||||
@@ -250,6 +253,32 @@ class TestAttackPathsRun:
|
||||
mock_starting.assert_not_called()
|
||||
mock_create_db.assert_not_called()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ingestion_error", "temporary_database_missing"),
|
||||
[
|
||||
(RuntimeError("ingestion boom"), False),
|
||||
(
|
||||
GraphDatabaseQueryException(
|
||||
message="Graph not found: db-scan-id",
|
||||
code="Neo.ClientError.Database.DatabaseNotFound",
|
||||
),
|
||||
True,
|
||||
),
|
||||
(
|
||||
GraphDatabaseQueryException(
|
||||
message="Graph not found: db-tenant-id",
|
||||
code="Neo.ClientError.Database.DatabaseNotFound",
|
||||
),
|
||||
False,
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"regular-error",
|
||||
"temporary-database-missing",
|
||||
"sink-database-missing",
|
||||
],
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.scan.logger")
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.utils.stringify_exception",
|
||||
return_value="Cartography failed: ingestion boom",
|
||||
@@ -302,6 +331,9 @@ class TestAttackPathsRun:
|
||||
mock_drop_db,
|
||||
mock_event_loop,
|
||||
mock_stringify,
|
||||
mock_logger,
|
||||
ingestion_error,
|
||||
temporary_database_missing,
|
||||
tenants_fixture,
|
||||
aws_provider,
|
||||
scans_fixture,
|
||||
@@ -321,7 +353,11 @@ class TestAttackPathsRun:
|
||||
session_ctx = MagicMock()
|
||||
session_ctx.__enter__.return_value = mock_session
|
||||
session_ctx.__exit__.return_value = False
|
||||
ingestion_fn = MagicMock(side_effect=RuntimeError("ingestion boom"))
|
||||
ingestion_fn = MagicMock(side_effect=ingestion_error)
|
||||
if temporary_database_missing:
|
||||
mock_finish.side_effect = DatabaseError(
|
||||
"Save with update_fields did not affect any rows"
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
@@ -337,13 +373,28 @@ class TestAttackPathsRun:
|
||||
return_value=ingestion_fn,
|
||||
),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="ingestion boom"):
|
||||
with pytest.raises(type(ingestion_error)):
|
||||
attack_paths_run(str(tenant.id), str(scan.id), "task-456")
|
||||
|
||||
failure_args = mock_finish.call_args[0]
|
||||
assert failure_args[0] is attack_paths_scan
|
||||
assert failure_args[1] == StateChoices.FAILED
|
||||
assert failure_args[2] == {"global_error": "Cartography failed: ingestion boom"}
|
||||
mock_drop_db.assert_called_once_with("db-scan-id")
|
||||
if temporary_database_missing:
|
||||
mock_logger.warning.assert_any_call("Cartography failed: ingestion boom")
|
||||
mock_logger.exception.assert_not_called()
|
||||
mock_logger.log.assert_called_once_with(
|
||||
logging.WARNING,
|
||||
f"Could not mark Attack Paths scan {attack_paths_scan.id} as `FAILED` "
|
||||
"(row may have been deleted): Save with update_fields did not affect "
|
||||
"any rows",
|
||||
exc_info=False,
|
||||
)
|
||||
else:
|
||||
mock_logger.exception.assert_called_once_with(
|
||||
"Cartography failed: ingestion boom"
|
||||
)
|
||||
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.utils.stringify_exception",
|
||||
@@ -1265,6 +1316,33 @@ class TestAttackPathsScanRLSTaskOnFailure:
|
||||
|
||||
mock_fail.assert_called_once_with("t-1", "s-1", "boom")
|
||||
|
||||
def test_on_failure_logs_provider_deletion_as_warning(self):
|
||||
from tasks.tasks import AttackPathsScanRLSTask
|
||||
|
||||
task = AttackPathsScanRLSTask()
|
||||
error = ProviderDeletedException("provider deleted")
|
||||
|
||||
with (
|
||||
patch("tasks.tasks.logger") as mock_logger,
|
||||
patch(
|
||||
"tasks.tasks.attack_paths_db_utils.fail_attack_paths_scan"
|
||||
) as mock_fail,
|
||||
):
|
||||
task.on_failure(
|
||||
exc=error,
|
||||
task_id="task-abc",
|
||||
args=(),
|
||||
kwargs={"tenant_id": "t-1", "scan_id": "s-1"},
|
||||
_einfo=None,
|
||||
)
|
||||
|
||||
mock_logger.warning.assert_called_once_with(
|
||||
"Attack paths scan task task-abc stopped because its provider or tenant "
|
||||
"was deleted: provider deleted"
|
||||
)
|
||||
mock_logger.error.assert_not_called()
|
||||
mock_fail.assert_called_once_with("t-1", "s-1", "provider deleted")
|
||||
|
||||
def test_on_failure_skips_when_missing_kwargs(self):
|
||||
from tasks.tasks import AttackPathsScanRLSTask
|
||||
|
||||
@@ -1896,7 +1974,7 @@ class TestSyncNodes:
|
||||
mock_source_1.run.return_value = [row]
|
||||
mock_source_2 = MagicMock()
|
||||
mock_source_2.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1000)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
@@ -1933,7 +2011,7 @@ class TestSyncNodes:
|
||||
src_1.run.return_value = [row]
|
||||
src_2 = MagicMock()
|
||||
src_2.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1000)
|
||||
sink.write_nodes.side_effect = lambda *_a, **_kw: call_order.append(
|
||||
"sink:write"
|
||||
)
|
||||
@@ -1969,18 +2047,15 @@ class TestSyncNodes:
|
||||
src_2.run.return_value = [row_b]
|
||||
src_3 = MagicMock()
|
||||
src_3.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
_make_session_ctx(src_3),
|
||||
],
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 1),
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
_make_session_ctx(src_3),
|
||||
],
|
||||
):
|
||||
result = sync_module.sync_nodes("src", "tgt", "t-1", "p-1", sink, [])
|
||||
|
||||
@@ -2009,17 +2084,14 @@ class TestSyncNodes:
|
||||
src_1.run.return_value = [row]
|
||||
src_2 = MagicMock()
|
||||
src_2.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=2)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
],
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 2),
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
],
|
||||
):
|
||||
result = sync_module.sync_nodes(
|
||||
"src", "tgt", "t-1", "p-1", sink, normalized_lists
|
||||
@@ -2037,7 +2109,7 @@ class TestSyncNodes:
|
||||
def test_sync_nodes_empty_source_returns_zero(self):
|
||||
src = MagicMock()
|
||||
src.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1000)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
@@ -2066,7 +2138,7 @@ class TestSyncRelationships:
|
||||
src_1.run.return_value = [row]
|
||||
src_2 = MagicMock()
|
||||
src_2.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1000)
|
||||
sink.write_relationships.side_effect = lambda *_a, **_kw: call_order.append(
|
||||
"sink:write"
|
||||
)
|
||||
@@ -2104,18 +2176,15 @@ class TestSyncRelationships:
|
||||
src_2.run.return_value = [row_b]
|
||||
src_3 = MagicMock()
|
||||
src_3.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
_make_session_ctx(src_3),
|
||||
],
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 1),
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
_make_session_ctx(src_3),
|
||||
],
|
||||
):
|
||||
total = sync_module.sync_relationships("src", "tgt", "p-1", sink)
|
||||
|
||||
@@ -2140,17 +2209,14 @@ class TestSyncRelationships:
|
||||
src_1.run.return_value = rows
|
||||
src_2 = MagicMock()
|
||||
src_2.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=2)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
],
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.sync.SYNC_BATCH_SIZE", 2),
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
side_effect=[
|
||||
_make_session_ctx(src_1),
|
||||
_make_session_ctx(src_2),
|
||||
],
|
||||
):
|
||||
total = sync_module.sync_relationships("src", "tgt", "p-1", sink)
|
||||
|
||||
@@ -2163,7 +2229,7 @@ class TestSyncRelationships:
|
||||
def test_sync_relationships_empty_source_returns_zero(self):
|
||||
src = MagicMock()
|
||||
src.run.return_value = []
|
||||
sink = MagicMock()
|
||||
sink = MagicMock(sync_batch_size=1000)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.sync.graph_database.get_session",
|
||||
@@ -3056,6 +3122,61 @@ class TestCleanupStaleAttackPathsScans:
|
||||
ap_scan.refresh_from_db()
|
||||
assert ap_scan.state == StateChoices.FAILED
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("age_seconds", "should_clean"),
|
||||
[
|
||||
(960 * 60 - 1, False),
|
||||
(960 * 60, False),
|
||||
(960 * 60 + 1, True),
|
||||
],
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
|
||||
@patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.cleanup.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.cleanup._revoke_task")
|
||||
@patch("tasks.jobs.attack_paths.cleanup._ping_workers")
|
||||
def test_stale_threshold_boundary_is_strict(
|
||||
self,
|
||||
mock_ping,
|
||||
mock_revoke,
|
||||
mock_drop_db,
|
||||
mock_recover,
|
||||
age_seconds,
|
||||
should_clean,
|
||||
tenants_fixture,
|
||||
aws_provider,
|
||||
):
|
||||
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
|
||||
|
||||
now = datetime.now(tz=UTC)
|
||||
ap_scan, task_result = self._create_executing_scan(
|
||||
tenants_fixture[0],
|
||||
aws_provider,
|
||||
started_at=now - timedelta(seconds=age_seconds),
|
||||
worker="live-worker@host",
|
||||
)
|
||||
mock_ping.return_value = ({"live-worker@host"}, set())
|
||||
|
||||
with patch("tasks.jobs.attack_paths.cleanup.datetime") as mock_datetime:
|
||||
mock_datetime.now.return_value = now
|
||||
result = cleanup_stale_attack_paths_scans()
|
||||
|
||||
assert result["cleaned_up_count"] == int(should_clean)
|
||||
ap_scan.refresh_from_db()
|
||||
expected_state = StateChoices.FAILED if should_clean else StateChoices.EXECUTING
|
||||
assert ap_scan.state == expected_state
|
||||
if should_clean:
|
||||
mock_revoke.assert_called_once_with(task_result, terminate=True)
|
||||
mock_drop_db.assert_called_once()
|
||||
mock_recover.assert_called_once()
|
||||
else:
|
||||
mock_revoke.assert_not_called()
|
||||
mock_drop_db.assert_not_called()
|
||||
mock_recover.assert_not_called()
|
||||
|
||||
@patch("tasks.jobs.attack_paths.cleanup.recover_graph_data_ready")
|
||||
@patch("tasks.jobs.attack_paths.cleanup.graph_database.drop_database")
|
||||
@patch(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -671,6 +672,8 @@ class TestSecurityHubIntegrationUploads:
|
||||
mock_integration = MagicMock()
|
||||
mock_integration.configuration = {"send_only_fails": True}
|
||||
mock_integration.credentials = {} # Empty credentials, use provider
|
||||
mock_integration.connected = False
|
||||
mock_integration.connection_last_checked_at = None
|
||||
|
||||
# Mock tenant_id
|
||||
tenant_id = "550e8400-e29b-41d4-a716-446655440000" # Valid UUID
|
||||
@@ -723,12 +726,22 @@ class TestSecurityHubIntegrationUploads:
|
||||
# Configure the test_connection to return our mock_connection
|
||||
mock_security_hub_class.test_connection = mock_test_connection
|
||||
|
||||
checked_at_before = datetime.now(tz=UTC)
|
||||
connected, security_hub = get_security_hub_client_from_integration(
|
||||
mock_integration, tenant_id, mock_findings
|
||||
)
|
||||
checked_at_after = datetime.now(tz=UTC)
|
||||
|
||||
assert connected is True
|
||||
assert security_hub == mock_security_hub
|
||||
assert mock_integration.connected is True
|
||||
assert mock_integration.connection_last_checked_at.tzinfo is UTC
|
||||
assert (
|
||||
checked_at_before
|
||||
<= mock_integration.connection_last_checked_at
|
||||
<= checked_at_after
|
||||
)
|
||||
mock_integration.save.assert_called_once()
|
||||
|
||||
# Verify SecurityHub was called once to create the client
|
||||
assert mock_security_hub_class.call_count == 1
|
||||
|
||||
@@ -3,12 +3,13 @@ from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from api.celery_utils import decode_celery_field
|
||||
from celery import states
|
||||
from celery.utils.saferepr import saferepr
|
||||
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,
|
||||
advisory_lock,
|
||||
@@ -36,24 +37,77 @@ def _orphan_result(*, name, kwargs, worker, created_minutes_ago, status=states.S
|
||||
return tr
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestDecodeCeleryField:
|
||||
def test_decodes_strict_json(self):
|
||||
assert decode_celery_field('{"enabled": true, "scan_id": null}', {}) == {
|
||||
"enabled": True,
|
||||
"scan_id": None,
|
||||
}
|
||||
|
||||
def test_decodes_single_encoded_repr(self):
|
||||
assert _decode_celery_field("{'tenant_id': 'abc'}", {}) == {"tenant_id": "abc"}
|
||||
assert decode_celery_field("{'tenant_id': 'abc'}", {}) == {"tenant_id": "abc"}
|
||||
|
||||
def test_decodes_double_encoded(self):
|
||||
import json
|
||||
|
||||
stored = json.dumps(repr({"tenant_id": "abc", "scan_id": "s1"}))
|
||||
assert _decode_celery_field(stored, {}) == {"tenant_id": "abc", "scan_id": "s1"}
|
||||
assert decode_celery_field(stored, {}) == {
|
||||
"tenant_id": "abc",
|
||||
"scan_id": "s1",
|
||||
}
|
||||
|
||||
def test_python_words_inside_strings_are_preserved(self):
|
||||
stored = repr(
|
||||
{
|
||||
"enabled": True,
|
||||
"scan_id": None,
|
||||
"label": "True North",
|
||||
"note": "None",
|
||||
}
|
||||
)
|
||||
|
||||
assert decode_celery_field(stored, {}) == {
|
||||
"enabled": True,
|
||||
"scan_id": None,
|
||||
"label": "True North",
|
||||
"note": "None",
|
||||
}
|
||||
|
||||
def test_empty_returns_default(self):
|
||||
assert _decode_celery_field(None, {}) == {}
|
||||
assert _decode_celery_field("", []) == []
|
||||
assert decode_celery_field(None, {}) == {}
|
||||
assert decode_celery_field("", []) == []
|
||||
assert decode_celery_field("null", {}) == {}
|
||||
assert decode_celery_field("None", []) == []
|
||||
|
||||
def test_empty_validates_default(self):
|
||||
with pytest.raises(ValueError):
|
||||
decode_celery_field("", {"value": ...})
|
||||
|
||||
def test_unparseable_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_decode_celery_field("<<not a literal>>", {})
|
||||
decode_celery_field("<<not a literal>>", {})
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
(
|
||||
"{'value': ...}",
|
||||
"{'value': {1, 2}}",
|
||||
"{'value': b'bytes'}",
|
||||
'{"value": NaN}',
|
||||
),
|
||||
)
|
||||
def test_non_json_values_raise(self, value):
|
||||
with pytest.raises(ValueError):
|
||||
decode_celery_field(value, {})
|
||||
|
||||
def test_truncated_repr_raises(self):
|
||||
kwargs_repr = saferepr(
|
||||
{"finding_ids": [str(uuid4()) for _ in range(30)]}, maxlen=1024
|
||||
)
|
||||
assert "..." in kwargs_repr
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
decode_celery_field(kwargs_repr, {})
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -98,6 +152,58 @@ class TestReconcileTaskResults:
|
||||
assert call["kwargs"] == {"tenant_id": str(tenant.id)}
|
||||
assert call["task_id"] != tr.task_id # fresh task id
|
||||
|
||||
def test_truncated_kwargs_are_not_reenqueued(self, tenants_fixture):
|
||||
tenant = tenants_fixture[0]
|
||||
tr = _orphan_result(
|
||||
name="tenant-deletion",
|
||||
kwargs={"tenant_id": str(tenant.id)},
|
||||
worker="dead@gone",
|
||||
created_minutes_ago=60,
|
||||
)
|
||||
tr.task_kwargs = saferepr(
|
||||
{"finding_ids": [str(uuid4()) for _ in range(30)]}, maxlen=1024
|
||||
)
|
||||
assert "..." in tr.task_kwargs
|
||||
tr.save(update_fields=["task_kwargs"])
|
||||
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
|
||||
|
||||
with (
|
||||
p_alive,
|
||||
p_revoke,
|
||||
p_app,
|
||||
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
|
||||
):
|
||||
result = _reconcile_task_results(
|
||||
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
|
||||
)
|
||||
|
||||
assert tr.task_id in result["failed"]
|
||||
mock_task.apply_async.assert_not_called()
|
||||
|
||||
def test_wrong_kwargs_shape_is_not_reenqueued(self, tenants_fixture):
|
||||
tr = _orphan_result(
|
||||
name="tenant-deletion",
|
||||
kwargs={"tenant_id": str(tenants_fixture[0].id)},
|
||||
worker="dead@gone",
|
||||
created_minutes_ago=60,
|
||||
)
|
||||
tr.task_kwargs = "[]"
|
||||
tr.save(update_fields=["task_kwargs"])
|
||||
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
|
||||
|
||||
with (
|
||||
p_alive,
|
||||
p_revoke,
|
||||
p_app,
|
||||
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
|
||||
):
|
||||
result = _reconcile_task_results(
|
||||
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
|
||||
)
|
||||
|
||||
assert tr.task_id in result["failed"]
|
||||
mock_task.apply_async.assert_not_called()
|
||||
|
||||
def test_external_integration_task_is_not_reenqueued_by_default(
|
||||
self, tenants_fixture
|
||||
):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -420,6 +420,124 @@ class TestGenerateOutputs:
|
||||
assert result == {"upload": False}
|
||||
mock_scan_update.return_value.update.assert_called_once()
|
||||
|
||||
def test_generate_outputs_removes_previous_run_artifacts(self):
|
||||
"""Regression for PROWLER-2266.
|
||||
|
||||
Output writers open files in append mode with a deterministic path
|
||||
(derived from scan.started_at). If this task runs again for the same
|
||||
scan (e.g. broker redelivery after a worker is killed mid-run with
|
||||
task_acks_late), reusing the leftover files appends every finding row
|
||||
again, duplicating rows in the CSV/output while the API console keeps
|
||||
showing a single finding. The task must start from a clean slate by
|
||||
removing the scan's tmp output directory before (re)generating.
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_root:
|
||||
# Simulate artifacts left behind by a previous run of the same scan.
|
||||
scan_tmp_dir = Path(tmp_root) / self.tenant_id / self.scan_id
|
||||
scan_tmp_dir.mkdir(parents=True)
|
||||
stale_artifact = scan_tmp_dir / "prowler-output-aws-20260723120000.csv"
|
||||
stale_artifact.write_text("HEADER\nold-finding-row\n")
|
||||
|
||||
with (
|
||||
patch("tasks.tasks.DJANGO_TMP_OUTPUT_DIRECTORY", tmp_root),
|
||||
patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter,
|
||||
patch("tasks.tasks.Provider.objects.get"),
|
||||
patch("tasks.tasks.initialize_prowler_provider"),
|
||||
patch("tasks.tasks.Compliance.get_bulk"),
|
||||
patch("tasks.tasks.get_compliance_frameworks"),
|
||||
patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
|
||||
patch("tasks.tasks.Finding.all_objects.filter") as mock_findings,
|
||||
patch(
|
||||
"tasks.tasks._generate_output_directory",
|
||||
return_value=("/tmp/test/out", "/tmp/test/comp"),
|
||||
),
|
||||
patch("tasks.tasks.FindingOutput._transform_findings_stats"),
|
||||
patch("tasks.tasks.FindingOutput.transform_api_finding"),
|
||||
patch(
|
||||
"tasks.tasks.OUTPUT_FORMATS_MAPPING",
|
||||
{
|
||||
"json": {
|
||||
"class": MagicMock(name="Writer"),
|
||||
"suffix": ".json",
|
||||
"kwargs": {},
|
||||
}
|
||||
},
|
||||
),
|
||||
patch("tasks.tasks.COMPLIANCE_CLASS_MAP", {"aws": []}),
|
||||
patch(
|
||||
"tasks.tasks._compress_output_files", return_value="/tmp/compressed"
|
||||
),
|
||||
patch("tasks.tasks._upload_to_s3", return_value=None),
|
||||
patch("tasks.tasks.Scan.all_objects.filter"),
|
||||
):
|
||||
mock_filter.return_value.exists.return_value = True
|
||||
mock_findings.return_value.order_by.return_value.iterator.return_value = [
|
||||
[MagicMock()],
|
||||
True,
|
||||
]
|
||||
|
||||
generate_outputs_task(
|
||||
scan_id=self.scan_id,
|
||||
provider_id=self.provider_id,
|
||||
tenant_id=self.tenant_id,
|
||||
)
|
||||
|
||||
# The stale artifacts from the previous run must be gone, so the
|
||||
# append-mode writers cannot duplicate rows onto them.
|
||||
assert not stale_artifact.exists()
|
||||
assert not scan_tmp_dir.exists()
|
||||
|
||||
def test_generate_outputs_aborts_when_stale_cleanup_fails(self):
|
||||
"""Regression for PROWLER-2266.
|
||||
|
||||
If the stale output directory cannot be removed (e.g. permission error),
|
||||
the leftover files would be reopened in append mode and every finding
|
||||
row would be duplicated. The task must abort instead of continuing and
|
||||
publishing duplicated rows, so the retry can start from a clean slate.
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_root:
|
||||
scan_tmp_dir = Path(tmp_root) / self.tenant_id / self.scan_id
|
||||
scan_tmp_dir.mkdir(parents=True)
|
||||
stale_artifact = scan_tmp_dir / "prowler-output-aws-20260723120000.csv"
|
||||
stale_artifact.write_text("HEADER\nold-finding-row\n")
|
||||
|
||||
with (
|
||||
patch("tasks.tasks.DJANGO_TMP_OUTPUT_DIRECTORY", tmp_root),
|
||||
patch("tasks.tasks.ScanSummary.objects.filter") as mock_filter,
|
||||
patch("tasks.tasks.Provider.objects.get"),
|
||||
patch("tasks.tasks.initialize_prowler_provider"),
|
||||
patch("tasks.tasks.Compliance.get_bulk"),
|
||||
patch("tasks.tasks.get_compliance_frameworks"),
|
||||
patch("tasks.tasks.get_prowler_provider_compliance", return_value={}),
|
||||
# `rmtree(ignore_errors=True)` swallows the failure and leaves the
|
||||
# directory behind; simulate that with a no-op so the guard fires.
|
||||
patch("tasks.tasks.rmtree"),
|
||||
patch("tasks.tasks._generate_output_directory") as mock_gen_dir,
|
||||
patch("tasks.tasks._compress_output_files") as mock_compress,
|
||||
patch("tasks.tasks._upload_to_s3") as mock_upload,
|
||||
patch("tasks.tasks.Scan.all_objects.filter") as mock_scan_update,
|
||||
):
|
||||
mock_filter.return_value.exists.return_value = True
|
||||
|
||||
with pytest.raises(RuntimeError, match="stale output directory"):
|
||||
generate_outputs_task(
|
||||
scan_id=self.scan_id,
|
||||
provider_id=self.provider_id,
|
||||
tenant_id=self.tenant_id,
|
||||
)
|
||||
|
||||
# The task must abort before generating/publishing any output.
|
||||
mock_gen_dir.assert_not_called()
|
||||
mock_compress.assert_not_called()
|
||||
mock_upload.assert_not_called()
|
||||
mock_scan_update.assert_not_called()
|
||||
|
||||
def test_generate_outputs_triggers_html_extra_update(self):
|
||||
mock_finding_output = MagicMock()
|
||||
mock_finding_output.compliance = {"cis": ["requirement-1", "requirement-2"]}
|
||||
@@ -3304,6 +3422,7 @@ class TestTaskTimeLimits:
|
||||
for name in (
|
||||
"scan-perform",
|
||||
"scan-perform-scheduled",
|
||||
"attack-paths-scan-perform",
|
||||
"provider-deletion",
|
||||
"tenant-deletion",
|
||||
):
|
||||
|
||||
Generated
+380
-134
@@ -45,7 +45,7 @@ constraints = [
|
||||
{ name = "alibabacloud-sls20201230", specifier = "==5.9.0" },
|
||||
{ name = "alibabacloud-sts20150401", specifier = "==1.1.6" },
|
||||
{ name = "alibabacloud-tea", specifier = "==0.4.3" },
|
||||
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.4" },
|
||||
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.5" },
|
||||
{ name = "alibabacloud-tea-util", specifier = "==0.3.14" },
|
||||
{ name = "alibabacloud-tea-xml", specifier = "==0.0.3" },
|
||||
{ name = "alibabacloud-vpc20160428", specifier = "==6.13.0" },
|
||||
@@ -127,9 +127,9 @@ constraints = [
|
||||
{ name = "coverage", specifier = "==7.5.4" },
|
||||
{ name = "cron-descriptor", specifier = "==1.4.5" },
|
||||
{ name = "crowdstrike-falconpy", specifier = "==1.6.0" },
|
||||
{ name = "cryptography", specifier = "==46.0.7" },
|
||||
{ name = "cryptography", specifier = "==50.0.0" },
|
||||
{ name = "cycler", specifier = "==0.12.1" },
|
||||
{ name = "darabonba-core", specifier = "==1.0.5" },
|
||||
{ name = "darabonba-core", specifier = "==1.0.8" },
|
||||
{ name = "dash", specifier = "==3.1.1" },
|
||||
{ name = "dash-bootstrap-components", specifier = "==2.0.3" },
|
||||
{ name = "debugpy", specifier = "==1.8.20" },
|
||||
@@ -194,7 +194,7 @@ constraints = [
|
||||
{ name = "h2", specifier = "==4.3.0" },
|
||||
{ name = "hpack", specifier = "==4.1.0" },
|
||||
{ name = "httpcore", specifier = "==1.0.9" },
|
||||
{ name = "httplib2", specifier = "==0.31.2" },
|
||||
{ name = "httplib2", specifier = "==0.32.0" },
|
||||
{ name = "httpx", specifier = "==0.28.1" },
|
||||
{ name = "humanfriendly", specifier = "==10.0" },
|
||||
{ name = "hyperframe", specifier = "==6.1.0" },
|
||||
@@ -231,13 +231,13 @@ constraints = [
|
||||
{ name = "matplotlib", specifier = "==3.10.8" },
|
||||
{ name = "mccabe", specifier = "==0.7.0" },
|
||||
{ name = "mdurl", specifier = "==0.1.2" },
|
||||
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.9" },
|
||||
{ name = "microsoft-kiota-authentication-azure", specifier = "==1.9.9" },
|
||||
{ name = "microsoft-kiota-http", specifier = "==1.9.9" },
|
||||
{ name = "microsoft-kiota-serialization-form", specifier = "==1.9.9" },
|
||||
{ name = "microsoft-kiota-serialization-json", specifier = "==1.9.9" },
|
||||
{ name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.9" },
|
||||
{ name = "microsoft-kiota-serialization-text", specifier = "==1.9.9" },
|
||||
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-authentication-azure", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-http", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-form", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-json", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-text", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-security-utilities-secret-masker", specifier = "==1.0.0b4" },
|
||||
{ name = "msal", specifier = "==1.35.0b1" },
|
||||
{ name = "msal-extensions", specifier = "==1.2.0" },
|
||||
@@ -254,7 +254,7 @@ constraints = [
|
||||
{ name = "nltk", specifier = "==3.9.4" },
|
||||
{ name = "numpy", specifier = "==2.2.6" },
|
||||
{ name = "oauthlib", specifier = "==3.3.1" },
|
||||
{ name = "oci", specifier = "==2.169.0" },
|
||||
{ name = "oci", specifier = "==2.183.0" },
|
||||
{ name = "openai", specifier = "==1.109.1" },
|
||||
{ name = "openstacksdk", specifier = "==4.2.0" },
|
||||
{ name = "opentelemetry-api", specifier = "==1.39.1" },
|
||||
@@ -266,7 +266,7 @@ constraints = [
|
||||
{ name = "pagerduty", specifier = "==6.1.0" },
|
||||
{ name = "pandas", specifier = "==2.2.3" },
|
||||
{ name = "pbr", specifier = "==7.0.3" },
|
||||
{ name = "pillow", specifier = "==12.2.0" },
|
||||
{ name = "pillow", specifier = "==12.3.0" },
|
||||
{ name = "pkginfo", specifier = "==1.12.1.2" },
|
||||
{ name = "platformdirs", specifier = "==4.5.1" },
|
||||
{ name = "plotly", specifier = "==6.5.2" },
|
||||
@@ -282,8 +282,8 @@ constraints = [
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.9" },
|
||||
{ name = "py-deviceid", specifier = "==0.1.1" },
|
||||
{ name = "py-iam-expand", specifier = "==0.3.0" },
|
||||
{ name = "py-ocsf-models", specifier = "==0.8.1" },
|
||||
{ name = "pyasn1", specifier = "==0.6.3" },
|
||||
{ name = "py-ocsf-models", specifier = "==0.10.0" },
|
||||
{ name = "pyasn1", specifier = "==0.6.4" },
|
||||
{ name = "pyasn1-modules", specifier = "==0.4.2" },
|
||||
{ name = "pycodestyle", specifier = "==2.14.0" },
|
||||
{ name = "pycparser", specifier = "==3.0" },
|
||||
@@ -295,7 +295,7 @@ constraints = [
|
||||
{ name = "pylint", specifier = "==3.2.5" },
|
||||
{ name = "pymsalruntime", specifier = "==0.18.1" },
|
||||
{ name = "pynacl", specifier = "==1.6.2" },
|
||||
{ name = "pyopenssl", specifier = "==26.0.0" },
|
||||
{ name = "pyopenssl", specifier = "==26.2.0" },
|
||||
{ name = "pyparsing", specifier = "==3.3.2" },
|
||||
{ name = "pyreadline3", specifier = "==3.5.4" },
|
||||
{ name = "pysocks", specifier = "==1.7.1" },
|
||||
@@ -364,7 +364,7 @@ constraints = [
|
||||
{ name = "wcwidth", specifier = "==0.5.3" },
|
||||
{ name = "websocket-client", specifier = "==1.9.0" },
|
||||
{ name = "werkzeug", specifier = "==3.1.7" },
|
||||
{ name = "workos", specifier = "==6.0.8" },
|
||||
{ name = "workos", specifier = "==8.3.0" },
|
||||
{ name = "wrapt", specifier = "==1.17.3" },
|
||||
{ name = "xlsxwriter", specifier = "==3.2.9" },
|
||||
{ name = "xmlsec", specifier = "==1.3.17" },
|
||||
@@ -377,8 +377,15 @@ constraints = [
|
||||
]
|
||||
overrides = [
|
||||
{ name = "azure-mgmt-containerservice", specifier = "==34.1.0" },
|
||||
{ name = "cryptography", specifier = "==50.0.0" },
|
||||
{ name = "dulwich", specifier = "==1.2.5" },
|
||||
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.9" },
|
||||
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-authentication-azure", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-http", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-form", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-json", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-multipart", specifier = "==1.9.10" },
|
||||
{ name = "microsoft-kiota-serialization-text", specifier = "==1.9.10" },
|
||||
{ name = "okta", specifier = "==3.4.2" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
|
||||
]
|
||||
@@ -853,7 +860,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/9a/7d/b22cb9a0d4f396ee0
|
||||
|
||||
[[package]]
|
||||
name = "alibabacloud-tea-openapi"
|
||||
version = "0.4.4"
|
||||
version = "0.4.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -862,9 +869,9 @@ dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "darabonba-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/30/93/138bcdc8fc596add73e37cf2073798f285284d1240bda9ee02f9384fc6be/alibabacloud_tea_openapi-0.4.4.tar.gz", hash = "sha256:1b0917bc03cd49417da64945e92731716d53e2eb8707b235f54e45b7473221ce", size = 21960, upload-time = "2026-03-26T10:16:16.792Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/73/fb0c4d44759791ecdf269fc715c1e810fa1aba3981bfaaf8a01f61899296/alibabacloud_tea_openapi-0.4.5.tar.gz", hash = "sha256:75fa1f4360a46e41f5bf5f8d4917e52efb6f64885839bc1328c35590670c97b9", size = 26616, upload-time = "2026-07-14T13:15:39.364Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5a/6bfc4506438c1809c486f66217ad11eab78157192b3d5707b4e2f4212f6c/alibabacloud_tea_openapi-0.4.4-py3-none-any.whl", hash = "sha256:cea6bc1fe35b0319a8752cb99eb0ecb0dab7ca1a71b99c12970ba0867410995f", size = 26236, upload-time = "2026-03-26T10:16:15.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/ec/6b368a10e9c2e8b1b394c69b96ac213ae66e8c4895e0baa1ffaf7178fd32/alibabacloud_tea_openapi-0.4.5-py3-none-any.whl", hash = "sha256:338979095c7beda80a5b413c31262892cafdc12069dde4ce4fc2e4f7ce0fc609", size = 33333, upload-time = "2026-07-14T13:15:38.365Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2088,6 +2095,37 @@ toml = [
|
||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32c"
|
||||
version = "2.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/66/7e97aa77af7cf6afbff26e3651b564fe41932599bc2d3dce0b2f73d4829a/crc32c-2.8.tar.gz", hash = "sha256:578728964e59c47c356aeeedee6220e021e124b9d3e8631d95d9a5e5f06e261c", size = 48179, upload-time = "2025-10-17T06:20:13.61Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/0b/5e03b22d913698e9cc563f39b9f6bbd508606bf6b8e9122cd6bf196b87ea/crc32c-2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e560a97fbb96c9897cb1d9b5076ef12fc12e2e25622530a1afd0de4240f17e1f", size = 66329, upload-time = "2025-10-17T06:19:01.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/38/2fe0051ffe8c6a650c8b1ac0da31b8802d1dbe5fa40a84e4b6b6f5583db5/crc32c-2.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6762d276d90331a490ef7e71ffee53b9c0eb053bd75a272d786f3b08d3fe3671", size = 62988, upload-time = "2025-10-17T06:19:02.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/30/5837a71c014be83aba1469c58820d287fc836512a0cad6b8fdd43868accd/crc32c-2.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:60670569f5ede91e39f48fb0cb4060e05b8d8704dd9e17ede930bf441b2f73ef", size = 61522, upload-time = "2025-10-17T06:19:03.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/29/63972fc1452778e2092ae998c50cbfc2fc93e3fa9798a0278650cd6169c5/crc32c-2.8-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:711743da6ccc70b3c6718c328947b0b6f34a1fe6a6c27cc6c1d69cc226bf70e9", size = 80200, upload-time = "2025-10-17T06:19:04.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/3a/60eb49d7bdada4122b3ffd45b0df54bdc1b8dd092cda4b069a287bdfcff4/crc32c-2.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5eb4094a2054774f13b26f21bf56792bb44fa1fcee6c6ad099387a43ffbfb4fa", size = 81757, upload-time = "2025-10-17T06:19:05.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/63/6efc1b64429ef7d23bd58b75b7ac24d15df327e3ebbe9c247a0f7b1c2ed1/crc32c-2.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fff15bf2bd3e95780516baae935ed12be88deaa5ebe6143c53eb0d26a7bdc7b7", size = 80830, upload-time = "2025-10-17T06:19:06.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/eb/0ae9f436f8004f1c88f7429e659a7218a3879bd11a6b18ed1257aad7e98b/crc32c-2.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4c0e11e3826668121fa53e0745635baf5e4f0ded437e8ff63ea56f38fc4f970a", size = 80095, upload-time = "2025-10-17T06:19:07.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/81/4afc9d468977a4cd94a2eb62908553345009a7c0d30e74463a15d4b48ec3/crc32c-2.8-cp311-cp311-win32.whl", hash = "sha256:38f915336715d1f1353ab07d7d786f8a789b119e273aea106ba55355dfc9101d", size = 64886, upload-time = "2025-10-17T06:19:08.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/e8/94e839c9f7e767bf8479046a207afd440a08f5c59b52586e1af5e64fa4a0/crc32c-2.8-cp311-cp311-win_amd64.whl", hash = "sha256:60e0a765b1caab8d31b2ea80840639253906a9351d4b861551c8c8625ea20f86", size = 66639, upload-time = "2025-10-17T06:19:09.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/36/fd18ef23c42926b79c7003e16cb0f79043b5b179c633521343d3b499e996/crc32c-2.8-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:572ffb1b78cce3d88e8d4143e154d31044a44be42cb3f6fbbf77f1e7a941c5ab", size = 66379, upload-time = "2025-10-17T06:19:10.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/b8/c584958e53f7798dd358f5bdb1bbfc97483134f053ee399d3eeb26cca075/crc32c-2.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cf827b3758ee0c4aacd21ceca0e2da83681f10295c38a10bfeb105f7d98f7a68", size = 63042, upload-time = "2025-10-17T06:19:10.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/e6/6f2af0ec64a668a46c861e5bc778ea3ee42171fedfc5440f791f470fd783/crc32c-2.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:106fbd79013e06fa92bc3b51031694fcc1249811ed4364ef1554ee3dd2c7f5a2", size = 61528, upload-time = "2025-10-17T06:19:11.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/8b/4a04bd80a024f1a23978f19ae99407783e06549e361ab56e9c08bba3c1d3/crc32c-2.8-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6dde035f91ffbfe23163e68605ee5a4bb8ceebd71ed54bb1fb1d0526cdd125a2", size = 80028, upload-time = "2025-10-17T06:19:12.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/8f/01c7afdc76ac2007d0e6a98e7300b4470b170480f8188475b597d1f4b4c6/crc32c-2.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e41ebe7c2f0fdcd9f3a3fd206989a36b460b4d3f24816d53e5be6c7dba72c5e1", size = 81531, upload-time = "2025-10-17T06:19:13.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/2b/8f78c5a8cc66486be5f51b6f038fc347c3ba748d3ea68be17a014283c331/crc32c-2.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ecf66cf90266d9c15cea597d5cc86c01917cd1a238dc3c51420c7886fa750d7e", size = 80608, upload-time = "2025-10-17T06:19:14.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/86/fad1a94cdeeeb6b6e2323c87f970186e74bfd6fbfbc247bf5c88ad0873d5/crc32c-2.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:59eee5f3a69ad0793d5fa9cdc9b9d743b0cd50edf7fccc0a3988a821fef0208c", size = 79886, upload-time = "2025-10-17T06:19:15.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/db/1a7cb6757a1e32376fa2dfce00c815ea4ee614a94f9bff8228e37420c183/crc32c-2.8-cp312-cp312-win32.whl", hash = "sha256:a73d03ce3604aa5d7a2698e9057a0eef69f529c46497b27ee1c38158e90ceb76", size = 64896, upload-time = "2025-10-17T06:19:16.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/8e/2024de34399b2e401a37dcb54b224b56c747b0dc46de4966886827b4d370/crc32c-2.8-cp312-cp312-win_amd64.whl", hash = "sha256:56b3b7d015247962cf58186e06d18c3d75a1a63d709d3233509e1c50a2d36aa2", size = 66645, upload-time = "2025-10-17T06:19:17.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/1d/dd926c68eb8aac8b142a1a10b8eb62d95212c1cf81775644373fe7cceac2/crc32c-2.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5833f4071da7ea182c514ba17d1eee8aec3c5be927d798222fbfbbd0f5eea02c", size = 62345, upload-time = "2025-10-17T06:20:09.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/be/803404e5abea2ef2c15042edca04bbb7f625044cca879e47f186b43887c2/crc32c-2.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1dc4da036126ac07b39dd9d03e93e585ec615a2ad28ff12757aef7de175295a8", size = 61229, upload-time = "2025-10-17T06:20:10.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/3a/00cc578cd27ed0b22c9be25cef2c24539d92df9fa80ebd67a3fc5419724c/crc32c-2.8-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:15905fa78344654e241371c47e6ed2411f9eeb2b8095311c68c88eccf541e8b4", size = 64108, upload-time = "2025-10-17T06:20:11.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/bc/0587ef99a1c7629f95dd0c9d4f3d894de383a0df85831eb16c48a6afdae4/crc32c-2.8-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c596f918688821f796434e89b431b1698396c38bf0b56de873621528fe3ecb1e", size = 64815, upload-time = "2025-10-17T06:20:11.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/42/94f2b8b92eae9064fcfb8deef2b971514065bd606231f8857ff8ae02bebd/crc32c-2.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8d23c4fe01b3844cb6e091044bc1cebdef7d16472e058ce12d9fadf10d2614af", size = 66659, upload-time = "2025-10-17T06:20:12.766Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cron-descriptor"
|
||||
version = "1.4.5"
|
||||
@@ -2112,47 +2150,45 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.7"
|
||||
version = "50.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2166,15 +2202,17 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "darabonba-core"
|
||||
version = "1.0.5"
|
||||
version = "1.0.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "alibabacloud-tea" },
|
||||
{ name = "requests" },
|
||||
{ name = "websocket-client" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/83/9321ccdb7a800c2cb97d8fa34bead5f20141f27f804594fd1fd815c4cd07/darabonba_core-1.0.8.tar.gz", hash = "sha256:f1661960b368e342d3d36434be82d264b70a01c49e843921d8a4dacd217376ae", size = 27604, upload-time = "2026-07-13T02:07:34.093Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/66/d3/a7daaee544c904548e665829b51a9fa2572acb82c73ad787a8ff90273002/darabonba_core-1.0.5-py3-none-any.whl", hash = "sha256:671ab8dbc4edc2a8f88013da71646839bb8914f1259efc069353243ef52ea27c", size = 24580, upload-time = "2025-12-12T07:53:59.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/88/38800ca22f39a31fdb75c7b2867c61d3af5e2792cee0b72942a639c88a79/darabonba_core-1.0.8-py3-none-any.whl", hash = "sha256:ac093fdd40f88f2f9dfbbbfd7bc143495a3cb031f35b397c98d24edfa6b69483", size = 30957, upload-time = "2026-07-13T02:07:33.138Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3327,14 +3365,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "httplib2"
|
||||
version = "0.31.2"
|
||||
version = "0.32.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyparsing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/f5/ccf58de92d61e3ad921119668f54ed36ca1d0cf5dcc5c1657dfb164fd78b/httplib2-0.32.0.tar.gz", hash = "sha256:48a0ef30a42db65d8f3399045e1d09ab0ba66e3b9efc360d07f80ea55d286025", size = 254283, upload-time = "2026-06-26T10:13:56.265Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/a0/550eec327e5f5c7b732531c489f5307efec41f047b0d703bd4ca1e5ad2db/httplib2-0.32.0-py3-none-any.whl", hash = "sha256:dc6705cacdf3fb0a2aba7629fa33c90fd93e30035db0c157325826be177e4816", size = 93148, upload-time = "2026-06-26T10:13:54.985Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3357,6 +3395,134 @@ http2 = [
|
||||
{ name = "h2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "huaweicloudsdkcore"
|
||||
version = "3.1.204"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "defusedxml" },
|
||||
{ name = "pyasn1" },
|
||||
{ name = "pymongo" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests-toolbelt" },
|
||||
{ name = "simplejson" },
|
||||
{ name = "six" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/f5/65e90764ea3bbfef50fb68cd5e12340acf1f51e9276b11745fbf5feb7e0e/huaweicloudsdkcore-3.1.204-py3-none-any.whl", hash = "sha256:9ae17744795ebdc8ce9291373a3a27bf72e90aa98677cfce0ea9394376875a95", size = 69578, upload-time = "2026-07-09T09:01:59.715Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "huaweicloudsdkcts"
|
||||
version = "3.1.204"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huaweicloudsdkcore" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/58/32/d06328e35375d4aa606719a27856cdf57b1f7fb0c49d4dfd22a9609dba19/huaweicloudsdkcts-3.1.204-py3-none-any.whl", hash = "sha256:9def561aa784a6ee13b46bfc96888cd1df5bfc42f8a89e60b42c91c608bf6d60", size = 121768, upload-time = "2026-07-09T09:02:08.16Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "huaweicloudsdkecs"
|
||||
version = "3.1.204"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huaweicloudsdkcore" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/88/66/f8e4a3b9ca70d3ea79c4d200f928ed9ffdf4910ac01be4864967408c8f18/huaweicloudsdkecs-3.1.204-py3-none-any.whl", hash = "sha256:dc5715d782c0260b901c793d009d5e632257acb04257b6f2c6631e415c589343", size = 765699, upload-time = "2026-07-09T09:02:39.272Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "huaweicloudsdkelb"
|
||||
version = "3.1.204"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huaweicloudsdkcore" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/4b/9bdc7e2066419d967e9b812cfacfb9c4996a8e977dc39853309a3c1ac9e2/huaweicloudsdkelb-3.1.204-py3-none-any.whl", hash = "sha256:620247c2b2a7f20e7da8b18fe9c64e29972055f015bc35270fb5b43243dc4830", size = 1292397, upload-time = "2026-07-09T09:02:45.656Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "huaweicloudsdkevs"
|
||||
version = "3.1.204"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huaweicloudsdkcore" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/cf/531dc55fd9d0f3bbd3eef24c7e4d78c6a1ba8eb80fa506e3574d72bcc98a/huaweicloudsdkevs-3.1.204-py3-none-any.whl", hash = "sha256:9118ac4c576e54aa7eaa926949e2b6824c5f038a2274b51d9a304d37fc0d7e2f", size = 251404, upload-time = "2026-07-09T09:02:50.05Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "huaweicloudsdkiam"
|
||||
version = "3.1.204"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huaweicloudsdkcore" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/9a/7da0fbe9b83bc7a7f6d586366b81e829ed355a57419d2184b7dd51f8c2a3/huaweicloudsdkiam-3.1.204-py3-none-any.whl", hash = "sha256:0021e204f81ceef2640017e517adb72ba56c9ced03f071a0265b10bc9759badf", size = 1251350, upload-time = "2026-07-09T09:03:05.467Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "huaweicloudsdkkms"
|
||||
version = "3.1.204"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huaweicloudsdkcore" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/28/3a/7392617d585cb2005f7d9ade0b0e0e493a7a88daf56e8dc39e4e219cc5d0/huaweicloudsdkkms-3.1.204-py3-none-any.whl", hash = "sha256:378986f33113ce99f445ef318d1c7dda89e361d16c008e5ef9793981d8385376", size = 275690, upload-time = "2026-07-09T09:03:29.833Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "huaweicloudsdkobs"
|
||||
version = "3.1.204"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huaweicloudsdkcore" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/af/49/28a09e1e33d1c039be22ee4171efaa739351653c7aa88d3a2f7a78d90217/huaweicloudsdkobs-3.1.204-py3-none-any.whl", hash = "sha256:8c5830fa30293185964d98e524887fc510c8e17ca2fadb4563dad10910f37b13", size = 235360, upload-time = "2026-07-09T09:03:52.171Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "huaweicloudsdkrds"
|
||||
version = "3.1.204"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huaweicloudsdkcore" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/7d/721f162c46e3de604a73674223bf6c6bc6cf7ade25b3751a71288f4dd122/huaweicloudsdkrds-3.1.204-py3-none-any.whl", hash = "sha256:a790b5b3c457a608e5679c101f463b4d037dd9a8a66f6e46144a9e5a4b37780f", size = 1626906, upload-time = "2026-07-09T09:04:06.936Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "huaweicloudsdkvpc"
|
||||
version = "3.1.204"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huaweicloudsdkcore" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/b5/4baa27c3a275ea92806068e35e06f249a30add8dd57c777bb45841f63406/huaweicloudsdkvpc-3.1.204-py3-none-any.whl", hash = "sha256:c57d6b6d2f70deca91e86f7956b33fc9ac4991b431f0d608c3632231119f8970", size = 1124332, upload-time = "2026-07-09T09:04:39.797Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "huaweicloudsdkwaf"
|
||||
version = "3.1.204"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huaweicloudsdkcore" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/21/01590dce200be487756451f5e9efb99da7810688062d177e4b58d6062465/huaweicloudsdkwaf-3.1.204-py3-none-any.whl", hash = "sha256:b2355276e0029808f45e2d1bd3eb14b37d2da9417e61e642ffe0e2b748ca8283", size = 1337762, upload-time = "2026-07-09T09:04:43.688Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "humanfriendly"
|
||||
version = "10.0"
|
||||
@@ -3920,21 +4086,21 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "microsoft-kiota-abstractions"
|
||||
version = "1.9.9"
|
||||
version = "1.9.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "std-uritemplate" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/94/37315b82a1bcc08145e5bc2af7396a4be8160ac138ec269611c3b9589b7a/microsoft_kiota_abstractions-1.9.9.tar.gz", hash = "sha256:5df9a8e0517a4568726c2cac6d9789284cc6ffa66043b68eba42ae55749fb861", size = 24468, upload-time = "2026-03-02T21:03:50.133Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/45/e1/39de28380fc0eddf12f66099469fb7561bc38f577ea06e3a074751ebbcd9/microsoft_kiota_abstractions-1.9.10.tar.gz", hash = "sha256:8eb62d64c35ad0eeb4e8bcdbb143c0b308dc4a494e757f8e44cb959d34f44ecf", size = 24473, upload-time = "2026-03-12T17:27:15.398Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/53/6a/7d5a1a8131f0eccc6b45839c091aa00ba29661854e7defaa7936cf342fa7/microsoft_kiota_abstractions-1.9.9-py3-none-any.whl", hash = "sha256:8d0a14eda42f3f0ccac2e9512227a338f69998dc9b782fd21cb8ca7c48302caa", size = 44453, upload-time = "2026-03-02T21:03:51.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/59/bf0cb26c80fbd3fa882df8474ad87e9dbd742656c376388c427c4e314171/microsoft_kiota_abstractions-1.9.10-py3-none-any.whl", hash = "sha256:cd169067ebe48e6feea1258630807034239e0c61c2abe5fd66896a58177e8f05", size = 44462, upload-time = "2026-03-12T17:27:16.532Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "microsoft-kiota-authentication-azure"
|
||||
version = "1.9.9"
|
||||
version = "1.9.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -3943,14 +4109,14 @@ dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/ce/5ae8b37ee4a50f0ed5e092c2d0105d60b592e6102a190959f76658a0994c/microsoft_kiota_authentication_azure-1.9.9.tar.gz", hash = "sha256:aca5e7dc8a0a28224f9025a479349ac2f9aaf166bfd6bc707f232658b45eec28", size = 5000, upload-time = "2026-03-02T21:04:02.355Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d5/53/7760f979c141ec590f0c1cfcb92b3e410eb2909cc19feb42f3fce78db171/microsoft_kiota_authentication_azure-1.9.10.tar.gz", hash = "sha256:b9f10a9fa86e36114abfee448d2dab91a502d6a55d349a306e2e41a1218fe1ad", size = 4999, upload-time = "2026-03-12T17:27:26.323Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/de/dc504324b776d00a420886cc6f39e04be2cf48cab0e9b18f8450a5efcc29/microsoft_kiota_authentication_azure-1.9.9-py3-none-any.whl", hash = "sha256:73dc21a1a2861ea78a135327291db3322e2255542a18b311dd03fd908342e902", size = 6951, upload-time = "2026-03-02T21:04:03.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/4a/e7852f9358d897ada1eec4e825c815761befe36df4defa79f1ae6c7b588c/microsoft_kiota_authentication_azure-1.9.10-py3-none-any.whl", hash = "sha256:b5d98b0d17173c61c0c7ab4274ea4ca69253b3c13424137758034506694964e9", size = 6961, upload-time = "2026-03-12T17:27:27.238Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "microsoft-kiota-http"
|
||||
version = "1.9.9"
|
||||
version = "1.9.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx", extra = ["http2"] },
|
||||
@@ -3958,57 +4124,57 @@ dependencies = [
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/3f/fc18eb0d1d845daf6355fd54fd990af7f7e10043ef6a6da39b9e5981cbaf/microsoft_kiota_http-1.9.9.tar.gz", hash = "sha256:ae672b145df71b644f8da0951767a12a4ce47a40576d86eba19b7c22d9e160f9", size = 21493, upload-time = "2026-03-02T21:04:11.662Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/e5/20972b620bd8cca086c284e97b285d437c108a23fee122ad7b92bd246c1a/microsoft_kiota_http-1.9.10.tar.gz", hash = "sha256:af1838d091f76426c974897357093ed977ce66f1d808cb161c190de873bb5833", size = 21493, upload-time = "2026-03-12T17:27:35.393Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/6a/cc1b1055b4b6d4dfc1be7a71917c2f0ef19c070c6a18b16d3c1032d20925/microsoft_kiota_http-1.9.9-py3-none-any.whl", hash = "sha256:a5b1b217ac9afeb4054f12515417e3b1d2be12a9385a70a41d18d64379ea2e7e", size = 31945, upload-time = "2026-03-02T21:04:12.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/f4/78ce18330a626138b2ff6bb62574adac01e8b9ee87c1349ddfeb9cab0556/microsoft_kiota_http-1.9.10-py3-none-any.whl", hash = "sha256:6127032c8d94f8607e4d36d0822b88bc8689ab368b4c00d6c7beb7d2d0f2ab10", size = 31960, upload-time = "2026-03-12T17:27:36.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "microsoft-kiota-serialization-form"
|
||||
version = "1.9.9"
|
||||
version = "1.9.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "microsoft-kiota-abstractions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/b4/18e9fce60a30c8b6ea0a6278fb81cf352127340d48df2d7c52ff1b579488/microsoft_kiota_serialization_form-1.9.9.tar.gz", hash = "sha256:3cdc8b172baec5b5282af72f2ce02715edcd23252ce0b5af96075256edd75114", size = 9015, upload-time = "2026-03-02T21:04:20.39Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/56/90/7e1a090a2099acae1a1baa9a0762214b73b63d9268369b510994f75f54e4/microsoft_kiota_serialization_form-1.9.10.tar.gz", hash = "sha256:4c6655d8cd479d1ada63fdfe6a272e50d87d7c8369dbc8e13833ba4787fc798b", size = 9012, upload-time = "2026-03-12T17:27:44.214Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/24/eb8436b882f1473bd0a868848d214df3df2d9b3db8e5422d111032f1114f/microsoft_kiota_serialization_form-1.9.9-py3-none-any.whl", hash = "sha256:1c426d4f0d463fc9215c41d7fa0f3dc5fe8d3c80573d555cf63ea67000148d84", size = 10718, upload-time = "2026-03-02T21:04:21.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/4c/5092fc896b34c21e8b9c03c63006b313a81e2377176a69c97aa6a9c8f5bb/microsoft_kiota_serialization_form-1.9.10-py3-none-any.whl", hash = "sha256:765d3f6408668f58bfdf892c32b45967c579d9131f3ba5a6b6868cb7ab956bfe", size = 10728, upload-time = "2026-03-12T17:27:45.103Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "microsoft-kiota-serialization-json"
|
||||
version = "1.9.9"
|
||||
version = "1.9.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "microsoft-kiota-abstractions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b8/2f/d36eba916c00136da122d1701acb862c5b1f2e22b6dc6fa4e0f4abda2786/microsoft_kiota_serialization_json-1.9.9.tar.gz", hash = "sha256:9b27479427f49bbac15ead8e8ff0176e47fcdf81153611acc408f5f399342079", size = 9545, upload-time = "2026-03-02T21:04:29.177Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/0e/55afd533a764ba77da988b7ca4242c84867a3a25f2ff0bf4c2b24b5e8fca/microsoft_kiota_serialization_json-1.9.10.tar.gz", hash = "sha256:6063028f30dd67afa2db20a72d9bde5e5d26d468f8bdedadd1445cf7c7630e17", size = 9746, upload-time = "2026-03-12T17:27:53.015Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/7b/b3f606ef2dcbdebe12ae27004ed6e7542370cb2494265f11a8877a1de2d1/microsoft_kiota_serialization_json-1.9.9-py3-none-any.whl", hash = "sha256:bb80b93e81bab41dc142e9b254f79bf0b7b9fe49a796ca0c8e8691925bd3967f", size = 11210, upload-time = "2026-03-02T21:04:29.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/56/d14c0185c8092abde1a60ad2bdd4480bb2ddb551ce71c6de1e6133a4d8d1/microsoft_kiota_serialization_json-1.9.10-py3-none-any.whl", hash = "sha256:0545ae910160b19caaa8c30c90c7416e1966294fbd6cc5af01f0e116a18f223a", size = 11452, upload-time = "2026-03-12T17:27:53.909Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "microsoft-kiota-serialization-multipart"
|
||||
version = "1.9.9"
|
||||
version = "1.9.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "microsoft-kiota-abstractions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/44/24087f0fac7c5682c13c7fb61468a0c5a5185b9f243de3a99309aa6fcaa7/microsoft_kiota_serialization_multipart-1.9.9.tar.gz", hash = "sha256:f8730be6da5f6c63a6bf4ea310a9723b9998a47a04745887dc156d08f119a829", size = 5162, upload-time = "2026-03-02T21:04:48.1Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/34/eadc15c2a3131e2a76126f3112c32b73502cb5a335e2e40cac2877e5d843/microsoft_kiota_serialization_multipart-1.9.10.tar.gz", hash = "sha256:8f2da4f93e79b09f9738b6889685e47acfafcca870db94ab1d4cd233d69e4268", size = 5167, upload-time = "2026-03-12T17:28:18.507Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/db/6b988fdf771c3d07dff4a116176d575832daf2a43823444d145d71da5b61/microsoft_kiota_serialization_multipart-1.9.9-py3-none-any.whl", hash = "sha256:572e9cbafa2eb946452cdadfb019a4e9245768c0d61c3089d3436d4f5106c550", size = 6696, upload-time = "2026-03-02T21:04:48.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/40/345cbcee6c52b4261fedf4ae2ff8573aec47ce4ae2015ea8b57c75ef978b/microsoft_kiota_serialization_multipart-1.9.10-py3-none-any.whl", hash = "sha256:7cadc26483b567c738f926b044521569e0b797446053c9e8eab02269d4a81062", size = 6708, upload-time = "2026-03-12T17:28:19.397Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "microsoft-kiota-serialization-text"
|
||||
version = "1.9.9"
|
||||
version = "1.9.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "microsoft-kiota-abstractions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/3c/d244ad08e03003134871698aa54de8243bcc61c0faf3ab114293bb76d6ad/microsoft_kiota_serialization_text-1.9.9.tar.gz", hash = "sha256:18bc0764dda4078a4c953300253344e05d0cdb9c17136f1a2f695d438cedb402", size = 7325, upload-time = "2026-03-02T21:04:37.567Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/a6/28a4a8d5c01f08e363135fc9585cab3c02d1b1a69c3c16032e6abb35dfed/microsoft_kiota_serialization_text-1.9.10.tar.gz", hash = "sha256:cfc433c2a95ea3c3ec43c8b09002fbf65c998c5c0571205df161fe0e9d5d8de7", size = 7326, upload-time = "2026-03-12T17:28:01.621Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/37/f8/43f8d00fed6e090810d3ce0c05e06c23eaa5dee6e87ab1fb89d96ca9559f/microsoft_kiota_serialization_text-1.9.9-py3-none-any.whl", hash = "sha256:84418119d4929a76fde7f31e957e240e003bf145757838b9aa3a0f36dec1b789", size = 8885, upload-time = "2026-03-02T21:04:38.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/bf/dd36e4a6d1cff3f2d30f03e2479cd38210e32d4715bb6a9f0e2737f13604/microsoft_kiota_serialization_text-1.9.10-py3-none-any.whl", hash = "sha256:742890cfd4450d12f58d42da7cfa474fe1ee5d6442e016bf70ab76e5c876c0ea", size = 8896, upload-time = "2026-03-12T17:28:02.328Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4260,20 +4426,22 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "oci"
|
||||
version = "2.169.0"
|
||||
version = "2.183.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "circuitbreaker" },
|
||||
{ name = "crc32c" },
|
||||
{ name = "cryptography" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "pyopenssl" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pytz" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/f4/3c2eddccc75dd06a692dbb3290f20f4bc733d99dc60de21f22d65efdeae4/oci-2.169.0.tar.gz", hash = "sha256:f3c5fff00b01783b5325ea7b13bf140053ec1e9f41da20bfb9c8a349ee7662fa", size = 16885837, upload-time = "2026-03-31T06:14:58.981Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/2a/77bd6cbf1c69b2f368fe3d6462d84369b0cba15e37ce713cdc08d459b95a/oci-2.183.0.tar.gz", hash = "sha256:ff572ef5f2030a788796bb509d257e6a41c6510ef9b4b6a75a079efd06e533ce", size = 17759723, upload-time = "2026-07-28T06:02:29.76Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/bf/19643bd939ab595193779ee25c2c12aef8e9a54e0a68de5ed79f209702e3/oci-2.169.0-py3-none-any.whl", hash = "sha256:c71bb5143f307791082b3e33cc1545c2490a518cfed85ab1948ef5107c36d30b", size = 34460447, upload-time = "2026-03-31T06:14:51.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/de/8574b3e527996a099d196e87794a4652d91a0c3185fcc7fdbb5649b75a8a/oci-2.183.0-py3-none-any.whl", hash = "sha256:bd789c98a94d7c5ea08c20d11dcf68c9cd1ad479b134727d80a930b84387070b", size = 36133501, upload-time = "2026-07-28T06:02:18.239Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4467,39 +4635,33 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
version = "12.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4673,8 +4835,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.32.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#5dac8a0a53272e4db68c476fb969dc03e88beb68" }
|
||||
version = "5.37.1"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.37#fbbf9fe1cdfbac1869b950a078ac36098dbb4ddc" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4729,6 +4891,17 @@ dependencies = [
|
||||
{ name = "google-api-python-client" },
|
||||
{ name = "google-auth-httplib2" },
|
||||
{ name = "h2" },
|
||||
{ name = "huaweicloudsdkcore" },
|
||||
{ name = "huaweicloudsdkcts" },
|
||||
{ name = "huaweicloudsdkecs" },
|
||||
{ name = "huaweicloudsdkelb" },
|
||||
{ name = "huaweicloudsdkevs" },
|
||||
{ name = "huaweicloudsdkiam" },
|
||||
{ name = "huaweicloudsdkkms" },
|
||||
{ name = "huaweicloudsdkobs" },
|
||||
{ name = "huaweicloudsdkrds" },
|
||||
{ name = "huaweicloudsdkvpc" },
|
||||
{ name = "huaweicloudsdkwaf" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "kingfisher-bin" },
|
||||
{ name = "kubernetes" },
|
||||
@@ -4762,7 +4935,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.36.0"
|
||||
version = "1.38.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -4862,7 +5035,7 @@ requires-dist = [
|
||||
{ name = "matplotlib", specifier = "==3.10.8" },
|
||||
{ name = "neo4j", specifier = "==6.1.0" },
|
||||
{ name = "openai", specifier = "==1.109.1" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.37" },
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.9" },
|
||||
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
|
||||
{ name = "reportlab", specifier = "==4.4.10" },
|
||||
@@ -4978,25 +5151,24 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "py-ocsf-models"
|
||||
version = "0.8.1"
|
||||
version = "0.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "email-validator" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/70/61e2f9ce3d7e83aa5339ed6ae17e473c15c7a36f161c6dbea0e939e3af0c/py_ocsf_models-0.8.1.tar.gz", hash = "sha256:c9045237857f951e073c9f9d1f57954c90d86875b469260725292d47f7a7d73c", size = 36540, upload-time = "2026-02-12T16:50:15.233Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/70/d6/f0787cbe953e3cf6ef4430f3cc7d66cbbaabe4b20cb82cc27cc2d21e622a/py_ocsf_models-0.10.0.tar.gz", hash = "sha256:29abaa5a3d4ebba0e2a21757508a4848fa5e1d57da233af57e580f97f0223c59", size = 36498, upload-time = "2026-07-13T07:05:44.448Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/18/63790884bf33f820e2c60f8d5038b5d6de967a03343ddf237c054e1d6d08/py_ocsf_models-0.8.1-py3-none-any.whl", hash = "sha256:061eb446c4171534c09a8b37f5a9d2a2fe9f87c5db32edbd1182446bc5fd097e", size = 64354, upload-time = "2026-02-12T16:50:12.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/56/eca45ec87a02f930cc7eaa7cb36660f69fb00c3d77bb4a84bb92d6c94c25/py_ocsf_models-0.10.0-py3-none-any.whl", hash = "sha256:a9d1e245b1c9fba1d2cb8c042253ef1b83a2dbfec30ed69975bbce599b4510bb", size = 64334, upload-time = "2026-07-13T07:05:42.93Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5187,6 +5359,37 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/ff/7f52c1461d8ceaefa989d2700a027f84427879bb7571145bbffdec5d5f4a/pylint-3.2.5-py3-none-any.whl", hash = "sha256:32cd6c042b5004b8e857d727708720c54a676d1e22917cf1a2df9b4d4868abd6", size = 519603, upload-time = "2024-06-28T13:10:23.526Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pymongo"
|
||||
version = "4.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dnspython" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/c0c6732fbd358b75a07e17d7e588fd23d481b9812ca96ceeff90bbf879fc/pymongo-4.15.1.tar.gz", hash = "sha256:b9f379a4333dc3779a6bf7adfd077d4387404ed1561472743486a9c58286f705", size = 2470613, upload-time = "2025-09-16T16:39:47.24Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/da/89066930a70b4299844f1155fc23baaa7e30e77c8a0cbf62a2ae06ee34a5/pymongo-4.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:363445cc0e899b9e55ac9904a868c8a16a6c81f71c48dbadfd78c98e0b54de27", size = 865410, upload-time = "2025-09-16T16:38:16.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/8f/a1d0402d52e5ebd14283718abefdc0c16f308cf10bee56cdff04b1f5119b/pymongo-4.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:da0a13f345f4b101776dbab92cec66f0b75015df0b007b47bd73bfd0305cc56a", size = 865695, upload-time = "2025-09-16T16:38:18.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/38/d1ef69028923f86fd00638d9eb16400d4e60a89eabd2011fe631fd3186cf/pymongo-4.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9481a492851e432122a83755d4e69c06aeb087bbf8370bac9f96d112ac1303fd", size = 1434758, upload-time = "2025-09-16T16:38:20.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/eb/a8d5dff748a2dd333610b2e4c8120b623e38ea2b5e30ad190d0ce2803840/pymongo-4.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:625dec3e9cd7c3d336285a20728c01bfc56d37230a99ec537a6a8625af783a43", size = 1485716, upload-time = "2025-09-16T16:38:21.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/d4/17ba457a828b733182ddc01a202872fef3006eed6b54450b20dc95a2f77d/pymongo-4.15.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26a31af455bffcc64537a7f67e2f84833a57855a82d05a085a1030c471138990", size = 1460160, upload-time = "2025-09-16T16:38:23.509Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/25/42b8662c09f5ca9c81d18d160f48e58842e0fa4c314ea02613c5e5d54542/pymongo-4.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea4415970d2a074d5890696af10e174d84cb735f1fa7673020c7538431e1cb6e", size = 1439284, upload-time = "2025-09-16T16:38:25.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/bb/46b9d978161828eb91973bd441a3f05f73c789203e976332a8de2832d5db/pymongo-4.15.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51ee050a2e026e2b224d2ed382830194be20a81c78e1ef98f467e469071df3ac", size = 1407933, upload-time = "2025-09-16T16:38:27.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/55/bd5af98f675001f4b06f7314b3918e45809424a7ad3510f823f6703cd8f2/pymongo-4.15.1-cp311-cp311-win32.whl", hash = "sha256:9aef07d33839f6429dc24f2ef36e4ec906979cb4f628c57a1c2676cc66625711", size = 844328, upload-time = "2025-09-16T16:38:28.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/78/90989a290dd458ed43a8a04fa561ac9c7b3391f395cdacd42e21f0f22ce4/pymongo-4.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ea6e5ff4d6747e7b64966629a964db3089e9c1e0206d8f9cc8720c90f5a7af1", size = 858951, upload-time = "2025-09-16T16:38:30.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/bb/d4d23f06e166cd773f2324cff73841a62d78a1ad16fb799cf7c5490ce32c/pymongo-4.15.1-cp311-cp311-win_arm64.whl", hash = "sha256:bb783d9001b464a6ef3ee76c30ebbb6f977caee7bbc3a9bb1bd2ff596e818c46", size = 848290, upload-time = "2025-09-16T16:38:31.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/31/bc4525312083706a59fffe6e8de868054472308230fdee8db0c452c2b831/pymongo-4.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bab357c5ff36ba2340dfc94f3338ef399032089d35c3d257ce0c48630b7848b2", size = 920261, upload-time = "2025-09-16T16:38:33.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/55/4d99aec625494f21151b8b31e12e06b8ccd3b9dcff609b0dd1acf9bbbc0e/pymongo-4.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46d1af3eb2c274f07815372b5a68f99ecd48750e8ab54d5c3ff36a280fb41c8e", size = 919956, upload-time = "2025-09-16T16:38:35.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/60/8f1afa41521df950e13f6490ecdef48155fc63b78f926e7649045e07afd1/pymongo-4.15.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7dc31357379318881186213dc5fc49b62601c955504f65c8e72032b5048950a1", size = 1698596, upload-time = "2025-09-16T16:38:36.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/3f/e48d50ee8d6aa0a4cda7889dd73076ec2ab79a232716a5eb0b9df070ffcf/pymongo-4.15.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12140d29da1ecbaefee2a9e65433ef15d6c2c38f97bc6dab0ff246a96f9d20cd", size = 1762833, upload-time = "2025-09-16T16:38:38.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/87/db976859efc617f608754e051e1468459d9a818fe1ad5d0862e8af57720b/pymongo-4.15.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf193d2dcd91fa1d1dfa1fd036a3b54f792915a4842d323c0548d23d30461b59", size = 1731875, upload-time = "2025-09-16T16:38:39.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/59/3643ad52a5064ad3ef8c32910de6da28eb658234c25f2db5366f16bffbfb/pymongo-4.15.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2c0bdcf4d57e4861ed323ba430b585ad98c010a83e46cb8aa3b29c248a82be1", size = 1701853, upload-time = "2025-09-16T16:38:41.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/96/441c190823f855fc6445ea574b39dca41156acf723c5e6a69ee718421700/pymongo-4.15.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43fcfc19446e0706bbfe86f683a477d1e699b02369dd9c114ec17c7182d1fe2b", size = 1660978, upload-time = "2025-09-16T16:38:42.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/49/bd7e783fb78aaf9bdaa3f88cc238449be5bc5546e930ec98845ef235f809/pymongo-4.15.1-cp312-cp312-win32.whl", hash = "sha256:e5fedea0e7b3747da836cd5f88b0fa3e2ec5a394371f9b6a6b15927cfeb5455d", size = 891175, upload-time = "2025-09-16T16:38:44.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/28/7de5858bdeaa07ea4b277f9eb06123ea358003659fe55e72e4e7c898b321/pymongo-4.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:330a17c1c89e2c3bf03ed391108f928d5881298c17692199d3e0cdf097a20082", size = 910619, upload-time = "2025-09-16T16:38:46.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/87/c39f4f8415e7c65f8b66413f53a9272211ff7dfe78a5128b27027bf88864/pymongo-4.15.1-cp312-cp312-win_arm64.whl", hash = "sha256:756b7a2a80ec3dd5b89cd62e9d13c573afd456452a53d05663e8ad0c5ff6632b", size = 896229, upload-time = "2025-09-16T16:38:48.563Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pymsalruntime"
|
||||
version = "0.18.1"
|
||||
@@ -5223,15 +5426,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyopenssl"
|
||||
version = "26.0.0"
|
||||
version = "26.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5556,6 +5759,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests-toolbelt"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requestsexceptions"
|
||||
version = "1.4.0"
|
||||
@@ -5774,6 +5989,37 @@ dependencies = [
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c5/06/c6dcc975a1e7d89bc764fd271da8138b318e18080b48e7f1acd2ab63df28/shodan-1.31.0.tar.gz", hash = "sha256:c73275386ea02390e196c35c660706a28dd4d537c5a21eb387ab6236fac251f6", size = 57939, upload-time = "2023-12-17T01:42:02.426Z" }
|
||||
|
||||
[[package]]
|
||||
name = "simplejson"
|
||||
version = "4.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/2a/54837395a3487c725669428d513293612a48d82b95a0642c936932e5d898/simplejson-4.1.1.tar.gz", hash = "sha256:c08eb9f7a90f77ae470e19a07472e9a79ebc0d1c2315d86a72767665bd5ba79f", size = 118860, upload-time = "2026-04-24T19:24:59.819Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/25/39013ffe279d90093ec1c848565b3683c586906c10fa55d9000ec29d046b/simplejson-4.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2867c64d92abd1992c15666fae198203093f593e43d6b81adf176bae530d493a", size = 111538, upload-time = "2026-04-24T19:22:49.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/ae/2c272971c8a87e2539c54a98eb6ff037bee1e2e93943c3986cf7500a4f3a/simplejson-4.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c47c46e16c8ea9e4850061e6ed5aa2b9cd2074cb2274bfd9c138cba15ce7453", size = 90594, upload-time = "2026-04-24T19:22:50.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/a2/6eebfb99dedc139f549200f61ade6d1890ac5707c5d427bdfa6fe39c9313/simplejson-4.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e294e33dbf316a9bbdd4030d46503c9b0f19470ae7ad6af5bae6c426bc2e869f", size = 90718, upload-time = "2026-04-24T19:22:51.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/7e/c9e6c0c4ad8415e64dad0c47f619b556b02680a41631b4dbc281d55dc54d/simplejson-4.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7ce252b28fddbdd83db5bd7d93dad2a8a591d7ada098afec9c1b23d6b722a7a4", size = 180901, upload-time = "2026-04-24T19:22:53.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/09/69e331e3994b1ed9be6ce9ace4ade704e7ed503edf869929ca7bb404eda8/simplejson-4.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c44ef6b02a4eb67ed17a72342341792149b3ff46f15426c26e970e49addf327", size = 178133, upload-time = "2026-04-24T19:22:54.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/40/ed806f24afef295c1032448f5ff6f6f2979392d5645ddb9f4fed7f38194d/simplejson-4.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82bfca2b85a34178c25829c703f0a9e9f113a5af7539285bd3efb583a0bf1ba3", size = 188155, upload-time = "2026-04-24T19:22:56.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/94/8d6f515b827b0f7881a49c8c1ac6920b7ae9428939ef04238c973278b42a/simplejson-4.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0e4b23f71dd781f8830f1663dc01a4944d3dbf87a1f93d78fba1cf64722d0ccf", size = 176225, upload-time = "2026-04-24T19:22:57.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/fd/6dffb4956563d48bbe46b91ff341adae34920e94008fd6b8d728072abfc7/simplejson-4.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:82fee635d7b73ad801030b05a75fbd34a098da0c2ecf600667a03636d09e1e42", size = 185535, upload-time = "2026-04-24T19:22:59.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/d2/a509ee37763e79aec75d68f8521db1440306edeba3b8b4064ab4ee8bf1d9/simplejson-4.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:68e62eda21192c5ea9bb92d571ca46a4477fef48762f50d433de2b4253051551", size = 179302, upload-time = "2026-04-24T19:23:01.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/23/5b343bfd2a79d3b6818e4db3586c405a001a090d4c89d336e31273ce7177/simplejson-4.1.1-cp311-cp311-win32.whl", hash = "sha256:ffd3d82294b47f5ec64050021ace95fd62628a0c1cc8bbf4d06d2d1fb697e055", size = 88408, upload-time = "2026-04-24T19:23:02.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/04/df9b37aedbd524dca20840d25ebe01d6ae486b89792aeff5d15b9c4114f7/simplejson-4.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:78a3fe0995be42bed62a26aa78e0e0b4d87c6545785346b9cc898f3389569a35", size = 90526, upload-time = "2026-04-24T19:23:04.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/25/e90998fe8e480eb43b966c09e835379887d427567ebd496563d3b1e16b19/simplejson-4.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:19040a17154dc03d289bab68d73ce0a6a0be01de30c584bbdd93490bead14b22", size = 112414, upload-time = "2026-04-24T19:23:06.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/a0/abd4785f36c3400f1fbb21f517be39295a750a714f04b7ee175adf6ef580/simplejson-4.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a94ebaecdbaa80d9551a3ec6bf0c9302fc8b53ab6c1b2bfd498a1df4cb28158d", size = 91120, upload-time = "2026-04-24T19:23:07.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/78/fc060d2e3b13c6ec59288574b8efac64075e316b2afba4396a56b2422f78/simplejson-4.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:67341c95c0a168ab4a6d1e807e50463f1c8da932c3286d81e201266c427061fa", size = 91055, upload-time = "2026-04-24T19:23:09.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b6/156a8de1e1b47694f0e7de6675866936608d45dc68388fd017d36f8693be/simplejson-4.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45ec18e337fec538b7e902d489505c450b2454653d1290f3f50385e6fd8aa607", size = 190297, upload-time = "2026-04-24T19:23:11.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/1c/e4d0eab695be3eb21d0f46bce820752031f03e7113f9c80a9b3c73ee7157/simplejson-4.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:820c69a4710400e9b248d5670647d60be58824369282d3925e516b3ff1a7cd82", size = 187002, upload-time = "2026-04-24T19:23:12.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/0e/7f5a59d29426b062d5928fb88b403c3f797129d53be7102f955dbe51aa44/simplejson-4.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e708d373a10e4378ef2d59f8361850c7150fd907ed49efe49bc5492160476d1", size = 195146, upload-time = "2026-04-24T19:23:14.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/18/9943db224dd4d5fa3c090c3e56a94c37b254338c83995ec5680285111c40/simplejson-4.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:980fc33353f81fd12d8c49d44f8c2760d1dc8192285e627c5180d141035b228a", size = 183931, upload-time = "2026-04-24T19:23:16.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/08/9a690da9a766161c06c627d805362cf159f1abe480969372b2897649b955/simplejson-4.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:de2ed102fff88dacf543699f53ee3a533cc11539a39baa176b7e09dd783069d6", size = 192228, upload-time = "2026-04-24T19:23:18.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/88/bd8aad36b451ffb0e0a3f721d695a88befa6d1ac7d1e02ae788ca7ff4029/simplejson-4.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785ff8edc0e28bf773a32543a6bbed46351453c997b3f6709c744e3c2f7eabb", size = 187808, upload-time = "2026-04-24T19:23:21.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/ee/14f91db0d1f481533b651dafbf8cd0da088d9817f7af30c68f7f19f9c847/simplejson-4.1.1-cp312-cp312-win32.whl", hash = "sha256:2e0d5ead6d14610467ec356ec1f6b5d8a56aa216abaad8d41c8b873b16cf313f", size = 88512, upload-time = "2026-04-24T19:23:22.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/c4/90de06b2d8737c68c05ff9274113f854dbf6a5f28b7a955212111672cb57/simplejson-4.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:63a5451f557d6be48a231bae932458655c620902b868170b2f1c8afed496f6b4", size = 90748, upload-time = "2026-04-24T19:23:24.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/6a/8b74c52ffd33dbbde00fe7251fee6a0acdc8cea33f7a43805aed258fb79b/simplejson-4.1.1-py3-none-any.whl", hash = "sha256:2ce92b3748f02423e26d2bfb636fb9d7a8f67c8f5854dcae69d350d123b2eee2", size = 69195, upload-time = "2026-04-24T19:24:57.962Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
@@ -6203,16 +6449,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "workos"
|
||||
version = "6.0.8"
|
||||
version = "8.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/0d/0a7f78912657f99412c788932ea1f3f4089916e77bdef7d2463842febe08/workos-6.0.8.tar.gz", hash = "sha256:43aa3f1992a0a4ca8933d9b6e5ada846dd3b1fe0ee10e64c876ee2000fc6090d", size = 178137, upload-time = "2026-04-24T18:48:03.203Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cd/f6/bb27fe77e70b5e2c5da72500ca0ece8b0e8318010fec92c31d68483314e3/workos-8.3.0.tar.gz", hash = "sha256:07b66c2fb287adb593e4d77a2e6cb05b48bd8ff0b2722f343d18eeb5e14f7472", size = 201587, upload-time = "2026-06-30T15:19:22.834Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/3f/3d96da80d650b2f97d58af626053354584f619dbb769051e118bd9cd1ca5/workos-6.0.8-py3-none-any.whl", hash = "sha256:a00dd4930333aded2babbba824f8032eea05c5ca8c44d04a3fa068cf6be6e21a", size = 524505, upload-time = "2026-04-24T18:48:01.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/ed/7e6fe07c5bc0222fd92c1cf1f3c4c24293e4e5fc7bb5d7df90e4ee61c17f/workos-8.3.0-py3-none-any.whl", hash = "sha256:d0fa842b93bfc5fb33bf49e69cf8c379936cf54b87c6e2f50bcc6dd2e84f8fe4", size = 592275, upload-time = "2026-06-30T15:19:21.333Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"title": "Prowler API key",
|
||||
"description": "API key token used to authenticate with Prowler Cloud / Prowler App via the Prowler MCP server. Create one at https://cloud.prowler.com.",
|
||||
"description": "API key token used to authenticate with Prowler (Prowler Cloud, Prowler Private Cloud, or Prowler Local Server) via the Prowler MCP server. Create one at https://cloud.prowler.com.",
|
||||
"sensitive": true,
|
||||
"required": true
|
||||
}
|
||||
|
||||
@@ -38,12 +38,12 @@ If the framework is not supported, tell the user, suggest they request it or con
|
||||
|
||||
### 1.1 Connect to Prowler Cloud
|
||||
|
||||
Verify the Prowler MCP connection by calling `prowler_app_search_providers` — a successful response returns the list of providers. If the call fails, walk the user through troubleshooting: internet connectivity, Prowler Cloud credentials, and permissions on the Prowler Cloud account.
|
||||
Verify the Prowler MCP connection by calling `prowler_search_providers` — a successful response returns the list of providers. If the call fails, walk the user through troubleshooting: internet connectivity, Prowler Cloud credentials, and permissions on the Prowler Cloud account.
|
||||
For getting accurate information about configurations use `prowler_docs_search` to pull relevant instructions from the Prowler documentation.
|
||||
|
||||
### 1.2 Verify the provider is configured (or configure it)
|
||||
|
||||
Call `prowler_app_search_providers` to check whether the target provider (AWS account, Azure Subscription, GitHub Account...) exists in the user's Prowler Cloud account. Handle the result based on what's found:
|
||||
Call `prowler_search_providers` to check whether the target provider (AWS account, Azure Subscription, GitHub Account...) exists in the user's Prowler Cloud account. Handle the result based on what's found:
|
||||
|
||||
- **Provider not present.** Guide the user through adding and configuring it. Retrieve the relevant connection, credential, and permission instructions with `prowler_docs_search`.
|
||||
- **Provider present but misconfigured** (missing credentials, insufficient permissions, etc.). Walk the user through fixing the configuration, pulling the relevant guidance with `prowler_docs_search`.
|
||||
@@ -57,15 +57,15 @@ Call `prowler_app_search_providers` to check whether the target provider (AWS ac
|
||||
|
||||
The flow needs at least one completed scan with a compliance report available.
|
||||
|
||||
Look for a completed scan first: call `prowler_app_list_scans` with the selected `provider_id` and `state: ["completed"]`, then call `prowler_app_get_compliance_overview` with each `scan_id` to find one whose compliance report is available. If one is found, continue to the next section.
|
||||
Look for a completed scan first: call `prowler_list_scans` with the selected `provider_id` and `state: ["completed"]`, then call `prowler_get_compliance_overview` with each `scan_id` to find one whose compliance report is available. If one is found, continue to the next section.
|
||||
|
||||
If no completed scan has a report, call `prowler_app_list_scans` again with `state: ["available", "executing"]` to detect a scan in progress.
|
||||
If no completed scan has a report, call `prowler_list_scans` again with `state: ["available", "executing"]` to detect a scan in progress.
|
||||
|
||||
> **Checkpoint — Scan-in-progress decision** *(conditional: an in-progress scan was detected)*
|
||||
>
|
||||
> Tell the user a scan is already running and ask whether to wait for it to complete or start a fresh one. Wait for the answer.
|
||||
|
||||
If no scan is running (or the user chose to start a fresh one), trigger a new scan with `prowler_app_trigger_scan` and the `provider_id`. The link `https://cloud.prowler.com/scans?filter%5Bprovider_uid__in%5D={provider_id}` lets the user monitor progress.
|
||||
If no scan is running (or the user chose to start a fresh one), trigger a new scan with `prowler_trigger_scan` and the `provider_id`. The link `https://cloud.prowler.com/scans?filter%5Bprovider_uid__in%5D={provider_id}` lets the user monitor progress.
|
||||
|
||||
When a scan is in progress (either pre-existing and elected to wait, or just triggered), stop the flow and ask the user to return when it's completed — restart this section to re-check the results.
|
||||
|
||||
@@ -85,7 +85,7 @@ Status taxonomy for failed requirements and their findings:
|
||||
|
||||
### Report template
|
||||
|
||||
A fresh report is rendered like this (substituting values from the `prowler_app_get_compliance_framework_state_details` Prowler MCP tool response):
|
||||
A fresh report is rendered like this (substituting values from the `prowler_get_compliance_framework_state_details` Prowler MCP tool response):
|
||||
|
||||
````markdown
|
||||
# Compliance report: <compliance_id>
|
||||
@@ -120,7 +120,7 @@ A fresh report is rendered like this (substituting values from the `prowler_app_
|
||||
|
||||
Resolve the report path for the current `compliance_id` and provider account.
|
||||
|
||||
If the file does not exist, call `prowler_app_get_compliance_framework_state_details` for the target scan, render the template above, and write the file with one initialization entry in the activity log.
|
||||
If the file does not exist, call `prowler_get_compliance_framework_state_details` for the target scan, render the template above, and write the file with one initialization entry in the activity log.
|
||||
|
||||
If the file exists, read it and compare its `Scan ID` to the target scan from section 1.3. When the scan matches, reuse the file and summarize remaining `[FAIL]` and `[IN PROGRESS]` items in chat.
|
||||
|
||||
@@ -128,7 +128,7 @@ If the file exists, read it and compare its `Scan ID` to the target scan from se
|
||||
>
|
||||
> Tell the user the report on disk was generated from a different scan and ask whether to refresh it from the new scan. Wait for the answer.
|
||||
|
||||
On confirmation, regenerate the failed-requirements section from the new `prowler_app_get_compliance_framework_state_details` response, carry forward the **Global remediation approach** block and the full activity log, and append an activity-log entry noting the scan change.
|
||||
On confirmation, regenerate the failed-requirements section from the new `prowler_get_compliance_framework_state_details` response, carry forward the **Global remediation approach** block and the full activity log, and append an activity-log entry noting the scan change.
|
||||
|
||||
Once the file is current, surface the top failing requirements in chat: sort by finding count descending, show the top 5 with their codes and counts, and point to the file path for the full list.
|
||||
|
||||
@@ -174,7 +174,7 @@ Once approved, the loop proceeds through the batch without further prompts unles
|
||||
|
||||
Pick the first `[FAIL]` requirement at the top of the failed-requirements section. Move its status and every finding under it to `[IN PROGRESS]`, and add a `**Fix plan**:` sub-bullet describing what will be done.
|
||||
|
||||
Call `prowler_app_get_finding_details` for each `finding_id` to retrieve the failing resource and the Prowler Hub's remediation guidance for that check using the tool `prowler_hub_get_check_details` with the `check_id` from the finding details. Summarize the guidance in chat, and append it to the `**Fix plan**` note for each finding.
|
||||
Call `prowler_get_finding_details` for each `finding_id` to retrieve the failing resource and the Prowler Hub's remediation guidance for that check using the tool `prowler_hub_get_check_details` with the `check_id` from the finding details. Summarize the guidance in chat, and append it to the `**Fix plan**` note for each finding.
|
||||
|
||||
If a finding does not apply to the target resource (Organization-only check on a User account, paid-tier feature, missing resource type, etc.), set the requirement status to `[SKIPPED]` with the reason, log it in the activity log, and move on without attempting the fix — even if it was missed during §3.2.
|
||||
|
||||
@@ -194,6 +194,6 @@ Move to the next `[FAIL]` requirement and repeat from section 3.3.
|
||||
|
||||
> **Checkpoint — Rescan trigger** *(conditional: no `[FAIL]` requirements remain; all are `[FIXED-UNVERIFIED]` or `[SKIPPED]`)*
|
||||
>
|
||||
> Summarize what was applied, list any `[SKIPPED]` items with reasons, and ask whether to trigger a fresh scan with `prowler_app_trigger_scan` to verify the fixes end-to-end. Wait for the answer.
|
||||
> Summarize what was applied, list any `[SKIPPED]` items with reasons, and ask whether to trigger a fresh scan with `prowler_trigger_scan` to verify the fixes end-to-end. Wait for the answer.
|
||||
|
||||
On confirmation, trigger the rescan. When it completes, restart section 2.1 with the carry-forward path — requirements no longer in the new FAIL list move to `[PASS]`, anything still failing reverts to `[FAIL]` with the previous fix attempt visible in the activity log.
|
||||
|
||||
@@ -7,5 +7,11 @@ component_management:
|
||||
paths:
|
||||
- "api/**"
|
||||
|
||||
flags:
|
||||
api:
|
||||
paths:
|
||||
- "api/**"
|
||||
carryforward: true
|
||||
|
||||
comment:
|
||||
layout: "header, diff, flags, components"
|
||||
|
||||
@@ -18,15 +18,12 @@ spec:
|
||||
triggers:
|
||||
- type: {{ .Values.worker.keda.triggerType }}
|
||||
metadata:
|
||||
userName: "postgres"
|
||||
passwordFromEnv: POSTGRES_ADMIN_PASSWORD
|
||||
host: {{ .Release.Name }}-postgresql
|
||||
port: {{ .Values.postgresql.port | quote }}
|
||||
dbName: {{ .Values.postgresql.auth.database | quote }}
|
||||
sslmode: disable
|
||||
# Query for KEDA to count the number of scans that are in executing, available, or scheduled states,
|
||||
# where the scheduled time is within the last 2 hours and is before NOW(). Used for scaling workers.
|
||||
query: >-
|
||||
SELECT COUNT(*) FROM scans WHERE ((state='executing' OR state='available' OR state='scheduled') and scheduled_at < NOW() and scheduled_at > NOW() - INTERVAL '2 hours')
|
||||
targetQueryValue: "1"
|
||||
userName: {{ .Values.worker.keda.postgresql.userName | quote }}
|
||||
passwordFromEnv: {{ .Values.worker.keda.postgresql.passwordFromEnv | quote }}
|
||||
host: {{ .Values.worker.keda.postgresql.host | default (printf "%s-postgresql.%s.svc.cluster.local" .Release.Name .Release.Namespace) | quote }}
|
||||
port: {{ .Values.worker.keda.postgresql.port | quote }}
|
||||
dbName: {{ .Values.worker.keda.postgresql.database | default .Values.postgresql.auth.database | quote }}
|
||||
sslmode: {{ .Values.worker.keda.postgresql.sslmode | quote }}
|
||||
query: {{ .Values.worker.keda.query | quote }}
|
||||
targetQueryValue: {{ .Values.worker.keda.targetQueryValue | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -189,6 +189,11 @@ api:
|
||||
DJANGO_STALE_WHILE_REVALIDATE: "60"
|
||||
DJANGO_MANAGE_DB_PARTITIONS: "True"
|
||||
DJANGO_BROKER_VISIBILITY_TIMEOUT: "86400"
|
||||
# Caps the Celery prefork pool size on the worker pods. Without it, Celery
|
||||
# sizes the pool from the number of visible CPUs, so on large nodes the
|
||||
# worker spawns one child per CPU, each loading the full Prowler SDK, and
|
||||
# OOMKills under memory pressure. Raise it on bigger workers.
|
||||
DJANGO_CELERY_WORKER_CONCURRENCY: "2"
|
||||
|
||||
# Secret names to be used as env vars for api, worker, and worker_beat.
|
||||
secrets: []
|
||||
@@ -422,10 +427,61 @@ worker:
|
||||
pollingInterval: 30
|
||||
# -- The cooldown period in seconds for scaling
|
||||
cooldownPeriod: 120
|
||||
# -- The trigger type for scaling (cpu or memory)
|
||||
# -- The KEDA scaler type. Only `postgresql` is supported by the default query below.
|
||||
triggerType: "postgresql"
|
||||
# -- The target utilization percentage for the worker pods
|
||||
value: "50"
|
||||
# PostgreSQL connection used by the scaler query. The KEDA operator opens this
|
||||
# connection from its own namespace, so `host` must resolve from there. The
|
||||
# defaults target the bundled postgresql subchart; set them explicitly when
|
||||
# using an external database (postgresql.enabled: false).
|
||||
postgresql:
|
||||
# -- Scaler database host. Defaults to the bundled "<release>-postgresql.<namespace>.svc.cluster.local" service.
|
||||
host: ""
|
||||
# -- Scaler database port.
|
||||
port: "5432"
|
||||
# -- Scaler database name. Defaults to `postgresql.auth.database`.
|
||||
database: ""
|
||||
# -- User the scaler authenticates as.
|
||||
userName: "postgres"
|
||||
# -- Name of an env var on the worker container holding the password.
|
||||
passwordFromEnv: "POSTGRES_ADMIN_PASSWORD"
|
||||
# -- sslmode for the scaler connection.
|
||||
sslmode: "disable"
|
||||
# -- The scaler divides the query result by this value to get the desired replica count.
|
||||
targetQueryValue: "1"
|
||||
# -- Query the scaler runs to measure pending work. It replaces the previous
|
||||
# 2-hour scheduled-only window, which missed manual scans, older backlogs and
|
||||
# in-progress scans. Override to tune scaling for your workload.
|
||||
#
|
||||
# The default sums three signals:
|
||||
# 1. Scans executing or available, bounded to rows updated in the last 24h so
|
||||
# orphaned rows do not pin the worker up, plus scheduled scans that are due
|
||||
# (no lower bound, so an overdue backlog still scales up).
|
||||
# 2. Scan tasks published in the last 48h that no worker has finished. A PENDING
|
||||
# TaskResult is written at publish time (before_task_publish in api/signals.py),
|
||||
# so Beat's daily publishes are visible even with zero workers. Signal 1 alone
|
||||
# deadlocks with minReplicas 0: every scan row after the first is created by
|
||||
# the worker, so once the initial row ages out of the 24h bound there is
|
||||
# nothing to count and nothing to create more.
|
||||
# 3. Non-scan tasks pending in the last hour. Provider connection checks,
|
||||
# deletions, reports and backfills never touch the scans table, so without
|
||||
# this they are never picked up while the worker is scaled to zero.
|
||||
# This includes reconcile-orphan-tasks, a Beat watchdog that runs every two
|
||||
# minutes, so with minReplicas 0 the worker is woken about that often. Add
|
||||
# it to the excluded task names below, or raise cooldownPeriod, if you would
|
||||
# rather trade watchdog latency for longer idle periods.
|
||||
query: >-
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM scans
|
||||
WHERE (state IN ('executing', 'available') AND updated_at > NOW() - INTERVAL '24 hours')
|
||||
OR (state = 'scheduled' AND scheduled_at < NOW()))
|
||||
+ (SELECT COUNT(*) FROM django_celery_results_taskresult
|
||||
WHERE task_name IN ('scan-perform', 'scan-perform-scheduled')
|
||||
AND status IN ('PENDING', 'RECEIVED', 'STARTED')
|
||||
AND date_created > NOW() - INTERVAL '48 hours')
|
||||
+ (SELECT COUNT(*) FROM django_celery_results_taskresult
|
||||
WHERE task_name NOT IN ('scan-perform', 'scan-perform-scheduled')
|
||||
AND status IN ('PENDING', 'RECEIVED', 'STARTED')
|
||||
AND date_created > NOW() - INTERVAL '1 hour')
|
||||
|
||||
worker_beat:
|
||||
# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/
|
||||
|
||||
@@ -64,7 +64,7 @@ services:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:16.3-alpine3.20@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41
|
||||
image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
|
||||
hostname: "postgres-db"
|
||||
volumes:
|
||||
- ./_data/postgres:/var/lib/postgresql/data
|
||||
@@ -88,7 +88,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
|
||||
image: valkey/valkey:8-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
|
||||
hostname: "valkey"
|
||||
volumes:
|
||||
- ./_data/valkey:/data
|
||||
|
||||
+2
-2
@@ -60,7 +60,7 @@ services:
|
||||
start_period: 60s
|
||||
|
||||
postgres:
|
||||
image: postgres:16.3-alpine3.20@sha256:36ed71227ae36305d26382657c0b96cbaf298427b3f1eaeb10d77a6dea3eec41
|
||||
image: postgres:16-alpine@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777
|
||||
hostname: "postgres-db"
|
||||
volumes:
|
||||
- ./_data/postgres:/var/lib/postgresql/data
|
||||
@@ -80,7 +80,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
|
||||
image: valkey/valkey:8-alpine@sha256:a038175878d66b9d274fbf8be73c0305e93798b83917647f167e18cef3c71eec
|
||||
hostname: "valkey"
|
||||
volumes:
|
||||
- ./_data/valkey:/data
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
---
|
||||
title: "Changelog"
|
||||
description: "New features and improvements in each Prowler release"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<Update label="v5.36.0" description="July 24, 2026">
|
||||
### 🎫 Finding Groups - Jira
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Selected Findings, Finding Groups, and mixed selections can now be sent to Jira. When you select multiple findings, choose between one grouped issue or separate issues. Generated issues keep their Prowler context with deep links and filter details, while the UI provides clear dispatch and failure feedback.
|
||||
|
||||

|
||||
|
||||
Read more in the [Jira integration documentation](/user-guide/tutorials/prowler-app-jira-integration).
|
||||
|
||||
### 🕸️ Attack Paths - Queries
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Prowler Cloud now records which built-in Attack Paths queries returned data at the end of each scan. The query selector hides confirmed-empty queries for the selected scan, so you can focus on paths that exist without opening blank graph views. Errored, unknown, and parameterized queries remain available when they still require investigation or input.
|
||||
|
||||
All Attack Paths queries are now published on [Prowler Hub](https://hub.prowler.com), where you can browse the full catalog.
|
||||
|
||||

|
||||
|
||||
Read more in the [Attack Paths documentation](/user-guide/tutorials/prowler-app-attack-paths).
|
||||
|
||||
### 🧑🏫 New Tutorials: Connect Your AI Agents to Prowler Cloud
|
||||
|
||||
<Note>
|
||||
This feature needs a Prowler Cloud API key, so it is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
New tutorials walk you through connecting your own AI agents to Prowler Cloud, so they can query your security posture and act on it programmatically.
|
||||
|
||||
Read more in the [AI agents documentation](/user-guide/ai-agents/index).
|
||||
|
||||
### ☁️ Region-less Oracle Cloud Infrastructure Setup
|
||||
|
||||
Oracle Cloud Infrastructure (OCI) provider credentials no longer require a region. Existing clients can still send the legacy `region` field for compatibility, but the API ignores it before storing credentials or starting a scan. This removes an unnecessary step from OCI onboarding.
|
||||
|
||||
Read more in the [OCI documentation](/user-guide/providers/oci/getting-started-oci).
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
#### AWS
|
||||
|
||||
- `sagemaker_notebook_instance_no_secrets` scans the `OnCreate` and `OnStart` lifecycle scripts of SageMaker notebook instances for hardcoded API keys, passwords, tokens, connection strings, and other secrets. Thanks to @kiranrajsg!
|
||||
|
||||
Read more in the [AWS documentation](/user-guide/providers/aws/getting-started-aws). Explore all AWS checks at [Prowler Hub](https://hub.prowler.com/check?provider=aws).
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Integration responses and operations now respect provider visibility, preventing hidden-provider disclosure and blocking unauthorized attachment, connection checks, Jira dispatches, edits, and deletion.
|
||||
- Next.js was updated from 16.2.9 to 16.2.11, patching four high-severity and five medium-severity vulnerabilities.
|
||||
- The unused `npm` CLI was removed from the UI container image, eliminating the bundled `node-tar` CVE-2026-59873 and reducing exposure to future bundled npm vulnerabilities.
|
||||
- Vitest and its browser packages were updated from 4.1.8 to 4.1.10, resolving the critical `@vitest/browser` file-access permission bypass. These are development dependencies and have no runtime impact.
|
||||
- Kubernetes kubeconfig validation now blocks legacy `auth-provider.config.cmd-path` command authentication, closing a command-execution bypass.
|
||||
- `next-auth` was updated from 5.0.0-beta.30 to 5.0.0-beta.32, patching two critical Auth.js advisories: existence-based authorization checks that could fail open when a provider is misconfigured, and a homoglyph `@` bypass in email address normalization. The bump also pulls in the patched `@auth/core` 0.41.3 transitively.
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @kiranrajsg: AWS `sagemaker_notebook_instance_no_secrets` check ([#11843](https://github.com/prowler-cloud/prowler/pull/11843))
|
||||
- @owenchenxy: Alibaba Cloud SSH and RDP security group checks now handle capitalized `Policy="Accept"` values correctly ([#12049](https://github.com/prowler-cloud/prowler/pull/12049))
|
||||
- @rsaladra: S3 bucket name validation no longer raises an invalid escape sequence `SyntaxWarning` at startup ([#12041](https://github.com/prowler-cloud/prowler/pull/12041))
|
||||
- @SujayKulkarni-2211: Updated the AWS check count in the README ([#12011](https://github.com/prowler-cloud/prowler/pull/12011))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.36.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.35.0" description="July 17, 2026">
|
||||
### 💬 Lighthouse AI - Side Chat
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Lighthouse AI now lives in a side panel you can open from anywhere in the app. Ask about the findings you are looking at without leaving the page, and expand to the full-page chat at any time: your draft, messages, and streaming response come along. Finding and resource details share the same panel, with tabs to switch between Details and Lighthouse AI.
|
||||
|
||||

|
||||
|
||||
Read more in the [Lighthouse AI documentation](/getting-started/products/prowler-cloud-lighthouse#side-panel).
|
||||
|
||||
### 🤖 Lighthouse AI - Take Action
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Lighthouse AI is no longer read-only. Ask it to do things and it will: connect or remove providers, trigger a scan, schedule daily scans, update scan settings, and manage your mutelist and mute rules, straight from the chat. Every action is gated by RBAC: Lighthouse can only do what the user asking could do themselves.
|
||||
|
||||
Read more in the [Lighthouse AI capabilities](/getting-started/products/prowler-cloud-lighthouse#capabilities).
|
||||
|
||||
### ☁️ One-step AWS Organizations onboarding
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Onboarding an entire AWS Organization is now a single step. One CloudFormation quick-create link deploys the management account role and a service-managed StackSet that rolls the role out to every member account, replacing the manual StackSet console setup. Target the whole organization or a specific Organizational Unit or Root ID, and deploy from the management account or a delegated administrator. The S3 integration quick-create link also pre-fills the bucket owner account ID, preventing a stack validation error.
|
||||
|
||||

|
||||
|
||||
Built on the full-organization CloudFormation template contributed by @jchrisfarris — thanks!
|
||||
|
||||
Read more in the [AWS Organizations documentation](/user-guide/tutorials/prowler-cloud-aws-organizations).
|
||||
|
||||
### 🎯 Scan configurations: exclude checks and services
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Scan configurations now accept `excluded_checks` and `excluded_services` to narrow the execution scope. Skip individual checks or entire services per provider, and the scan does not run them at all: less noise, faster scans, and no findings you would mute anyway.
|
||||
|
||||
Read more in the [Scan Configuration documentation](/user-guide/tutorials/prowler-app-scan-configuration#limiting-the-scan-scope).
|
||||
|
||||
### 🧭 Redesigned sidebar navigation
|
||||
|
||||
The sidebar was redesigned around how you actually work: grouped sections for security, settings, and help, a Home/Chat switch at the top, collapsible configuration entries, clearer active states, and a responsive mobile overlay.
|
||||
|
||||

|
||||
|
||||
### 🔌 Prowler MCP tools renamed to `prowler_*`
|
||||
|
||||
Core Prowler tools in Prowler MCP moved from the `prowler_app_*` prefix to the shorter `prowler_*` namespace, and the MCP documentation was restructured around it. Legacy `prowler_app_*` names keep working in Lighthouse AI, so existing setups are not broken.
|
||||
|
||||
Read more in the [Prowler MCP tools reference](/getting-started/basic-usage/prowler-mcp-tools).
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Jira integration credentials now only accept bare Atlassian site names (letters, numbers, and hyphens), and Jira tenant information requests validate site names and no longer follow redirects.
|
||||
- Social account linking now requires a verified matching email from both the identity provider and the existing user account, and account connection notification emails are disabled.
|
||||
- 13 advisories reported by `pnpm audit` on the UI (3 high, 9 moderate, 1 low) are resolved with patched versions of `hono`, `ws`, `vite`, `dompurify`, `js-yaml`, `@opentelemetry/core`, and `@babel/core`, including `hono` CVE-2026-59896.
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
No external contributors in this release.
|
||||
|
||||
Special mention to @jchrisfarris, whose full-organization CloudFormation template from v5.34.0 powers the new one-step AWS Organizations onboarding ([#10403](https://github.com/prowler-cloud/prowler/pull/10403)).
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.35.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.34.0" description="July 15, 2026">
|
||||
### 🏷️ New product names
|
||||
|
||||
The Prowler family has grown, and the names now say what each product is. Same products, clearer names:
|
||||
|
||||
**Prowler products:**
|
||||
|
||||
- **Prowler Cloud** — the managed cloud security platform operated by the Prowler team.
|
||||
- **Prowler Private Cloud** (formerly *Prowler Enterprise*) — the self-hosted deployment of Prowler Cloud in your own environment.
|
||||
- **Prowler Hub** — the free public library of versioned checks, cloud service artifacts, and compliance frameworks.
|
||||
- **Prowler Lighthouse AI** — The Agentic Cloud Defender in Prowler Cloud and Prowler Private Cloud.
|
||||
- **Prowler MCP** — the MCP server that connects AI assistants and agents to Prowler, including the IDE plugins.
|
||||
|
||||
**Open source projects:**
|
||||
|
||||
- **Prowler CLI** — the command-line scanner for all supported providers.
|
||||
- **Prowler Local Server** (formerly *Prowler App*) — the self-hosted web application and API to run scans, visualize findings, and manage providers.
|
||||
- **Prowler Local Dashboard** — the web dashboard for visualizing Prowler CLI scan results, distributed with the CLI.
|
||||
- **Prowler SDK** — the Python library behind Prowler CLI and Prowler Local Server.
|
||||
|
||||
See the full family in the [Prowler products documentation](/getting-started/products).
|
||||
|
||||
### 🧭 Cross-Provider Compliance
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
One framework, every cloud, a single answer. The new **Cross-provider** tab in Compliance takes the most recent completed scan of every compatible provider and rolls them up into a single compliance posture per framework, with a per-provider breakdown and a combined executive PDF report. Requirement status follows strict precedence (FAIL over PASS over MANUAL), so one failing provider is enough to flag a requirement across your whole estate.
|
||||
|
||||

|
||||
|
||||
Three universal frameworks support it today:
|
||||
|
||||
- **CIS Controls 8.1** — AWS, Azure, Google Cloud, Microsoft 365, Kubernetes, GitHub, Google Workspace, Okta, Oracle Cloud, Alibaba Cloud, Cloudflare, MongoDB Atlas, OpenStack, and Vercel.
|
||||
- **CSA CCM 4.0** — AWS, Azure, Google Cloud, Alibaba Cloud, and Oracle Cloud.
|
||||
- **DORA 2022/2554** — AWS, Azure, Google Cloud, Alibaba Cloud, and Cloudflare.
|
||||
|
||||
Filter by provider type, account, or provider group, drill into each framework's requirements, and export the combined PDF.
|
||||
|
||||

|
||||
|
||||
Read more in the [Cross-Provider Compliance documentation](/user-guide/compliance/tutorials/cross-provider-compliance).
|
||||
|
||||
### 🏢 New Provider — E2E Networks
|
||||
|
||||
Prowler now scans [**E2E Networks**](https://www.e2enetworks.com/), with **27 checks** spanning compute nodes, networking, security groups, load balancers, block and file storage, and managed databases. Thanks to @deepak7093 for their 1st provider in Prowler!
|
||||
|
||||
Available in the Prowler CLI:
|
||||
|
||||
```bash
|
||||
export E2E_NETWORKS_API_KEY="your-api-key"
|
||||
export E2E_NETWORKS_AUTH_TOKEN="your-auth-token"
|
||||
export E2E_NETWORKS_PROJECT_ID="your-project-id"
|
||||
prowler e2enetworks
|
||||
```
|
||||
|
||||
Read more in the [E2E Networks documentation](/user-guide/providers/e2enetworks/getting-started-e2enetworks). Explore all E2E Networks checks at [Prowler Hub](https://hub.prowler.com/check?provider=e2enetworks).
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
User role relationship updates in the API are now limited to the active tenant, preserving the role assignments the same user holds in other tenants.
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
#### AWS
|
||||
|
||||
- `ec2_ami_account_block_public_access` — verifies AMI block public access is enabled at the account level in each Region, so AMIs cannot be shared publicly. Thanks to @goutham-hari!
|
||||
- `datapipeline_pipeline_no_secrets_in_definition` — scans Data Pipeline object fields, parameter objects, and parameter values for hardcoded secrets with Kingfisher. Thanks to @YinkaMetrics!
|
||||
- `elbv2_listener_pqc_tls_enabled` — verifies ELBv2 HTTPS/TLS listeners use post-quantum TLS security policies with TLS 1.2 or higher, helping reduce harvest-now-decrypt-later exposure.
|
||||
- `amplify_app_no_secrets_in_environment` — scans Amplify app and branch environment variables and build settings (buildSpec) for hardcoded secrets with Kingfisher. Thanks to @Deep070203!
|
||||
|
||||
#### Azure
|
||||
|
||||
- `app_function_ensure_http_is_redirected_to_https` — verifies that Function Apps enforce HTTPS-only traffic. Thanks to @amandalal007!
|
||||
|
||||
#### Kubernetes
|
||||
|
||||
- `core_minimize_hostpath_volume_mounts` — detects Pods that use `hostPath` volumes. Thanks to @0xTaoZ!
|
||||
- `core_readonly_root_filesystem_enabled` — verifies that every container in each Pod explicitly sets `readOnlyRootFilesystem: true` in its security context. Thanks to @Weedle02!
|
||||
|
||||
#### STACKIT
|
||||
|
||||
- `iaas_server_public_ip_attached` — flags IaaS servers that have a public IP address directly attached to a network interface. Thanks to @johannes-engler-mw!
|
||||
|
||||
Explore all checks at [Prowler Hub](https://hub.prowler.com/check).
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @jchrisfarris — Deploy AWS Organizations with the CloudFormation template in one step ([#10403](https://github.com/prowler-cloud/prowler/pull/10403))
|
||||
- @deepak7093 — New E2E Networks provider: 27 checks across compute nodes, networking, security groups, load balancers, block/file storage, and managed databases ([#11654](https://github.com/prowler-cloud/prowler/pull/11654))
|
||||
- @goutham-hari — AWS `ec2_ami_account_block_public_access` check ([#11828](https://github.com/prowler-cloud/prowler/pull/11828))
|
||||
- @YinkaMetrics — AWS `datapipeline_pipeline_no_secrets_in_definition` check ([#11821](https://github.com/prowler-cloud/prowler/pull/11821))
|
||||
- @amandalal007 — Azure `app_function_ensure_http_is_redirected_to_https` check ([#11929](https://github.com/prowler-cloud/prowler/pull/11929))
|
||||
- @0xTaoZ — Kubernetes `core_minimize_hostpath_volume_mounts` check ([#11837](https://github.com/prowler-cloud/prowler/pull/11837))
|
||||
- @Weedle02 — Kubernetes `core_readonly_root_filesystem_enabled` check ([#11835](https://github.com/prowler-cloud/prowler/pull/11835))
|
||||
- @johannes-engler-mw — STACKIT `iaas_server_public_ip_attached` check ([#11549](https://github.com/prowler-cloud/prowler/pull/11549))
|
||||
- @janderik — Trailing newlines added to compliance, region, and fixture data files for POSIX compliance ([#11765](https://github.com/prowler-cloud/prowler/pull/11765))
|
||||
- @Deep070203 — AWS `amplify_app_no_secrets_in_environment` check ([#11825](https://github.com/prowler-cloud/prowler/pull/11825))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.34.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.33.0" description="July 7, 2026">
|
||||
### 🤖 Lighthouse AI — The Agentic Cloud Defender
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Lighthouse AI is now a full agentic assistant wired to the Prowler Cloud backend. Ask it about your findings, your compliance posture, or your riskiest resources, and watch it work: the agent discovers and runs the Prowler tools it needs to answer, with every tool call visible in the new agentic view. It reads your security data through read-only tools, so it can never touch secrets or modify your tenant.
|
||||
|
||||

|
||||
|
||||
The chat experience is rebuilt around **persistent sessions**: conversations stream in real time, stay in your session history, can be archived, and a **sidebar chat mode** lets you ask questions from any page in the app without losing your place.
|
||||
|
||||

|
||||
|
||||
You control the brain behind it. Configure one or more LLM providers — **OpenAI**, **Amazon Bedrock**, or any **OpenAI-compatible** endpoint (OpenRouter, Ollama) — with connection testing built into the setup and per-provider model selection. Add a shared **business context** (your security goals, compliance needs, organizational priorities) and every session uses it to give answers that fit your environment.
|
||||
|
||||

|
||||
|
||||
Read more in the [Lighthouse AI documentation](/getting-started/products/prowler-cloud-lighthouse) and the [multiple LLM providers guide](/user-guide/tutorials/prowler-cloud-lighthouse-multi-llm).
|
||||
|
||||
### 📄 Compliance PDF Reports Without Credentials
|
||||
|
||||
Compliance PDF reports no longer require the provider's credentials to be present. Findings are now enriched from the provider metadata stored in the database, so a report still generates even after the provider secret has been deleted or its credentials have become invalid.
|
||||
|
||||
Read more in the [compliance documentation](/user-guide/compliance/tutorials/compliance).
|
||||
|
||||
### ⏳ Scan Queueing
|
||||
|
||||
Overlapping scans for the same provider now queue behind the active one instead of dispatching concurrent scan workers. Launch a manual scan while a scheduled one is running and it waits its turn. No more duplicated work or racing scans.
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
The Kubernetes provider credentials now reject kubeconfigs using `exec` authentication in Prowler Cloud, at the API and in the credential form, preventing user-supplied commands from running on Cloud workers.
|
||||
|
||||
Read more in the [Kubernetes provider authentication documentation](/user-guide/providers/kubernetes/getting-started-k8s#step-2-configure-kubernetes-authentication).
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @kratos0718 — Azure `postgresql_flexible_server_log_retention_days_greater_3` Flexible Server log retention fix ([#11761](https://github.com/prowler-cloud/prowler/pull/11761))
|
||||
- @Sanjays2402 — `KeyError: 'MANUAL'` crash fix in the compliance summary table, shipped early in v5.32.1 ([#11823](https://github.com/prowler-cloud/prowler/pull/11823))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.33.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.32.0" description="July 2, 2026">
|
||||
### 🔎 Findings Triage
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Triage findings straight from the Findings view. Each finding gets a triage status you can move through its lifecycle:
|
||||
|
||||
**Open → Under Review → Remediating → Risk Accepted → False Positive → Resolved**
|
||||
|
||||
Add a triage note to record the decision, mute a finding, all from the row's actions menu. The current status shows inline on every finding row, so you keep track of what has been reviewed and stop re-checking the same issues scan after scan.
|
||||
|
||||

|
||||
|
||||
The status also follows the finding automatically across scans: when a finding flips from `FAIL` to `PASS` on the next scan it moves to **Resolved**, and when it flips from `PASS` back to `FAIL` it moves to **Reopened**. You always know whether an issue is genuinely fixed or has regressed, without touching it by hand.
|
||||
|
||||

|
||||
|
||||
Read more in the [Findings Triage documentation](/user-guide/tutorials/prowler-app-findings-triage).
|
||||
|
||||
### ⚙️ Scan Configuration
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Create named, reusable scan configurations from a dedicated **Scans / Configuration** page. Each configuration is YAML that follows the structure of [`prowler/config/config.yaml`](https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml), so you only include the keys you want to override; the rest fall back to the built-in defaults. Values are validated on save against a per-provider, type-safe configuration schema that range-checks each field and rejects unknown keys, so a malformed config is caught before it ever reaches a scan. Attach a configuration to one or more providers so it applies on their next scan, or save it now and attach providers later.
|
||||
|
||||

|
||||
|
||||
From the Providers view you can pick which configuration a provider uses (`Default` or any of your saved ones) without leaving the page. No more passing config files around by hand.
|
||||
|
||||

|
||||
|
||||
Read more in the [Scan Configuration documentation](/user-guide/tutorials/prowler-app-scan-configuration).
|
||||
|
||||
### ✅ Per-Requirement Configuration Validation
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Private Cloud** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
|
||||
Compliance frameworks can now declare `ConfigRequirements` on a requirement, so it's reported as **FAIL** when its mapped checks ran under a configuration too loose to satisfy it. Even if every individual finding PASSed. This applies across all compliance outputs: CSV, OCSF, and console tables, and is the engine behind Scan Configuration's "marked as FAIL" behavior described above.
|
||||
|
||||

|
||||
|
||||
Read more in the [Configuration File documentation](/user-guide/cli/tutorials/configuration_file).
|
||||
|
||||
### ⏱️ Okta — Request Throttling & Retries
|
||||
|
||||
Prowler now proactively throttles Okta API requests to stay under rate limits, with reactive retries on HTTP 429 as a safety net. Both are set in the scan configuration (or their equivalent CLI flags):
|
||||
|
||||
- `okta_requests_per_second` (config file) / `--okta-requests-per-second` (CLI) — cap the request rate. Default: 4 req/s.
|
||||
- `okta_max_retries` (config file) / `--okta-retries-max-attempts` (CLI) — bound retry attempts. Default: 5.
|
||||
|
||||
This makes large Okta scans more reliable and less likely to be rate-limited.
|
||||
|
||||
Read more in the [Okta rate limit documentation](/user-guide/providers/okta/retry-configuration#request-throttling-requests-per-second).
|
||||
|
||||
### 📉 AWS — Cap Resources Scanned per Service
|
||||
|
||||
Large AWS accounts can now cap how many resources Prowler analyzes for the highest-volume services, keeping scan time and cost under control. Set a global limit with `max_scanned_resources_per_service`, or override it per service:
|
||||
|
||||
- EBS snapshots (`max_ebs_snapshots`)
|
||||
- Backup recovery points (`max_backup_recovery_points`)
|
||||
- CloudWatch log groups (`max_cloudwatch_log_groups`)
|
||||
- Lambda functions (`max_lambda_functions`)
|
||||
- ECS task definitions (`max_ecs_task_definitions`)
|
||||
- CodeArtifact packages (`max_codeartifact_packages`)
|
||||
|
||||
Limits are **disabled by default** (`0` = unlimited); only positive values cap the analyzed resources.
|
||||
|
||||
<Warning>
|
||||
When a positive limit is set, compliance results reflect only the sampled resources, not every matching resource in the account.
|
||||
</Warning>
|
||||
|
||||
Read more in the [configuration file documentation](/user-guide/cli/tutorials/configuration_file#supported-aws-resource-limits).
|
||||
|
||||
### 🏷️ Azure — Filter by Resource Group
|
||||
|
||||
Azure scans can now be scoped to one or more resource groups with the new `--azure-resource-group` / `--azure-resource-groups` option. This lets you run focused assessments against specific environments, teams, or workloads instead of scanning every accessible resource in the subscription. Thanks to @Legin-ML for contributing this feature!
|
||||
|
||||
```bash
|
||||
# Single resource group
|
||||
prowler azure --az-cli-auth --azure-resource-group rg-prod
|
||||
|
||||
# Multiple resource groups
|
||||
prowler azure --az-cli-auth --azure-resource-group rg-prod1 rg-prod2
|
||||
```
|
||||
|
||||
Read more in the [Azure Resource Groups documentation](/user-guide/providers/azure/resource-groups).
|
||||
|
||||
### 🧭 Provider Group Filter
|
||||
|
||||
Filter the **Overview, Findings, Resources, Scans, and Providers** views by provider group. Scope the whole app to a team, an environment, or a business unit in one click instead of filtering provider by provider.
|
||||
|
||||

|
||||
|
||||
Read more about managing provider groups in the [RBAC documentation](/user-guide/tutorials/prowler-app-rbac).
|
||||
|
||||
### 🔬 API — Timestamp Precision in Findings Filters
|
||||
|
||||
The `/api/v1/findings` endpoint now accepts full timestamps on the `inserted_at` and `updated_at` filters (`filter[inserted_at__gte]`, `filter[inserted_at__lte]`, and the `updated_at` variants), so you can query narrow time windows instead of whole days. Date-only filtering keeps working, so existing integrations are unaffected.
|
||||
|
||||
```bash
|
||||
# Findings inserted within a precise timestamp window
|
||||
curl --globoff \
|
||||
'http://localhost:8080/api/v1/findings?filter[inserted_at__gte]=2026-07-01T06:12:18Z&filter[inserted_at__lte]=2026-07-02T19:25:55Z' \
|
||||
-H 'Authorization: Bearer <YOUR_TOKEN>' \
|
||||
-H 'Accept: application/vnd.api+json'
|
||||
```
|
||||
|
||||
### 🕸️ Attack Paths — Neptune as a persistent sink
|
||||
|
||||
Attack Paths can now persist its graph in **AWS Neptune** in addition to Neo4j, selectable via `ATTACK_PATHS_SINK_DATABASE=neptune` (default `neo4j`). Cartography's per-scan ingest database stays on Neo4j. The scan task preflights the ingest database and the configured sink before ingestion, and provider graph cleanup now deletes relationships in directed batches before deleting nodes.
|
||||
|
||||
This is the groundwork for scale: a managed graph database lets Attack Paths hold much larger graphs, extend coverage to more providers, and link resources across them so an attack path can cross provider boundaries instead of stopping at one cloud's edge.
|
||||
|
||||
Read more in the [Attack Paths documentation](/user-guide/tutorials/prowler-app-attack-paths).
|
||||
|
||||
### 🔐 New Secret-Scanning Engine — Kingfisher
|
||||
|
||||
Prowler's secret-scanning checks now run on [Kingfisher](https://github.com/mongodb/kingfisher) instead of `detect-secrets`. Scans run **fully offline by default**, and obvious placeholder values (e.g. `password123`, `changeme`) are no longer reported, cutting down false positives.
|
||||
|
||||
Opt in to **live validation** with the new `--scan-secrets-validate` flag (or the `aws.secrets_validate` config option): Prowler checks discovered secrets against the provider APIs, and any secret confirmed to be **live is reported as critical**, so you can prioritize the credentials that actually work.
|
||||
|
||||
<Note>
|
||||
The `detect_secrets_plugins` configuration option has been removed, as it is no longer used by the new engine.
|
||||
</Note>
|
||||
|
||||
Read more in the [secret detection documentation](/user-guide/cli/tutorials/pentesting#detect-secrets).
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
#### AWS
|
||||
|
||||
- `stepfunctions_statemachine_encrypted_with_cmk` — Step Functions state machines use a customer-managed KMS key for encryption at rest instead of the default AWS-owned key. Thanks to @Sid-0602!
|
||||
- `waf_regional_webacl_logging_enabled` — AWS WAF Classic Regional Web ACLs have logging enabled to a Kinesis Data Firehose stream. Thanks to @Sid-0602!
|
||||
- **IAM privilege escalation** — the privesc checks now cover **AWS Bedrock AgentCore** paths across Runtime, Harness, Code Interpreter, and Custom Browser. Thanks to @MrCloudSec!
|
||||
- `apigateway_restapi_no_secrets_in_stage_variables` — scans API Gateway REST API stage variables for hardcoded passwords, API keys, and tokens. Thanks to @chirag1206!
|
||||
- `awslambda_function_no_secrets_in_code` — this check now supports a `secrets_ignore_files` audit-config option to skip files inside the deployment package by glob pattern (e.g. `*.deps.json`), suppressing .NET dependency-manifest false positives without masking real secrets.
|
||||
- `s3_bucket_object_public` — spot-checks a configurable sample of object ACLs in each bucket and flags objects granted to the `AllUsers` or `AuthenticatedUsers` groups. Disabled by default; opt in via the `s3_bucket_object_public_enabled` configuration option. Thanks to @Synchx00!
|
||||
|
||||
#### Microsoft 365
|
||||
|
||||
New **Conditional Access** hardening checks:
|
||||
|
||||
- `entra_conditional_access_policy_explicitly_targets_azure_devops` — at least one enabled policy explicitly includes the Azure DevOps cloud application, rather than relying on a broad "All cloud apps" policy. Thanks to @mzl2233!
|
||||
- `entra_conditional_access_policy_no_exclusion_gaps` — every user, group, role, or application excluded from an enabled policy stays in scope of another enabled policy. Thanks to @UTKARSH698 with @arieleli01212 as co-author!
|
||||
- `entra_conditional_access_policy_groups_management_restricted` — every security group referenced by an enabled or report-only policy is management-restricted or role-assignable. Thanks to @SAMurai-16!
|
||||
- `exchange_application_access_policy_restricts_mailbox_apps` — every service principal with Microsoft Graph application-level Exchange mailbox permissions is restricted by an Exchange Online Application Access Policy. Thanks to @VasistAcharya!
|
||||
|
||||
### 📚 Compliance
|
||||
|
||||
#### CIS Benchmark Refresh — Six New Versions
|
||||
|
||||
Prowler ships a coordinated refresh of the CIS Benchmarks across six providers:
|
||||
|
||||
- **AWS** — CIS Amazon Web Services Foundations Benchmark v7.0.0, adding the new Organizations section (2.1.1-2.1.6), resource policy (2.21), web front-end access logging (4.10), and VPC Endpoints (6.8) recommendations.
|
||||
- **Azure** — CIS Microsoft Azure Foundations Benchmark v6.0.0.
|
||||
- **GCP** — CIS Google Cloud Platform Foundation Benchmark v5.0.0.
|
||||
- **Kubernetes** — CIS Kubernetes Benchmark v2.0.1.
|
||||
- **GitHub** — CIS GitHub Benchmark v1.2.0.
|
||||
- **Microsoft 365** — CIS Microsoft 365 Foundations Benchmark v7.0.0.
|
||||
|
||||
#### CIS Controls v8.1 — Universal Framework
|
||||
|
||||
A new **universal** (cross-provider) compliance framework mapping existing checks across 18 providers — AWS, Azure, GCP, Kubernetes, M365, GitHub, AlibabaCloud, OracleCloud, GoogleWorkspace, Okta, Cloudflare, Vercel, MongoDB Atlas, OpenStack, Linode, StackIT, NHN, and Scaleway — to the 18 CIS Critical Security Controls and their Safeguards. Ships with a dedicated detail view and report mapping in the UI.
|
||||
|
||||
Read more in the [compliance documentation](/user-guide/compliance/tutorials/compliance). Explore the full compliance catalog at [Prowler Hub](https://hub.prowler.com/compliance).
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @chirag1206 — `apigateway_restapi_no_secrets_in_stage_variables` check ([#11188](https://github.com/prowler-cloud/prowler/pull/11188))
|
||||
- @MrCloudSec — AWS Bedrock AgentCore privilege escalation paths in the IAM privesc checks ([#11726](https://github.com/prowler-cloud/prowler/pull/11726))
|
||||
- @Sid-0602 — `stepfunctions_statemachine_encrypted_with_cmk` ([#11538](https://github.com/prowler-cloud/prowler/pull/11538)) and `waf_regional_webacl_logging_enabled` ([#11539](https://github.com/prowler-cloud/prowler/pull/11539)) checks
|
||||
- @mzl2233 — `entra_conditional_access_policy_explicitly_targets_azure_devops` check ([#11182](https://github.com/prowler-cloud/prowler/pull/11182))
|
||||
- @UTKARSH698 with @arieleli01212 as co-author — `entra_conditional_access_policy_no_exclusion_gaps` check ([#11577](https://github.com/prowler-cloud/prowler/pull/11577))
|
||||
- @SAMurai-16 — `entra_conditional_access_policy_groups_management_restricted` check ([#11342](https://github.com/prowler-cloud/prowler/pull/11342))
|
||||
- @vahidg — Azure PostgreSQL flexible server collection resilience fix ([#11595](https://github.com/prowler-cloud/prowler/pull/11595))
|
||||
- @davletd — Azure `keyvault_logging_enabled` `AuditEvent` category fix ([#11660](https://github.com/prowler-cloud/prowler/pull/11660))
|
||||
- @VasistAcharya — `exchange_application_access_policy_restricts_mailbox_apps` ([#11247](https://github.com/prowler-cloud/prowler/pull/11247))
|
||||
- @Legin-ML — Filter scans at Resource Group level ([#10657](https://github.com/prowler-cloud/prowler/pull/10657))
|
||||
- @Synchx00 — `s3_bucket_object_public` check ([#9517](https://github.com/prowler-cloud/prowler/pull/9517))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.32.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.31.0" description="June 23, 2026">
|
||||
### 🗓️ Flexible Scan Scheduling
|
||||
|
||||
<Note>
|
||||
Available exclusively in **Prowler Cloud**. Prowler Local Server supports daily scans only.
|
||||
</Note>
|
||||
|
||||

|
||||
|
||||
You can now set a per-provider scan schedule from the Providers page. Pick a **scan time** and a **repeat cadence**: Daily, Every 48 hours, Weekly (with a day-of-week selector), or Monthly. Schedules can be edited or removed at any time, and a new scan never interrupts access to existing data.
|
||||
|
||||

|
||||
|
||||
All schedules are listed in one place under the **Scheduled** tab in **Scan Jobs**, showing each provider's cadence, next scan, and last scan at a glance.
|
||||
|
||||

|
||||
|
||||
Read more in the [scan scheduling documentation](/user-guide/tutorials/prowler-scan-scheduling).
|
||||
|
||||
### 📚 DORA — Expanded Provider Coverage
|
||||
|
||||
Prowler extends [**DORA**](https://www.eiopa.europa.eu/digital-operational-resilience-act-dora_en) (Digital Operational Resilience Act, Regulation (EU) 2022/2554) coverage to **Azure**, **GCP**, **Cloudflare**, and **Alibaba Cloud**, mapping each provider's existing checks across the five DORA pillars.
|
||||
|
||||

|
||||
|
||||
<Note>
|
||||
The framework follows the `<name>_<version>` naming convention as `DORA_2022_2554`.
|
||||
</Note>
|
||||
|
||||
Read more in the [compliance documentation](/user-guide/compliance/tutorials/compliance).
|
||||
|
||||
### 🚀 Guided Onboarding
|
||||
|
||||
<Note>
|
||||
Available exclusively in **Prowler Cloud**.
|
||||
</Note>
|
||||
|
||||
New accounts now get a guided first-run experience. The Overview greets you with an **"Add your first provider"** prompt: connect a provider so Prowler has something to scan and assess, then get started in one click (or skip for now).
|
||||
|
||||

|
||||
|
||||
From there, contextual empty states across the product point you to the next action rather than leaving you stuck. Attack Paths, for example, explains that you need a completed scan before it can build a graph and links straight to **Scan Jobs**, with a **"See how it works"** affordance for first-timers.
|
||||
|
||||

|
||||
|
||||
### 🔐 Optional SAML SSO `userType`
|
||||
|
||||
The SAML `userType` attribute is now optional. If your IdP does not send it, or sends it blank, Prowler keeps the user's existing roles unchanged instead of replacing them with a fallback role.
|
||||
|
||||
When `userType` is provided, Prowler still maps the user to the matching role. If that role does not exist yet, Prowler creates it with read-only access: visibility over all providers, with no management permissions.
|
||||
|
||||
Read more in the [SAML SSO documentation](/user-guide/tutorials/prowler-app-sso).
|
||||
|
||||
### 🏢 New Provider — Linode
|
||||
|
||||
Prowler now scans [**Linode**](https://www.linode.com/) (Akamai Cloud), covering its administration, compute, and networking services. Thanks to @varunmamillapalli for their 1st provider in Prowler!
|
||||
|
||||
<Note>
|
||||
Linode is not officially supported. For more information, [contact us](https://prowler.com/contact).
|
||||
</Note>
|
||||
|
||||
Read more in the [Linode documentation](/user-guide/providers/linode/getting-started-linode). Explore all Linode checks at [Prowler Hub](https://hub.prowler.com/check?provider=linode).
|
||||
|
||||
### 🔍 Checks
|
||||
|
||||
#### AWS
|
||||
|
||||
**Post-Quantum Cryptography readiness** — get ahead of the migration to quantum-resistant cryptography:
|
||||
|
||||
- `cloudfront_distributions_pqc_tls_enabled` — CloudFront distributions enforce a post-quantum TLS 1.3 security policy.
|
||||
- `apigateway_domain_name_pqc_tls_enabled` — API Gateway custom domain names use a post-quantum TLS security policy.
|
||||
- `transfer_server_pqc_ssh_kex_enabled` — Transfer Family servers use a post-quantum hybrid SSH key exchange.
|
||||
- `acmpca_certificate_authority_pqc_key_algorithm` — Private CA authorities use a post-quantum (ML-DSA) key algorithm (new `acmpca` service).
|
||||
- `rolesanywhere_trust_anchor_pqc_pki` — IAM Roles Anywhere trust anchors are backed by a post-quantum (ML-DSA) PKI (new `rolesanywhere` service).
|
||||
|
||||
**Organization-wide governance:**
|
||||
|
||||
- `securityhub_delegated_admin_enabled_all_regions` — Security Hub has a delegated administrator, active in all opted-in regions, with organization auto-enable on. Thanks to @ernestprovo23!
|
||||
- `config_delegated_admin_and_org_aggregator_all_regions` — AWS Config has a delegated administrator and an organization aggregator covering all regions. Thanks to @ernestprovo23!
|
||||
|
||||
**Machine learning:**
|
||||
|
||||
- `sagemaker_clarify_exists` — verifies at least one SageMaker Clarify processing job exists per scanned region, so bias-detection and model-explainability controls are in place. Thanks to @AlexanderSanin!
|
||||
|
||||
#### Azure
|
||||
|
||||
A large batch of new Azure checks spanning data, compute, identity, and networking:
|
||||
|
||||
- **Cosmos DB** — automatic failover, continuous backup policy, minimum TLS 1.2, and public network access disabled.
|
||||
- **MySQL & PostgreSQL Flexible Servers** — geo-redundant backup and high availability.
|
||||
- **AKS** — auto-upgrade, Azure Monitor (Container Insights), local accounts disabled, and Microsoft Defender enabled.
|
||||
- **Databricks** — public network access disabled and secure cluster connectivity (no public IP).
|
||||
- **Defender** — CSPM on the Standard tier.
|
||||
- **Networking** — NSG association on subnets and DDoS Network Protection on VNets.
|
||||
- **Entra ID** — app registration credential expiry, users with recent sign-in and strong authentication enforcement.
|
||||
- **Recovery Services** — vaults with at least one protected backup item and vaults with adequate backup policy.
|
||||
|
||||
Thanks to @s1ns3nz0 for all these contributions!
|
||||
|
||||
#### GCP
|
||||
|
||||
New coverage for high availability and public-exposure detection:
|
||||
|
||||
- `cloudsql_instance_high_availability_enabled` — Cloud SQL primary instances use `REGIONAL` availability for automatic zone failover.
|
||||
- `cloudfunction_function_inside_vpc` — Cloud Functions use a Serverless VPC Access connector for private egress.
|
||||
- `cloudfunction_function_not_publicly_accessible` — detects `allUsers` / `allAuthenticatedUsers` IAM invocation bindings.
|
||||
- `secretmanager_secret_not_publicly_accessible` — detects Secret Manager secrets with public IAM bindings.
|
||||
- `secretmanager_secret_rotation_enabled` — verifies Secret Manager secrets have automatic rotation configured with a period of 90 days or less and no missed rotation.
|
||||
|
||||
Thanks to @s1ns3nz0 for all these contributions!
|
||||
|
||||
#### Kubernetes
|
||||
|
||||
New core checks for container resource governance and reliability: CPU limits, CPU requests, memory limits, memory requests, fixed image tags, liveness probes, and readiness probes. Thanks to @Nikhilkumar2311 for all these contributions!
|
||||
|
||||
#### Microsoft 365
|
||||
|
||||
- `entra_directory_sync_object_takeover_blocked` — hybrid Entra tenants block cloud object takeover through soft-match and hard-match directory synchronization. Thanks to @PrettyFox0 and @omobolajiadeyan!
|
||||
- `entra_conditional_access_policy_no_deleted_object_references` — flags Conditional Access policies that reference user, group, or role objects that no longer resolve in the directory. Thanks to @ernestprovo23!
|
||||
|
||||
#### Oracle Cloud Infrastructure
|
||||
|
||||
- `identity_storage_service_level_admins_scoped` — CIS 3.1 control 1.15, ensuring storage service-level administrators exclude delete permissions.
|
||||
|
||||
Explore all checks at [Prowler Hub](https://hub.prowler.com/check).
|
||||
|
||||
### 🐍 Python 3.13 Support
|
||||
|
||||
The Prowler SDK now supports **Python 3.13**. Thanks to @branchv!
|
||||
|
||||
### 🔐 Security Updates
|
||||
|
||||
- **SDK** — `pytest` 8.3.5 → 9.0.3, `black` 25.1.0 → 26.3.1, `microsoft-kiota-*` → 1.9.9, and `aiohttp` → 3.14.0, patching known CVEs.
|
||||
- **API** — `aiohttp` → 3.14.0 and `idna` → 3.15, patching known CVEs.
|
||||
- **UI** — bumped vulnerable `Next.js`, React, AI SDK, `postcss`, `hono`, `qs`, `esbuild`, and Alpine OpenSSL packages; `dompurify` 3.4.2 → 3.4.10, patching XSS sanitization bypass advisories.
|
||||
- **Containers** — base image bumped to `python:3.12.13-slim-bookworm` (patches `libgnutls30` CVE-2026-33845 and CVE-2026-42010) and `trivy` to 0.71.0 (patches embedded `golang.org/x/crypto` and Go stdlib CVEs).
|
||||
|
||||
### 🙌 External Contributors
|
||||
|
||||
Thank you to our community contributors for this release!
|
||||
|
||||
- @varunmamillapalli — New Linode provider: administration, compute, and networking services ([#11633](https://github.com/prowler-cloud/prowler/pull/11633))
|
||||
- @s1ns3nz0 — 20+ Azure & GCP checks across Cosmos DB, AKS, Databricks, Flexible Servers, Entra, networking, and GCP public-exposure
|
||||
- @Nikhilkumar2311 — Kubernetes resource limits, requests, image tag, and probe checks ([#11373](https://github.com/prowler-cloud/prowler/pull/11373))
|
||||
- @ernestprovo23 — AWS Security Hub/Config org-wide delegated admin checks ([#11259](https://github.com/prowler-cloud/prowler/pull/11259)) and M365 conditional access check ([#11236](https://github.com/prowler-cloud/prowler/pull/11236))
|
||||
- @AlexanderSanin — `sagemaker_clarify_exists` check ([#11211](https://github.com/prowler-cloud/prowler/pull/11211))
|
||||
- @PrettyFox0 with @omobolajiadeyan as co-author — M365 directory sync object takeover check ([#11098](https://github.com/prowler-cloud/prowler/pull/11098))
|
||||
- @branchv — Python 3.13 support ([#9293](https://github.com/prowler-cloud/prowler/pull/9293))
|
||||
- @alinealfa — GCP audit-filtered aggregated sinks fix ([#11575](https://github.com/prowler-cloud/prowler/pull/11575))
|
||||
- @b-abderrahmane — Configurable Celery worker concurrency ([#11075](https://github.com/prowler-cloud/prowler/pull/11075))
|
||||
|
||||
See the [full release notes on GitHub](https://github.com/prowler-cloud/prowler/releases/tag/5.31.0) for the complete list of changes.
|
||||
</Update>
|
||||
|
||||
<Update label="Earlier releases">
|
||||
Release notes for v5.30.0 and earlier, along with every patch release, are on [GitHub Releases](https://github.com/prowler-cloud/prowler/releases).
|
||||
</Update>
|
||||
@@ -0,0 +1,467 @@
|
||||
---
|
||||
title: "Attack Paths Queries"
|
||||
---
|
||||
|
||||
This guide explains how to write and maintain Prowler Attack Paths queries: the read-only openCypher queries that traverse the Cartography-ingested cloud graph to detect privilege escalation chains, network exposure, and other graph-shaped security risks.
|
||||
|
||||
<Info>
|
||||
**New to Attack Paths?** Start with the user documentation:
|
||||
- [Attack Paths](/user-guide/tutorials/prowler-app-attack-paths) - What Attack Paths detects, how to run built-in queries, and how to explore the resulting graph.
|
||||
- [Writing Custom openCypher Queries](/user-guide/tutorials/prowler-app-attack-paths#writing-custom-opencypher-queries) - Run ad-hoc read-only queries from the Prowler App.
|
||||
</Info>
|
||||
|
||||
## Introduction
|
||||
|
||||
Attack Paths queries run against a property graph populated by [Cartography](https://github.com/cartography-cncf/cartography), an open-source graph ingestion framework, and enriched with Prowler findings. Every query is read-only openCypher (Version 9) so it runs on both the Neo4j and Amazon Neptune sinks.
|
||||
|
||||
Two categories of query exist, each with a different isolation model:
|
||||
|
||||
| | Predefined queries | Custom queries |
|
||||
| ------------------ | ----------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| Where they live | `api/src/backend/api/attack_paths/queries/{provider}.py` | User-supplied through the custom query API endpoint |
|
||||
| Provider isolation | `AWSAccount {id: $provider_uid}` anchor plus path connectivity | Automatic `_Provider_{uuid}` label injection by `cypher_sanitizer.py` |
|
||||
| What to write | Chain every `MATCH` from the `aws` variable | Plain Cypher, no isolation boilerplate |
|
||||
| Internal labels | Never use | Never use (system-injected) |
|
||||
|
||||
For **predefined queries**, every node must be reachable from the `AWSAccount` root through graph traversal. That reachability is the isolation boundary.
|
||||
|
||||
For **custom queries**, the runner injects a `_Provider_{uuid}` label into every node pattern, and a post-query filter handles edge cases, so query authors write natural Cypher without isolation boilerplate.
|
||||
|
||||
The rest of this guide focuses on predefined queries, though the graph model, list-property handling, and compatibility rules apply to both.
|
||||
|
||||
## The Graph Model
|
||||
|
||||
### Cartography Schema
|
||||
|
||||
Node labels, relationship types, and properties follow the upstream Cartography schema for each provider. Do not guess them, fetch the schema for the pinned Cartography version:
|
||||
|
||||
```bash
|
||||
grep cartography api/pyproject.toml
|
||||
```
|
||||
|
||||
Then read the schema for that exact tag:
|
||||
|
||||
```text
|
||||
# Git pin (prowler-cloud/cartography@<TAG>):
|
||||
https://raw.githubusercontent.com/prowler-cloud/cartography/refs/tags/<TAG>/docs/root/modules/{provider}/schema.md
|
||||
|
||||
# PyPI pin (cartography==<TAG>):
|
||||
https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/<TAG>/docs/root/modules/{provider}/schema.md
|
||||
```
|
||||
|
||||
The public schema reference for AWS is available at [Cartography AWS Schema](https://cartography-cncf.github.io/cartography/modules/aws/schema.html).
|
||||
|
||||
### Prowler-Specific Additions
|
||||
|
||||
The Prowler sync task enriches the Cartography graph with the following labels and relationships. These are not part of the upstream schema:
|
||||
|
||||
| Label / Relationship | Description |
|
||||
| ---------------------- | ----------------------------------------------------------- |
|
||||
| `ProwlerFinding` | Finding node (`status`, `severity`, `check_id`) |
|
||||
| `Internet` | Internet sentinel node used to model public exposure |
|
||||
| `CAN_ACCESS` | `(Internet)-[:CAN_ACCESS]->(resource)` exposure edge |
|
||||
| `HAS_FINDING` | `(resource)-[:HAS_FINDING]->(:ProwlerFinding)` finding link |
|
||||
| `TRUSTS_AWS_PRINCIPAL` | Role trust relationship |
|
||||
| `STS_ASSUMEROLE_ALLOW` | Principal can assume a role |
|
||||
|
||||
### Internal Isolation Labels
|
||||
|
||||
The sync layer also adds internal labels used only for tenant and provider isolation: `_ProviderResource`, `_AWSResource`, `_Tenant_*`, and `_Provider_*`. These must never appear in query text, predefined or custom. The runner applies isolation automatically.
|
||||
|
||||
## Query Structure
|
||||
|
||||
### Provider Scoping Parameter
|
||||
|
||||
| Parameter | Property | Used on | Purpose |
|
||||
| --------------- | -------- | ------------ | -------------------------------------- |
|
||||
| `$provider_uid` | `id` | `AWSAccount` | Scopes the query to a specific account |
|
||||
|
||||
The runner binds `$provider_uid` automatically. Every other node is isolated by path connectivity from the `AWSAccount` anchor.
|
||||
|
||||
### Imports
|
||||
|
||||
```python
|
||||
from api.attack_paths.queries.types import (
|
||||
AttackPathsQueryAttribution,
|
||||
AttackPathsQueryDefinition,
|
||||
AttackPathsQueryParameterDefinition,
|
||||
)
|
||||
from tasks.jobs.attack_paths.config import PROWLER_FINDING_LABEL
|
||||
```
|
||||
|
||||
Always reference `PROWLER_FINDING_LABEL` through f-string interpolation, never hardcode `"ProwlerFinding"`.
|
||||
|
||||
### Definition Fields
|
||||
|
||||
- **id**: kebab-case `{provider}-{category}-{description}`, e.g. `aws-ec2-privesc-passrole-iam`.
|
||||
- **name**: short, human-friendly label. Sourced queries append the reference ID: `"EC2 Instance Launch with Privileged Role (EC2-001)"`.
|
||||
- **short_description**: one sentence, no technical permissions.
|
||||
- **description**: full technical explanation, plain text.
|
||||
- **provider**: `aws`, `azure`, `gcp`, `kubernetes`, or `github`.
|
||||
- **cypher**: f-string Cypher body. Literal `{` and `}` are escaped as `{{` and `}}`.
|
||||
- **parameters**: `parameters=[]` when the query takes no input.
|
||||
- **attribution**: optional `AttackPathsQueryAttribution(text, link)` for sourced queries. The `link` uses the lowercase ID.
|
||||
|
||||
Append the constant to the `{PROVIDER}_QUERIES` list at the bottom of the provider file.
|
||||
|
||||
## The Predefined Query Template
|
||||
|
||||
The canonical shape combines a principal walk, an optional target walk, deduplicated nodes, and a typed finding overlay:
|
||||
|
||||
```python
|
||||
AWS_QUERY_NAME = AttackPathsQueryDefinition(
|
||||
id="aws-kebab-case-name",
|
||||
name="Label (REFERENCE_ID)",
|
||||
short_description="One sentence.",
|
||||
description="Full technical explanation.",
|
||||
attribution=AttackPathsQueryAttribution(
|
||||
text="pathfinding.cloud - REFERENCE_ID - permission",
|
||||
link="https://pathfinding.cloud/paths/reference_id_lowercase",
|
||||
),
|
||||
provider="aws",
|
||||
cypher=f"""
|
||||
// Find principals with the source permission
|
||||
MATCH path_principal = (aws:AWSAccount {{id: $provider_uid}})--(principal:AWSPrincipal)-[:POLICY]->(policy:AWSPolicy)-[:STATEMENT]->(stmt:AWSPolicyStatement {{effect: 'Allow'}})
|
||||
MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
|
||||
WHERE toLower(act.value) IN ['permission_lowercase', 'service:*']
|
||||
OR act.value = '*'
|
||||
WITH DISTINCT aws, principal, stmt, path_principal
|
||||
|
||||
// Pre-aggregate the statement's resource values (see "Avoiding Cartesian Products")
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
|
||||
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
|
||||
|
||||
// Match each target once against the in-memory resource list
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
WITH path_principal, path_target, res_values, res_wildcard,
|
||||
target_role.name AS rname, target_role.arn AS rarn
|
||||
WHERE res_wildcard
|
||||
OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0
|
||||
|
||||
WITH DISTINCT path_principal, path_target
|
||||
WITH collect(path_principal) + collect(path_target) AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
WITH paths, collect(DISTINCT n) AS unique_nodes
|
||||
UNWIND unique_nodes AS n
|
||||
OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
|
||||
|
||||
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr
|
||||
""",
|
||||
parameters=[],
|
||||
)
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- The principal walk types the `POLICY` and `STATEMENT` hops. Both are low-fan-out (each principal has a handful of policies; each policy a handful of statements), so the typed edge lets the planner cost a cheap inline filter.
|
||||
- The `(aws)--` hub hops stay anonymous. `AWSAccount` is a high-degree node that fans out to every principal, role, policy, and resource in the account; typing those edges forces the planner to enumerate from the hub and collapses performance on multi-tenant Neptune.
|
||||
- Other relationship types appear only where the file's existing queries already use one (`TRUSTS_AWS_PRINCIPAL`, `STS_ASSUMEROLE_ALLOW`, `MEMBER_AWS_GROUP`, `HAS_EXECUTION_ROLE`).
|
||||
- The finding probe is typed `:HAS_FINDING` and left undirected. The type lets Neptune apply an inline edge filter; the missing direction matches the convention of the rest of the file.
|
||||
- Collapse duplicate rows after each permission gate with `WITH DISTINCT`, carrying only the variables needed by later clauses.
|
||||
- The `RETURN` shape `paths, dpf, dpfr` is the contract the serializer and visualizer depend on. Do not change it.
|
||||
|
||||
## Avoiding Cartesian Products
|
||||
|
||||
The most common performance defect in Attack Paths queries is a Cartesian product between a target set and a policy statement's resource items. When the two are written as independent `MATCH` clauses, the planner pairs every target with every resource item before any filter runs. On accounts with many IAM principals, that multiplies into hundreds of thousands of rows and the query errors or times out.
|
||||
|
||||
### The Pattern That Causes It
|
||||
|
||||
```cypher
|
||||
// One row per (target_role x resource_item): a Cartesian product
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WHERE res.value = '*'
|
||||
OR res.value CONTAINS target_role.name
|
||||
OR target_role.arn CONTAINS res.value
|
||||
```
|
||||
|
||||
The two `MATCH` clauses share no relationship, so the engine enumerates all targets multiplied by all resource items, applies a non-indexable `CONTAINS` to each pair, then expands the finding overlay for every surviving row. Cost grows with `targets × resources`, and a second constrained statement (`stmt2`) multiplies it again.
|
||||
|
||||
### The Pattern That Avoids It
|
||||
|
||||
Collect the statement's resource values into a list once, then match each target a single time against that in-memory list:
|
||||
|
||||
```cypher
|
||||
// Pre-aggregate the statement's resource values into a list
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
|
||||
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
|
||||
|
||||
// Match each target once; bind name/arn to locals so the predicate reads them once
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
WITH path_principal, path_target, res_values, res_wildcard,
|
||||
target_role.name AS rname, target_role.arn AS rarn
|
||||
WHERE res_wildcard
|
||||
OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0
|
||||
```
|
||||
|
||||
Cost now grows with `targets + resources` (linear) rather than `targets × resources`. The rewrite is a pure algebraic identity: the result set is unchanged.
|
||||
|
||||
Guidelines:
|
||||
|
||||
- **Aggregate resources before matching targets, not after.** `collect(DISTINCT res.value)` reduces the resource items to a single list per statement.
|
||||
- **Short-circuit the wildcard grant** with `('*' IN res_values)`. When a statement grants `*`, every target matches, so the list scan is skipped entirely.
|
||||
- **Bind `target.name` and `target.arn` to local variables** in a `WITH` before the predicate. The list comprehension then reads each once per target instead of re-reading the property store once per resource value.
|
||||
- **Use `size([... ]) > 0`, not `any(...)`.** The `any()`, `all()`, and `none()` predicate functions are not part of the openCypher specification and fail on Amazon Neptune. See [openCypher Compatibility](#opencypher-compatibility).
|
||||
- **For two-statement queries**, aggregate each statement's resources into its own list (`res_values`, `res2_values`) and combine the two `size([... ]) > 0` checks with `AND`.
|
||||
|
||||
Every IAM privilege escalation query in `aws.py` uses this pattern. The lateral-movement variants that constrain the target with a relationship (`STS_ASSUMEROLE_ALLOW`, `TRUSTS_AWS_PRINCIPAL`) already limit the target set before the resource filter, which keeps them efficient without further aggregation.
|
||||
|
||||
## Privilege Escalation Sub-Patterns
|
||||
|
||||
Four `path_target` shapes cover the common escalation types. Each shares the canonical template's `path_principal`, the resource pre-aggregation, the deduplication tail, and the `RETURN`; only the `path_target` `MATCH` and its resource predicate differ.
|
||||
|
||||
| Sub-pattern | Target | `path_target` shape | Example |
|
||||
| ------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Self-escalation | Principal's own policies | `(aws)--(target_policy:AWSPolicy)--(principal)` | IAM-001 |
|
||||
| Lateral to user | Other IAM users | `(aws)--(target_user:AWSUser)` | IAM-002 |
|
||||
| Assume-role lateral | Assumable roles | `(aws)--(target_role:AWSRole)-[:STS_ASSUMEROLE_ALLOW]-(principal)` | IAM-014 |
|
||||
| PassRole plus service | Service-trusting roles | `(aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]-(:AWSPrincipal {arn: '{service}.amazonaws.com'})` | EC2-001 |
|
||||
|
||||
**Multi-permission queries** (for example PassRole plus a service-create action) add permission gates before `path_target`. Reuse the per-query counter for new variables (`act2`, `policy2`, `stmt2`) and collapse rows after each gate:
|
||||
|
||||
```cypher
|
||||
MATCH (principal)-[:POLICY]->(policy2:AWSPolicy)-[:STATEMENT]->(stmt2:AWSPolicyStatement {effect: 'Allow'})
|
||||
MATCH (stmt2)-[:HAS_ACTION]->(act2:AWSPolicyStatementActionItem)
|
||||
WHERE toLower(act2.value) IN ['service:*', 'service:createsomething']
|
||||
OR act2.value = '*'
|
||||
WITH DISTINCT aws, principal, stmt, stmt2, path_principal
|
||||
```
|
||||
|
||||
When a permission is an existence-only gate whose statement resource is not checked later, keep the policy and statement anonymous and carry only the variables still needed:
|
||||
|
||||
```cypher
|
||||
MATCH (principal)-[:POLICY]->(:AWSPolicy)-[:STATEMENT]->(:AWSPolicyStatement {effect: 'Allow'})-[:HAS_ACTION]->(act3:AWSPolicyStatementActionItem)
|
||||
WHERE toLower(act3.value) IN ['service:*', 'service:othersomething']
|
||||
OR act3.value = '*'
|
||||
WITH DISTINCT aws, principal, stmt, path_principal
|
||||
```
|
||||
|
||||
## Network Exposure Pattern
|
||||
|
||||
The Internet node is reached through `CAN_ACCESS` from an already-scoped resource, never as a standalone lookup:
|
||||
|
||||
```python
|
||||
cypher=f"""
|
||||
// Resource scoped through the account anchor
|
||||
MATCH path = (aws:AWSAccount {{id: $provider_uid}})--(resource:EC2Instance)
|
||||
WHERE resource.exposed_internet = true
|
||||
|
||||
// Internet node reached through path connectivity from the resource
|
||||
OPTIONAL MATCH (internet:Internet)-[can_access:CAN_ACCESS]->(resource)
|
||||
|
||||
WITH collect(path) AS paths, head(collect(internet)) AS internet, collect(can_access) AS can_access
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
WITH paths, internet, can_access, collect(DISTINCT n) AS unique_nodes
|
||||
UNWIND unique_nodes AS n
|
||||
OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})
|
||||
|
||||
RETURN paths, collect(DISTINCT pf) as dpf, collect(DISTINCT pfr) as dpfr,
|
||||
internet, can_access
|
||||
"""
|
||||
```
|
||||
|
||||
The `CAN_ACCESS` edge stays typed and directed (`-[:CAN_ACCESS]->`); that is its canonical sync-time orientation. Network-exposure queries extend the `RETURN` contract with `internet, can_access`.
|
||||
|
||||
## Working with List-Typed Properties
|
||||
|
||||
Some Cartography node properties carry a list of values: `AWSPolicyStatement.action`, `AWSPolicyStatement.resource`, `AWSPolicyStatement.notaction`, `AWSPolicyStatement.notresource`, `KMSKey.encryption_algorithms`, `CloudFrontDistribution.aliases`, the container-definition lists on `ECSContainerDefinition`, and many others. The graph models each such property as a set of child item nodes connected to the parent by a typed edge. Queries reach the values by traversing the edge; the parent does not carry the list as a single field.
|
||||
|
||||
### Naming Convention
|
||||
|
||||
For a list-typed parent property the sink stores:
|
||||
|
||||
- **Child label**: `<ParentLabel><PropertyPascal>Item`. Example: `AWSPolicyStatement.resource` becomes `AWSPolicyStatementResourceItem`.
|
||||
- **Edge type**: `HAS_<PROPERTY_UPPER>`. Example: `resource` becomes `HAS_RESOURCE`.
|
||||
- **Child property**: `value`, a single scalar string per list element. For list-of-dict properties (rare; for example `SecretsManagerSecretVersion.tags`) the child carries the original dict keys as named fields per the catalog's `field_map`.
|
||||
|
||||
### Variable Naming for Child-Item Matches
|
||||
|
||||
`aws.py` uses a per-query counter for each `HAS_*` traversal so chained matches stay unambiguous. The counter resets at the top of every query.
|
||||
|
||||
| Edge | First | Second | Third |
|
||||
| ----------------- | ------ | ------- | ------- |
|
||||
| `HAS_ACTION` | `act` | `act2` | `act3` |
|
||||
| `HAS_RESOURCE` | `res` | `res2` | `res3` |
|
||||
| `HAS_NOTACTION` | `nact` | `nact2` | `nact3` |
|
||||
| `HAS_NOTRESOURCE` | `nres` | `nres2` | `nres3` |
|
||||
|
||||
### Matching an Action
|
||||
|
||||
To find statements that grant `iam:PassRole`, `iam:*`, or `*`, traverse the `HAS_ACTION` edge in its own `MATCH` clause and apply the predicate in the attached `WHERE`:
|
||||
|
||||
```cypher
|
||||
MATCH (stmt:AWSPolicyStatement {effect: 'Allow'})
|
||||
MATCH (stmt)-[:HAS_ACTION]->(act:AWSPolicyStatementActionItem)
|
||||
WHERE toLower(act.value) IN ['iam:passrole', 'iam:*']
|
||||
OR act.value = '*'
|
||||
```
|
||||
|
||||
The literal-action list is case-folded with `toLower(act.value)` because IAM authors mix case (`iam:PassRole`, `iam:passrole`); the `*` wildcard never lower-cases.
|
||||
|
||||
### Matching a Resource Against a Target
|
||||
|
||||
To find statements whose resource can target a specific node, pre-aggregate the resource values and test the target against the list once (see [Avoiding Cartesian Products](#avoiding-cartesian-products)):
|
||||
|
||||
```cypher
|
||||
MATCH (stmt)-[:HAS_RESOURCE]->(res:AWSPolicyStatementResourceItem)
|
||||
WITH aws, path_principal, collect(DISTINCT res.value) AS res_values
|
||||
WITH aws, path_principal, res_values, ('*' IN res_values) AS res_wildcard
|
||||
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)
|
||||
WITH path_principal, path_target, res_values, res_wildcard,
|
||||
target_role.name AS rname, target_role.arn AS rarn
|
||||
WHERE res_wildcard
|
||||
OR size([rv IN res_values WHERE rv CONTAINS rname OR rarn CONTAINS rv]) > 0
|
||||
```
|
||||
|
||||
Three predicates cover the resource cases: full wildcard (`*`), a pattern containing the target name (`arn:aws:iam::*:role/admin*`), and a pattern that is a prefix or component of the actual ARN.
|
||||
|
||||
### Every-Item and Any-Item Predicates on a Custom Query
|
||||
|
||||
Custom queries can express list predicates directly with pattern comprehensions. To check whether *every* item satisfies a predicate, count the counter-examples and require zero, together with a guard that ensures at least one item is attached:
|
||||
|
||||
```cypher
|
||||
MATCH (stmt:AWSPolicyStatement)
|
||||
WHERE size([
|
||||
(stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem)
|
||||
WHERE NOT toLower(a.value) STARTS WITH 's3:'
|
||||
| a
|
||||
]) = 0
|
||||
AND size([(stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem) | a]) > 0
|
||||
RETURN stmt
|
||||
LIMIT 25
|
||||
```
|
||||
|
||||
To return the list of values directly, collect them from the child items:
|
||||
|
||||
```cypher
|
||||
MATCH (stmt:AWSPolicyStatement {effect: 'Allow'})
|
||||
OPTIONAL MATCH (stmt)-[:HAS_ACTION]->(a:AWSPolicyStatementActionItem)
|
||||
RETURN stmt, collect(a.value) AS actions
|
||||
LIMIT 25
|
||||
```
|
||||
|
||||
### Catalog of List Properties
|
||||
|
||||
The provider catalog lives in `api/src/backend/tasks/jobs/attack_paths/provider_config.py` (`AWS_NORMALIZED_LISTS`). Beyond policy statements it includes KMS algorithms, ECS container-definition lists (`entry_point`, `command`, `links`, `dns_servers`, and others), CloudFront aliases, Inspector finding URL and vulnerability lists, and RDS event-subscription categories. To query a list property that is not in the catalog, add an entry there first so the sync layer materializes it. Properties absent from the catalog are serialized to a comma-delimited string and emit a one-time warning during sync.
|
||||
|
||||
## Working with JSON-Encoded Properties
|
||||
|
||||
Some Cartography properties represent nested objects, most notably `condition` on `AWSPolicyStatement` and `S3PolicyStatement` nodes. To keep the schema portable across graph backends, object-typed properties are stored as JSON-encoded strings:
|
||||
|
||||
```
|
||||
'{"StringEquals":{"aws:SourceAccount":"123456789012"}}'
|
||||
```
|
||||
|
||||
No JSON parser is available at query time, so use `CONTAINS` for substring checks against keys or known values:
|
||||
|
||||
```cypher
|
||||
MATCH (stmt:AWSPolicyStatement)
|
||||
WHERE stmt.effect = 'Allow'
|
||||
AND stmt.condition CONTAINS '"aws:SourceAccount"'
|
||||
RETURN stmt
|
||||
LIMIT 25
|
||||
```
|
||||
|
||||
When a query needs to inspect the structured members of a condition (for example, to evaluate every operator and key), fetch the rows first and parse the JSON in application code. Cypher cannot navigate JSON object keys or values.
|
||||
|
||||
## openCypher Compatibility
|
||||
|
||||
Queries must run on both Neo4j and Amazon Neptune. Neptune implements a subset of Cypher, so several convenient constructs are unavailable. Avoid the following:
|
||||
|
||||
| Feature | Use instead |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| APOC procedures (`apoc.*`) | Real nodes and relationships in the graph |
|
||||
| Neptune extensions | Standard openCypher |
|
||||
| `any(x IN list ...)` | `size([x IN list WHERE pred]) > 0` |
|
||||
| `all(x IN list ...)` | `size([x IN list WHERE pred]) = size(list)` |
|
||||
| `none(x IN list ...)` | `size([x IN list WHERE pred]) = 0` |
|
||||
| `reduce()` | `UNWIND` plus `collect()` |
|
||||
| `FOREACH` | `WITH` plus `UNWIND` plus `SET` |
|
||||
| Regex `=~` | `toLower()` plus exact match, or `STARTS WITH` / `CONTAINS` |
|
||||
| `CALL () { UNION }` | Multi-label `OR` in `WHERE` |
|
||||
| Carried value plus aggregate expression | Project the aggregate first (`WITH principal_paths, collect(...) AS target_paths`), then combine lists in the next `WITH` |
|
||||
| `EXISTS { MATCH (pattern) WHERE pred }` | Standalone `MATCH (pattern)` plus `WHERE pred`; precede the downstream `collect(path...)` with `WITH DISTINCT <path-vars>` to dedupe the joins |
|
||||
|
||||
The carried-value-plus-aggregate rule is worth calling out because it is easy to hit. Neo4j 5.x rejects an expression that concatenates a carried list variable with an aggregate in the same projection:
|
||||
|
||||
```cypher
|
||||
// Rejected: "Aggregation column contains implicit grouping expressions"
|
||||
WITH principal_paths + collect(DISTINCT path_target) AS paths
|
||||
```
|
||||
|
||||
Split it into two `WITH` clauses so the aggregation resolves before the concatenation:
|
||||
|
||||
```cypher
|
||||
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
|
||||
WITH principal_paths + target_paths AS paths
|
||||
```
|
||||
|
||||
For list-typed properties in the catalog (action, resource, and so on), traverse the `HAS_*` edges to the child item nodes rather than reading a single field; `split(...)` and comma-string predicates do not apply.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Chain every MATCH from the account anchor.** An unanchored `MATCH (role:AWSRole)` returns roles from every provider in the graph; `MATCH (aws)--(role:AWSRole)` is scoped. A second-permission `MATCH` such as `MATCH (principal)--(policy2:AWSPolicy)--(stmt2:AWSPolicyStatement)` is safe because `principal` is already bound to the account subgraph.
|
||||
2. **Pre-aggregate resource lists before matching targets** to avoid Cartesian products (see [Avoiding Cartesian Products](#avoiding-cartesian-products)).
|
||||
3. **Type the finding probe.** Always `OPTIONAL MATCH (n)-[pfr:HAS_FINDING]-(pf:{PROWLER_FINDING_LABEL} {{status: 'FAIL'}})`. The type lets Neptune apply an inline edge filter; an untyped probe scans every incident edge of high-degree nodes.
|
||||
4. **Comment each MATCH.** One inline `// ...` line per clause explaining its role.
|
||||
5. **Never use internal labels.** `_ProviderResource`, `_AWSResource`, `_Tenant_*`, and `_Provider_*` are system isolation labels and must not appear in query text.
|
||||
6. **Reach the Internet node through path connectivity** with `(internet:Internet)-[:CAN_ACCESS]->(resource)`, never as a standalone match.
|
||||
7. **Preserve the RETURN contract.** `paths, dpf, dpfr` for the standard shape; add `internet, can_access` for network-exposure queries. The serializer and visualizer depend on these names.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- **ID**: kebab-case `{provider}-{category}-{description}`, e.g. `aws-ec2-privesc-passrole-iam`.
|
||||
- **Constant**: `UPPER_SNAKE_CASE` `{PROVIDER}_{CATEGORY}_{DESCRIPTION}`, e.g. `AWS_EC2_PRIVESC_PASSROLE_IAM`.
|
||||
|
||||
## Creating a New Query
|
||||
|
||||
New queries come from one of two input sources: a [pathfinding.cloud](https://github.com/DataDog/pathfinding.cloud) research ID (for example `ECS-001`, `GLUE-001`) or a natural-language description from the requester. The aggregated `paths.json` is too large to fetch whole; query a single path by ID:
|
||||
|
||||
```bash
|
||||
# Fetch a single path by ID
|
||||
curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \
|
||||
| jq '.[] | select(.id == "ecs-002")'
|
||||
|
||||
# List all path IDs and names
|
||||
curl -s https://raw.githubusercontent.com/DataDog/pathfinding.cloud/main/docs/paths.json \
|
||||
| jq -r '.[] | "\(.id): \(.name)"'
|
||||
```
|
||||
|
||||
Then follow these steps:
|
||||
|
||||
1. **Read the queries module first** to match the existing style:
|
||||
|
||||
```text
|
||||
api/src/backend/api/attack_paths/queries/
|
||||
├── __init__.py
|
||||
├── types.py # dataclass definitions
|
||||
├── registry.py
|
||||
└── {provider}.py
|
||||
```
|
||||
|
||||
2. **Fetch the Cartography schema for the pinned version.** Do not guess labels, properties, or relationships. See [The Graph Model](#the-graph-model).
|
||||
|
||||
3. **Build the query** from the canonical template plus the appropriate sub-pattern (privilege escalation or network exposure). Pre-aggregate resource lists, traverse `HAS_*` edges for list-typed properties, and keep the `RETURN` contract.
|
||||
|
||||
4. **Register** the constant in the `{PROVIDER}_QUERIES` list at the bottom of the provider file.
|
||||
|
||||
5. **Verify compatibility** against the [openCypher Compatibility](#opencypher-compatibility) rules, and confirm the query parses and runs on Neo4j before it reaches Neptune.
|
||||
|
||||
<Note>
|
||||
AI assistants connected through Prowler MCP Server can fetch the exact Cartography schema for the active scan with the `prowler_get_attack_paths_cartography_schema` tool, which guarantees that generated queries match the schema version pinned by the running Prowler release.
|
||||
</Note>
|
||||
|
||||
## Reference
|
||||
|
||||
- **pathfinding.cloud**: [github.com/DataDog/pathfinding.cloud](https://github.com/DataDog/pathfinding.cloud) (use `curl | jq`; the aggregated `paths.json` is too large for a single fetch).
|
||||
- **Cartography AWS schema**: [cartography-cncf.github.io/cartography/modules/aws/schema.html](https://cartography-cncf.github.io/cartography/modules/aws/schema.html).
|
||||
- **Neptune openCypher compliance**: [docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html](https://docs.aws.amazon.com/neptune/latest/userguide/feature-opencypher-compliance.html).
|
||||
- **Neptune openCypher rewrites**: [docs.aws.amazon.com/neptune/latest/userguide/migration-opencypher-rewrites.html](https://docs.aws.amazon.com/neptune/latest/userguide/migration-opencypher-rewrites.html).
|
||||
- **openCypher specification**: [github.com/opencypher/openCypher](https://github.com/opencypher/openCypher).
|
||||
@@ -39,7 +39,7 @@ The AWS provider implementation follows the general [Provider structure](/develo
|
||||
- **Key AWS Responsibilities:**
|
||||
- Receives an `AwsProvider` instance to access session, identity, and configuration.
|
||||
- Manages clients for all services by regions.
|
||||
- Provides `__threading_call__` method to make boto3 calls in parallel. By default, this calls are made by region, but it can be overridden with the first parameter of the method and use by resource.
|
||||
- Provides `__threading_call__` method to make boto3 calls in parallel. By default, these calls are made by region, but it can be overridden with the first parameter of the method and use by resource.
|
||||
- Exposes common audit context (`audited_account`, `audited_account_arn`, `audited_partition`, `audited_resources`) to subclasses.
|
||||
|
||||
### Exception Handling
|
||||
@@ -59,7 +59,7 @@ The generic service pattern is described in [service page](/developer-guide/serv
|
||||
- Directly in the code, in location [`prowler/providers/aws/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/aws/services)
|
||||
- In the [Prowler Hub](https://hub.prowler.com/). For a more human-readable view.
|
||||
|
||||
The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used accross all AWS services.
|
||||
The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used across all AWS services.
|
||||
|
||||
### AWS Service Common Patterns
|
||||
|
||||
@@ -68,7 +68,7 @@ The best reference to understand how to implement a new service is following the
|
||||
- The constructor (`__init__`) always calls `super().__init__` with the service name and provider (e.g. `super().__init__(__class__.__name__, provider))`). Ensure that the service name in boto3 is the same that you use in the constructor. Usually is used the `__class__.__name__` to get the service name because it is the same as the class name.
|
||||
- Resource containers **must** be initialized in the constructor. They should be dictionaries, with the key being the resource ARN or equivalent unique identifier and the value being the resource object.
|
||||
- Resource discovery and attribute collection are parallelized using `self.__threading_call__`, typically by region or resource, for performance. The first parameter of the method is the iterator, if not provided, it will be the region; but if present indicate an array of the resources to be processed.
|
||||
- Resource filtering is consistently enforced using `self.audit_resources` attribute and `is_resource_filtered` function, it is used to see if user has provided some resource that is not in the audit scope, so we can skip it in the service logic. Normally it is used befor storing the resource in the service container as follows: `if not self.audit_resources or (is_resource_filtered(resource["arn"], self.audit_resources)):`.
|
||||
- Resource filtering is consistently enforced using `self.audit_resources` attribute and `is_resource_filtered` function, it is used to see if user has provided some resource that is not in the audit scope, so we can skip it in the service logic. Normally it is used before storing the resource in the service container as follows: `if not self.audit_resources or (is_resource_filtered(resource["arn"], self.audit_resources)):`.
|
||||
- All AWS resources are represented as Pydantic `BaseModel` classes, providing type safety and structured access to resource attributes.
|
||||
- AWS API calls are wrapped in try/except blocks, with specific handling for `ClientError` and generic exceptions, always logging errors.
|
||||
- If ARN is not present for some resource, it can be constructed using string interpolation, always including partition, service, region, account, and resource ID.
|
||||
@@ -162,7 +162,7 @@ When you instantiate `Check_Report_AWS`, you must provide the check metadata and
|
||||
|
||||
If the resource object does not contain the required attributes, you must set them manually in the check logic.
|
||||
|
||||
Other attributes are inherited from the `Check_Report` class, from that ones you **always** have to set the `status` and `status_extended` attributes in the check logic.
|
||||
Other attributes are inherited from the `Check_Report` class, from those you **always** have to set the `status` and `status_extended` attributes in the check logic.
|
||||
|
||||
#### Example Usage
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ The generic service pattern is described in [service page](/developer-guide/serv
|
||||
- Directly in the code, in location [`prowler/providers/azure/services/`](https://github.com/prowler-cloud/prowler/tree/master/prowler/providers/azure/services)
|
||||
- In the [Prowler Hub](https://hub.prowler.com/) for a more human-readable view.
|
||||
|
||||
The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used accross all Azure services.
|
||||
The best reference to understand how to implement a new service is following the [service implementation documentation](/developer-guide/services#adding-a-new-service) and taking other services already implemented as reference. In next subsection you can find a list of common patterns that are used across all Azure services.
|
||||
|
||||
### Azure Service Common Patterns
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ else:
|
||||
|
||||
### Resource Identification in Prowler
|
||||
|
||||
Each check **must** populate the report with an unique identifier for the audited resource. This identifier or identifiers are going to depend on the provider and the resource that is being audited. Here are the criteria for each provider:
|
||||
Each check **must** populate the report with a unique identifier for the audited resource. This identifier or identifiers are going to depend on the provider and the resource that is being audited. Here are the criteria for each provider:
|
||||
|
||||
- AWS
|
||||
- Amazon Resource ID — `report.resource_id`.
|
||||
|
||||
@@ -133,6 +133,7 @@ Only fields with a numeric range, a fixed value set, or a length cap are listed.
|
||||
| `max_unused_sagemaker_access_days` | `7..180` days | |
|
||||
| `max_security_group_rules` | `1..1000` | AWS hard limit is 1000 rules per security group |
|
||||
| `max_ec2_instance_age_in_days` | `1..1095` days | 3 years |
|
||||
| `max_ec2_instance_stopped_days` | `1..1095` days | 3 years |
|
||||
| `ec2_high_risk_ports` | each port `1..65535` | port 0 is reserved |
|
||||
| `max_idle_disconnect_timeout_in_seconds` | `60..1800` s | NIST AC-12: cap at 30 min |
|
||||
| `max_disconnect_timeout_in_seconds` | `60..3600` s | |
|
||||
|
||||
@@ -36,6 +36,9 @@ The former build-time variables map to the new runtime variables as follows:
|
||||
| `NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID` | `UI_GOOGLE_TAG_MANAGER_ID` |
|
||||
| `NEXT_PUBLIC_SENTRY_DSN`, `SENTRY_DSN` | `UI_SENTRY_DSN` |
|
||||
| `NEXT_PUBLIC_SENTRY_ENVIRONMENT`, `SENTRY_ENVIRONMENT` | `UI_SENTRY_ENVIRONMENT` |
|
||||
| `NEXT_PUBLIC_IS_CLOUD_ENV` | `UI_CLOUD_ENABLED` |
|
||||
|
||||
`UI_CLOUD_ENABLED` is a plain runtime boolean flag that enables Prowler Cloud behavior when set to the exact string `"true"` and defaults to off; unlike the other renamed variables it has no legacy fallback, so `NEXT_PUBLIC_IS_CLOUD_ENV` is no longer read.
|
||||
|
||||
The build-time-only Sentry variables used for source-map upload — `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN`, and `SENTRY_RELEASE` — keep their names, as they are not part of Prowler Local Server's runtime configuration.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user