mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-20 10:00:40 +00:00
Compare commits
204
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7d96df300 | ||
|
|
5e60402469 | ||
|
|
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 | ||
|
|
0d899b3076 | ||
|
|
3fd9fca32f | ||
|
|
07d7e9d5bb | ||
|
|
31b8ab9b4b | ||
|
|
60c4e9b257 | ||
|
|
8e9af708f8 | ||
|
|
24b670ac36 | ||
|
|
cc6c731af6 | ||
|
|
c53acd4184 | ||
|
|
faa74f4c6c | ||
|
|
077dff7f68 | ||
|
|
a4752d6a27 | ||
|
|
a9f6e04a84 | ||
|
|
d9224e682f | ||
|
|
9822fd97d7 | ||
|
|
bd72ec91ea | ||
|
|
fa9b2c707b | ||
|
|
22401a54a0 | ||
|
|
9c15796c84 | ||
|
|
db9356ba86 | ||
|
|
54237d824a | ||
|
|
7df1054966 | ||
|
|
53772e2931 | ||
|
|
d37d5058bb | ||
|
|
8debf70d5c | ||
|
|
1a1e804c00 | ||
|
|
c7583a5633 | ||
|
|
470e218bb8 | ||
|
|
488f3d1bed | ||
|
|
0f6137dd04 | ||
|
|
f5f6e5768d | ||
|
|
c6dae3a711 | ||
|
|
4242297e72 | ||
|
|
8d4be0f586 | ||
|
|
d78e0189e0 | ||
|
|
cef131812c | ||
|
|
3c78a0df43 | ||
|
|
991e8b8061 | ||
|
|
dbdbd8a379 | ||
|
|
a0a0883578 | ||
|
|
a4bf70eecf | ||
|
|
5bc41b2609 | ||
|
|
58fd4170b5 | ||
|
|
3369e48260 | ||
|
|
106026614d | ||
|
|
3f2e5929d9 | ||
|
|
c93ea035fc | ||
|
|
847997672d | ||
|
|
08ee83c572 | ||
|
|
892cc2bc07 | ||
|
|
2a077cae5e | ||
|
|
1ee4de18be | ||
|
|
b44f8ebf5f | ||
|
|
6f72785a28 | ||
|
|
be78fe8374 | ||
|
|
01c004a7af | ||
|
|
0b36d08b92 | ||
|
|
bf9c53a4c3 | ||
|
|
80c5363649 | ||
|
|
9d899ae0e2 | ||
|
|
13bd6fc0bf | ||
|
|
18b3c49234 | ||
|
|
84ea68927f | ||
|
|
1af9cdd351 | ||
|
|
25bcbac309 | ||
|
|
20aad80a78 | ||
|
|
52875b5c7c | ||
|
|
c665005790 | ||
|
|
b2532ebfe5 | ||
|
|
3461f9ac49 | ||
|
|
d874fc573d | ||
|
|
991c204a88 | ||
|
|
eee17f0e8b | ||
|
|
80608bfdbc | ||
|
|
6c5f54808d | ||
|
|
bb6608c1d7 | ||
|
|
18e1bf5195 |
@@ -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
|
||||
@@ -146,7 +146,7 @@ DJANGO_SENTRY_DSN=
|
||||
DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute
|
||||
|
||||
# Sentry for the web app (server + browser). The UI_SENTRY_* values load only
|
||||
# when UI_SENTRY_ENABLE="true"; without it they are ignored (default off, zero
|
||||
# when UI_SENTRY_ENABLED="true"; without it they are ignored (default off, zero
|
||||
# egress). The deprecated NEXT_PUBLIC_SENTRY_DSN still activates Sentry without
|
||||
# the flag. SENTRY_RELEASE (unprefixed) feeds the web app's server/edge SDKs.
|
||||
UI_SENTRY_DSN=
|
||||
@@ -158,7 +158,7 @@ SENTRY_RELEASE=local
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.33.2
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.37.0
|
||||
|
||||
# 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'
|
||||
|
||||
@@ -5,10 +5,20 @@
|
||||
"version": "v8",
|
||||
"sha": "ed597411d8f924073f98dfc5c65a23a2325f34cd"
|
||||
},
|
||||
"github/gh-aw-actions/setup@v0.81.6": {
|
||||
"repo": "github/gh-aw-actions/setup",
|
||||
"version": "v0.81.6",
|
||||
"sha": "ba6380cc6e5be5d21677bebe04d52fb48e3abec7"
|
||||
},
|
||||
"github/gh-aw/actions/setup@v0.43.23": {
|
||||
"repo": "github/gh-aw/actions/setup",
|
||||
"version": "v0.43.23",
|
||||
"sha": "9382be3ca9ac18917e111a99d4e6bbff58d0dccc"
|
||||
},
|
||||
"step-security/harden-runner@v2.20.0": {
|
||||
"repo": "step-security/harden-runner",
|
||||
"version": "v2.20.0",
|
||||
"sha": "bf7454d06d71f1098171f2acdf0cd4708d7b5920"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ Please add a detailed description of how to review this PR.
|
||||
|
||||
- [ ] This feature/issue is listed in the [open issues](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or roadmap.prowler.com
|
||||
- [ ] Is it assigned to me, if not, request it via the [open issues](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or [Prowler Community Slack](https://goto.prowler.com/slack)
|
||||
- [ ] I have reviewed the [open pull requests](https://github.com/prowler-cloud/prowler/pulls?q=sort%3Aupdated-desc+is%3Apr+is%3Aopen) and confirmed there is no existing PR that implements the same outcome
|
||||
|
||||
</details>
|
||||
|
||||
@@ -27,7 +28,7 @@ Please add a detailed description of how to review this PR.
|
||||
- [ ] Review if the code is being covered by tests.
|
||||
- [ ] Review if code is being documented following this specification https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings
|
||||
- [ ] Review if backport is needed.
|
||||
- [ ] Review if is needed to change the [Readme.md](https://github.com/prowler-cloud/prowler/blob/master/README.md)
|
||||
- [ ] Review if is needed to change the [README.md](https://github.com/prowler-cloud/prowler/blob/master/README.md)
|
||||
- [ ] Ensure a changelog fragment is added under [prowler/changelog.d/](https://github.com/prowler-cloud/prowler/tree/master/prowler/changelog.d), if applicable.
|
||||
|
||||
#### SDK/CLI
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
"prConcurrentLimit": 20,
|
||||
"prHourlyLimit": 10,
|
||||
"vulnerabilityAlerts": {
|
||||
"labels": [
|
||||
"dependencies",
|
||||
"security"
|
||||
],
|
||||
"prHourlyLimit": 0,
|
||||
"prConcurrentLimit": 0
|
||||
},
|
||||
@@ -60,6 +64,13 @@
|
||||
],
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"description": "gh-aw compiled lock files - generated by 'gh aw compile', action pins must match the compiler version, never bump directly",
|
||||
"matchFileNames": [
|
||||
".github/workflows/*.lock.yml"
|
||||
],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": "GitHub Actions - single grouped PR, no changelog, scope=ci",
|
||||
"matchManagers": [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -92,6 +92,8 @@ jobs:
|
||||
_http._tcp.deb.debian.org:443
|
||||
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
|
||||
get.trivy.dev:443
|
||||
raw.githubusercontent.com:443
|
||||
releases.astral.sh:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -103,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
|
||||
@@ -111,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
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -107,6 +107,7 @@ jobs:
|
||||
files: |
|
||||
api/**
|
||||
.github/workflows/api-tests.yml
|
||||
codecov.yml
|
||||
files_ignore: |
|
||||
api/docs/**
|
||||
api/README.md
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
patch_version: ${{ steps.detect.outputs.patch_version }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -202,7 +202,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -307,7 +307,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
name: 'Tools: Check Test Init Files'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
check-test-init-files:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for __init__.py files in test directories
|
||||
run: python3 scripts/check_test_init_files.py .
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Block outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: 'Docs: Check Provider Cards Snippet'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'docs/user-guide/providers/**/getting-started-*.mdx'
|
||||
- 'docs/scripts/generate_provider_cards.py'
|
||||
- 'docs/snippets/provider-cards.mdx'
|
||||
- 'api/src/backend/api/models.py'
|
||||
- '.github/workflows/docs-check-provider-cards.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
check-provider-cards:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify provider cards snippet is up to date
|
||||
run: |
|
||||
if ! python3 docs/scripts/generate_provider_cards.py; then
|
||||
echo "::error::docs/snippets/provider-cards.mdx is out of sync with the provider getting-started pages or the API ProviderChoices enum."
|
||||
echo "Run 'python3 docs/scripts/generate_provider_cards.py' locally and commit the regenerated snippet."
|
||||
echo "--- diff ---"
|
||||
git diff docs/snippets/provider-cards.mdx
|
||||
exit 1
|
||||
fi
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
# We can't block as Trufflehog needs to verify secrets against vendors
|
||||
egress-policy: audit
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
Generated
+1301
-703
File diff suppressed because one or more lines are too long
@@ -12,8 +12,8 @@ if: contains(toJson(github.event.issue.labels), 'status/needs-triage')
|
||||
|
||||
timeout-minutes: 12
|
||||
|
||||
rate-limit:
|
||||
max: 5
|
||||
user-rate-limit:
|
||||
max-runs-per-window: 5
|
||||
window: 60
|
||||
|
||||
concurrency:
|
||||
@@ -30,6 +30,12 @@ permissions:
|
||||
engine: copilot
|
||||
strict: false
|
||||
|
||||
pre-steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
imports:
|
||||
- ../agents/issue-triage.md
|
||||
|
||||
@@ -108,7 +114,7 @@ Triage the following GitHub issue using the Prowler Issue Triage Agent persona.
|
||||
|
||||
## Sanitized Issue Content
|
||||
|
||||
${{ needs.activation.outputs.text }}
|
||||
${{ steps.sanitized.outputs.text }}
|
||||
|
||||
## Instructions
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Add community label
|
||||
if: steps.check_membership.outputs.is_member == 'false'
|
||||
if: steps.check_membership.outputs.is_member == 'false' && github.event.pull_request.user.type != 'Bot'
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -165,7 +165,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -227,7 +227,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -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
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -25,14 +25,21 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Enable release freeze
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh variable set RELEASE_FREEZE --body true --repo "${GITHUB_REPOSITORY}"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
|
||||
@@ -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
|
||||
@@ -28,12 +28,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.github.com:443
|
||||
github.com:443
|
||||
raw.githubusercontent.com:443
|
||||
objects.githubusercontent.com:443
|
||||
codeload.github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
@@ -41,6 +42,7 @@ jobs:
|
||||
files.pythonhosted.org:443
|
||||
registry.npmjs.org:443
|
||||
nodejs.org:443
|
||||
releases.astral.sh:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
|
||||
with:
|
||||
aws-region: us-east-1
|
||||
role-to-assume: ${{ secrets.PUBLIC_ECR_IAM_ROLE_ARN }}
|
||||
role-to-assume: ${{ secrets.PUBLIC_ECR_PUSH_ROLE_ARN }}
|
||||
|
||||
- name: Login to Public ECR
|
||||
uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2.1.6
|
||||
@@ -215,7 +215,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -241,7 +241,7 @@ jobs:
|
||||
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
|
||||
with:
|
||||
aws-region: us-east-1
|
||||
role-to-assume: ${{ secrets.PUBLIC_ECR_IAM_ROLE_ARN }}
|
||||
role-to-assume: ${{ secrets.PUBLIC_ECR_PUSH_ROLE_ARN }}
|
||||
|
||||
- name: Login to Public ECR
|
||||
uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2.1.6
|
||||
@@ -330,7 +330,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -94,6 +94,8 @@ jobs:
|
||||
_http._tcp.deb.debian.org:443
|
||||
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
|
||||
get.trivy.dev:443
|
||||
raw.githubusercontent.com:443
|
||||
releases.astral.sh:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -111,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/**
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -615,6 +615,30 @@ jobs:
|
||||
flags: prowler-py${{ matrix.python-version }}-linode
|
||||
files: ./linode_coverage.xml
|
||||
|
||||
# E2E Networks Provider
|
||||
- name: Check if E2E Networks files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
id: changed-e2enetworks
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
./prowler/**/e2enetworks/**
|
||||
./tests/**/e2enetworks/**
|
||||
./uv.lock
|
||||
|
||||
- name: Run E2E Networks tests
|
||||
if: steps.changed-e2enetworks.outputs.any_changed == 'true'
|
||||
run: uv run pytest -n auto --cov=./prowler/providers/e2enetworks --cov-report=xml:e2enetworks_coverage.xml tests/providers/e2enetworks
|
||||
|
||||
- name: Upload E2E Networks coverage to Codecov
|
||||
if: steps.changed-e2enetworks.outputs.any_changed == 'true'
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
with:
|
||||
flags: prowler-py${{ matrix.python-version }}-e2enetworks
|
||||
files: ./e2enetworks_coverage.xml
|
||||
|
||||
# External Provider (dynamic loading)
|
||||
- name: Check if External Provider files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -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:
|
||||
@@ -96,7 +97,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -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
|
||||
@@ -316,7 +438,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -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/
|
||||
|
||||
@@ -144,7 +144,7 @@ repos:
|
||||
|
||||
- id: generate-provider-cards
|
||||
name: "Docs - regenerate provider cards snippet"
|
||||
entry: python docs/scripts/generate_provider_cards.py
|
||||
entry: python3 docs/scripts/generate_provider_cards.py
|
||||
language: system
|
||||
files: { glob: ["docs/user-guide/providers/**/getting-started-*.mdx", "docs/scripts/generate_provider_cards.py", "docs/snippets/provider-cards.mdx", "api/src/backend/api/models.py"] }
|
||||
pass_filenames: false
|
||||
|
||||
+47
-22
@@ -15,21 +15,46 @@
|
||||
# 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-07-15
|
||||
CVE-2026-42496 pkg:perl-base exp:2026-07-15
|
||||
CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-07-15
|
||||
CVE-2026-42496 pkg:libperl5.36 exp:2026-07-15
|
||||
CVE-2026-8376 pkg:perl exp:2026-07-15
|
||||
CVE-2026-8376 pkg:perl-base exp:2026-07-15
|
||||
CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-07-15
|
||||
CVE-2026-8376 pkg:libperl5.36 exp:2026-07-15
|
||||
CVE-2026-42496 pkg:perl exp:2026-08-15
|
||||
CVE-2026-42496 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-42496 pkg:libperl5.36 exp:2026-08-15
|
||||
CVE-2026-8376 pkg:perl exp:2026-08-15
|
||||
CVE-2026-8376 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-8376 pkg:libperl5.36 exp:2026-08-15
|
||||
|
||||
# CVE-2026-13221 - Perl regex trie overflow.
|
||||
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
|
||||
# Why ignored: upstream confirms Perl 5.36.0 is not affected; the regression
|
||||
# was introduced after this version. Debian currently marks bookworm as
|
||||
# vulnerable, which causes Trivy to report a false positive.
|
||||
# Ref: https://github.com/Perl/perl5/issues/23388
|
||||
CVE-2026-13221 pkg:perl exp:2026-08-15
|
||||
CVE-2026-13221 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-13221 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-13221 pkg:libperl5.36 exp:2026-08-15
|
||||
|
||||
# CVE-2026-57433 — Perl Storable signed integer overflow when deserializing a
|
||||
# crafted SX_HOOK record (retrieve_hook_common passes a wrapped negative count
|
||||
# to av_extend).
|
||||
# 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. Prowler does not invoke perl at runtime and
|
||||
# never calls Storable's thaw/retrieve on attacker-controlled blobs, so the
|
||||
# vulnerable deserialization path is unreachable. Fixed upstream in
|
||||
# Storable 3.41; no Debian bookworm fix is available yet.
|
||||
CVE-2026-57433 pkg:perl exp:2026-08-15
|
||||
CVE-2026-57433 pkg:perl-base exp:2026-08-15
|
||||
CVE-2026-57433 pkg:perl-modules-5.36 exp:2026-08-15
|
||||
CVE-2026-57433 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-07-15
|
||||
CVE-2025-7458 pkg:libsqlite3-0 exp:2026-08-15
|
||||
|
||||
# CVE-2026-43185 — Linux kernel ksmbd signedness bug.
|
||||
# Package: linux-libc-dev.
|
||||
@@ -37,7 +62,7 @@ CVE-2025-7458 pkg:libsqlite3-0 exp:2026-07-15
|
||||
# not a running kernel. Containers execute against the host kernel, so these
|
||||
# headers are inert at runtime. The upstream fix landed in kernel 7.0-rc2 and
|
||||
# has not been backported to Debian's 6.1 LTS line.
|
||||
CVE-2026-43185 pkg:linux-libc-dev exp:2026-07-15
|
||||
CVE-2026-43185 pkg:linux-libc-dev exp:2026-08-15
|
||||
|
||||
# CVE-2023-45853 — zlib MiniZip integer overflow / heap overflow in
|
||||
# zipOpenNewFileInZip4_64.
|
||||
@@ -49,8 +74,8 @@ CVE-2026-43185 pkg:linux-libc-dev exp:2026-07-15
|
||||
# zlib 1.3.1, available in Debian trixie (13); migrating the base image would
|
||||
# clear it fully.
|
||||
# Ref: https://security-tracker.debian.org/tracker/CVE-2023-45853
|
||||
CVE-2023-45853 pkg:zlib1g exp:2026-07-15
|
||||
CVE-2023-45853 pkg:zlib1g-dev exp:2026-07-15
|
||||
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).
|
||||
@@ -63,7 +88,7 @@ CVE-2023-45853 pkg:zlib1g-dev exp:2026-07-15
|
||||
# affected code is unreachable at runtime. Fixed upstream in libssh2 commit
|
||||
# 97acf3df (PR #2052); no Debian bookworm fix is available yet.
|
||||
# Ref: https://security-tracker.debian.org/tracker/CVE-2026-55200
|
||||
CVE-2026-55200 pkg:libssh2-1 exp:2026-07-15
|
||||
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
|
||||
@@ -78,13 +103,13 @@ CVE-2026-55200 pkg:libssh2-1 exp:2026-07-15
|
||||
# at runtime. The vulnerable path requires parsing attacker-controlled XML with
|
||||
# the affected interpreter, which Prowler does not do with the system Python.
|
||||
# Full mitigation also needs libexpat >= 2.8.0; no Debian bookworm fix yet.
|
||||
CVE-2026-7210 pkg:python3.11 exp:2026-07-15
|
||||
CVE-2026-7210 pkg:python3.11-dev exp:2026-07-15
|
||||
CVE-2026-7210 pkg:python3.11-minimal exp:2026-07-15
|
||||
CVE-2026-7210 pkg:libpython3.11 exp:2026-07-15
|
||||
CVE-2026-7210 pkg:libpython3.11-dev exp:2026-07-15
|
||||
CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-07-15
|
||||
CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-07-15
|
||||
CVE-2026-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.
|
||||
@@ -94,5 +119,5 @@ CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-07-15
|
||||
# vulnerabilities require operating a live Unbound recursive DNSSEC validator
|
||||
# that processes attacker-influenced DNS responses. Prowler never starts an
|
||||
# Unbound resolver, so neither code path is reachable. No Debian bookworm fix yet.
|
||||
CVE-2026-33278 pkg:libunbound8 exp:2026-07-15
|
||||
CVE-2026-42960 pkg:libunbound8 exp:2026-07-15
|
||||
CVE-2026-33278 pkg:libunbound8 exp:2026-08-15
|
||||
CVE-2026-42960 pkg:libunbound8 exp:2026-08-15
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -5,6 +5,8 @@ LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
|
||||
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
|
||||
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
|
||||
ENV POWERSHELL_TELEMETRY_OPTOUT=1
|
||||
|
||||
ARG TRIVY_VERSION=0.71.2
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
<img align="center" alt="Prowler logo" src="https://github.com/prowler-cloud/prowler/blob/master/docs/img/prowler-logo-white.png#gh-dark-mode-only" width="50%" height="50%">
|
||||
</p>
|
||||
<p align="center">
|
||||
<b><i>Prowler</b> is the Open Cloud Security Platform trusted by thousands to automate security and compliance in any cloud environment. With hundreds of ready-to-use checks and compliance frameworks, Prowler delivers real-time, customizable monitoring and seamless integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.
|
||||
<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">
|
||||
@@ -21,7 +24,7 @@
|
||||
<a href="https://pypi.python.org/pypi/prowler/"><img alt="Python Version" src="https://img.shields.io/pypi/pyversions/prowler.svg"></a>
|
||||
<a href="https://pypistats.org/packages/prowler"><img alt="PyPI Downloads" src="https://img.shields.io/pypi/dw/prowler.svg?label=downloads"></a>
|
||||
<a href="https://hub.docker.com/r/toniblyx/prowler"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/toniblyx/prowler"></a>
|
||||
<a href="https://gallery.ecr.aws/prowler-cloud/prowler"><img width="120" height=19" alt="AWS ECR Gallery" src="https://user-images.githubusercontent.com/3985464/151531396-b6535a68-c907-44eb-95a1-a09508178616.png"></a>
|
||||
<a href="https://gallery.ecr.aws/prowler-cloud/prowler"><img width="120" height="19" alt="AWS ECR Gallery" src="https://user-images.githubusercontent.com/3985464/151531396-b6535a68-c907-44eb-95a1-a09508178616.png"></a>
|
||||
<a href="https://codecov.io/gh/prowler-cloud/prowler"><img alt="Codecov coverage" src="https://codecov.io/gh/prowler-cloud/prowler/graph/badge.svg?token=OflBGsdpDl"/></a>
|
||||
<a href="https://insights.linuxfoundation.org/project/prowler-cloud-prowler"><img alt="Linux Foundation insights health score" src="https://insights.linuxfoundation.org/api/badge/health-score?project=prowler-cloud-prowler"/></a>
|
||||
</p>
|
||||
@@ -41,7 +44,7 @@
|
||||
|
||||
# Description
|
||||
|
||||
**Prowler** is the world’s most widely used _Open-Source Cloud Security Platform_ that automates security and compliance across **any cloud environment**. With hundreds of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler is built to _“Secure ANY Cloud at AI Speed”_. Prowler delivers **AI-driven**, **customizable**, and **easy-to-use** assessments, dashboards, reports, and integrations, making cloud security **simple**, **scalable**, and **cost-effective** for organizations of any size.
|
||||
**Prowler** is the world’s most widely used _Open-Source Cloud Security Platform_ that automates security and compliance across **any cloud environment**. With thousands of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler is built to _“Secure ANY Cloud at AI Speed”_. Prowler delivers **AI-driven**, **customizable**, and **easy-to-use** assessments, dashboards, reports, and integrations, making cloud security **simple**, **scalable**, and **cost-effective** for organizations of any size.
|
||||
|
||||
Prowler includes hundreds of built-in controls to ensure compliance with standards and frameworks, including:
|
||||
|
||||
@@ -54,16 +57,16 @@ Prowler includes hundreds of built-in controls to ensure compliance with standar
|
||||
- **National Security Standards:** ENS (Spanish National Security Scheme) and KISA ISMS-P (Korean)
|
||||
- **Custom Security Frameworks:** Tailored to your needs
|
||||
|
||||
## Prowler App / Prowler Cloud
|
||||
## Prowler Cloud & Prowler Local Server
|
||||
|
||||
Prowler App / [Prowler Cloud](https://cloud.prowler.com/) is a web-based application that simplifies running Prowler across your cloud provider accounts. It provides a user-friendly interface to visualize the results and streamline your security assessments.
|
||||
[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.
|
||||
|
||||

|
||||

|
||||

|
||||

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

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

|
||||

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

|
||||
```mermaid
|
||||
flowchart TB
|
||||
user([User / Security Team])
|
||||
cli([Prowler CLI])
|
||||
|
||||
<!-- Diagram source: docs/images/products/prowler-app-architecture.mmd — edit there, re-render at https://mermaid.live, and replace the PNG. -->
|
||||
subgraph APP["Prowler Local Server"]
|
||||
ui["Prowler UI<br/>(Next.js)"]
|
||||
api["Prowler API<br/>(Django REST Framework)"]
|
||||
worker["API Worker<br/>(Celery)"]
|
||||
beat["API Scheduler<br/>(Celery Beat)"]
|
||||
mcp["Prowler MCP Server<br/>(Lighthouse AI tools)"]
|
||||
end
|
||||
|
||||
sdk["Prowler SDK<br/>(Python)"]
|
||||
|
||||
subgraph DATA["Data Layer"]
|
||||
pg[("PostgreSQL")]
|
||||
valkey[("Valkey / Redis")]
|
||||
neo4j[("Neo4j")]
|
||||
end
|
||||
|
||||
providers["Providers"]
|
||||
|
||||
user --> ui
|
||||
user --> cli
|
||||
ui -->|REST| api
|
||||
ui -->|MCP HTTP| mcp
|
||||
mcp -->|REST| api
|
||||
api --> pg
|
||||
api --> valkey
|
||||
beat -->|enqueue jobs| valkey
|
||||
valkey -->|dispatch| worker
|
||||
worker --> pg
|
||||
worker -->|Attack Paths| neo4j
|
||||
worker -->|invokes| sdk
|
||||
cli --> sdk
|
||||
|
||||
sdk --> providers
|
||||
```
|
||||
|
||||
<!-- Diagram source: docs/images/products/prowler-app-architecture.mmd — keep this inline block, the docs page getting-started/products/prowler-app.mdx, and the .mmd file in sync. -->
|
||||
|
||||
|
||||
## Prowler CLI
|
||||
|
||||
@@ -4,6 +4,57 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [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
|
||||
|
||||
- `rls_transaction` now falls back directly to the primary DB for connection-level mid-query read replica failures via `execute_wrapper`, reducing non-streaming read crashes during replica recovery [(#10379)](https://github.com/prowler-cloud/prowler/pull/10379)
|
||||
- RBAC permission gates now combine permissions from every role assigned to a user in the active tenant [(#11979)](https://github.com/prowler-cloud/prowler/pull/11979)
|
||||
- `attack-paths-cleanup-stale-scans` now retries worker pings and checks recent scan activity before failing scans and removing temporary databases [(#11986)](https://github.com/prowler-cloud/prowler/pull/11986)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- User role relationship updates are limited to the active tenant to preserve role assignments in other tenants [(#11903)](https://github.com/prowler-cloud/prowler/pull/11903)
|
||||
- `api` container image removes the unused Debian `libxml2` runtime package and scopes the `CVE-2026-13221` Trivy exception to unaffected Perl 5.36 packages [(#11991)](https://github.com/prowler-cloud/prowler/pull/11991)
|
||||
|
||||
---
|
||||
|
||||
## [1.34.2] (Prowler v5.33.2)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
+4
-1
@@ -102,7 +102,9 @@ 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
|
||||
|
||||
@@ -113,6 +115,7 @@ RUN apt-get purge -y --auto-remove \
|
||||
make \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libxmlsec1-openssl \
|
||||
pkg-config \
|
||||
libtool \
|
||||
libxslt1-dev \
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
Attack Paths adds 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)
|
||||
@@ -0,0 +1 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
`task_args` serialization no longer returns HTTP 500 errors when Celery truncates stored task keyword arguments
|
||||
@@ -0,0 +1 @@
|
||||
Provider deletion and connection checks, scan creation, provider secrets, provider groups, and daily schedules now respect role provider-group visibility
|
||||
@@ -0,0 +1 @@
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
AWS Security Hub integrations now persist successful connection checks during finding delivery so their connection status and last checked timestamp stay current
|
||||
@@ -0,0 +1 @@
|
||||
Social signups create users and authentication records in one database transaction, preventing incomplete accounts when provisioning fails
|
||||
+2
-2
@@ -45,7 +45,7 @@ dependencies = [
|
||||
"gunicorn==26.0.0",
|
||||
"uvloop==0.22.1",
|
||||
"lxml==6.1.0",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.33",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.34.2"
|
||||
version = "1.38.0"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
|
||||
@@ -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,
|
||||
@@ -66,7 +66,7 @@ READ_EXCEPTION_CODES = [
|
||||
"Neo.ClientError.Procedure.ProcedureNotFound",
|
||||
]
|
||||
CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement."
|
||||
RETRYABLE_WRITE_ERROR_PREFIXES = (
|
||||
RETRYABLE_WRITE_ERROR_FRAGMENTS = (
|
||||
"Operation failed due to conflicting concurrent operations",
|
||||
"Operation terminated (deadline exceeded)",
|
||||
)
|
||||
@@ -78,12 +78,15 @@ SIGV4_TOKEN_LIFETIME_MINUTES = 4
|
||||
def _is_retryable_write_error(exc: Exception) -> bool:
|
||||
if not isinstance(exc, neo4j.exceptions.Neo4jError):
|
||||
return False
|
||||
return bool(exc.message and exc.message.startswith(RETRYABLE_WRITE_ERROR_PREFIXES))
|
||||
message = exc.message or ""
|
||||
return any(fragment in message for fragment in RETRYABLE_WRITE_ERROR_FRAGMENTS)
|
||||
|
||||
|
||||
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
|
||||
@@ -205,6 +208,7 @@ class NeptuneSink(SinkDatabase):
|
||||
from api.attack_paths.database import (
|
||||
ClientStatementException,
|
||||
GraphDatabaseQueryException,
|
||||
NeptuneWriteRetryExhaustedException,
|
||||
WriteQueryNotAllowedException,
|
||||
)
|
||||
|
||||
@@ -226,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
|
||||
@@ -290,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,
|
||||
)
|
||||
@@ -329,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
|
||||
@@ -348,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:
|
||||
@@ -152,10 +171,10 @@ def execute_custom_query(
|
||||
scan: AttackPathsScan,
|
||||
) -> dict[str, Any]:
|
||||
# Defense-in-depth for custom queries:
|
||||
# 1. `neo4j.READ_ACCESS` — prevents mutations at the driver level
|
||||
# 2. `inject_provider_label()` — regex-based label injection scopes node patterns
|
||||
# 3. `_serialize_graph()` — post-query filter drops nodes without the provider label
|
||||
# 4. `USING QUERY:TIMEOUTMILLISECONDS` on Neptune — server-side runaway cutoff
|
||||
# 1. `neo4j.READ_ACCESS` - prevents mutations at the driver level
|
||||
# 2. `inject_provider_label()` - regex-based label injection scopes node patterns
|
||||
# 3. `_serialize_graph()` - post-query filter drops nodes without the provider label
|
||||
# 4. `USING QUERY:TIMEOUTMILLISECONDS` on Neptune - server-side runaway cutoff
|
||||
#
|
||||
# Layer 2 is best-effort (regex can't fully parse Cypher);
|
||||
# layer 3 is the safety net that guarantees provider isolation.
|
||||
|
||||
@@ -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
|
||||
|
||||
+273
-50
@@ -2,7 +2,7 @@ import re
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from contextlib import ExitStack, contextmanager, nullcontext
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from api.db_router import (
|
||||
@@ -48,6 +48,140 @@ REPLICA_MAX_ATTEMPTS = env.int("POSTGRES_REPLICA_MAX_ATTEMPTS", default=3)
|
||||
REPLICA_RETRY_BASE_DELAY = env.float("POSTGRES_REPLICA_RETRY_BASE_DELAY", default=0.5)
|
||||
|
||||
SET_CONFIG_QUERY = "SELECT set_config(%s, %s::text, TRUE);"
|
||||
SET_TRANSACTION_READ_ONLY_QUERY = "SET TRANSACTION READ ONLY;"
|
||||
|
||||
REPLICA_CONNECTION_SQLSTATE_PREFIXES = ("08",)
|
||||
REPLICA_CONNECTION_SQLSTATES = {"57P01", "57P02", "57P03"}
|
||||
REPLICA_NON_FAILOVER_SQLSTATES = {"57014", "40001", "40P01"}
|
||||
REPLICA_CONNECTION_ERROR_MESSAGES = (
|
||||
"ssl syscall",
|
||||
"eof detected",
|
||||
"server closed the connection",
|
||||
"connection already closed",
|
||||
"connection not open",
|
||||
"could not connect to server",
|
||||
"connection refused",
|
||||
"connection reset",
|
||||
"connection timed out",
|
||||
"lost synchronization",
|
||||
"terminating connection",
|
||||
"database system is starting up",
|
||||
"database system is shutting down",
|
||||
"database system is in recovery mode",
|
||||
)
|
||||
REPLICA_NON_FAILOVER_ERROR_MESSAGES = (
|
||||
"canceling statement due to user request",
|
||||
"deadlock detected",
|
||||
"could not serialize access",
|
||||
)
|
||||
|
||||
|
||||
def _iter_exception_chain(error: BaseException):
|
||||
seen = set()
|
||||
pending = [error]
|
||||
while pending:
|
||||
current = pending.pop(0)
|
||||
if current is None or id(current) in seen:
|
||||
continue
|
||||
seen.add(id(current))
|
||||
yield current
|
||||
|
||||
cause = getattr(current, "__cause__", None)
|
||||
context = getattr(current, "__context__", None)
|
||||
if cause is not None:
|
||||
pending.append(cause)
|
||||
if context is not None:
|
||||
pending.append(context)
|
||||
for arg in getattr(current, "args", ()):
|
||||
if isinstance(arg, BaseException):
|
||||
pending.append(arg)
|
||||
|
||||
|
||||
def _get_exception_sqlstate(error: BaseException) -> str | None:
|
||||
for attr in ("pgcode", "sqlstate"):
|
||||
sqlstate = getattr(error, attr, None)
|
||||
if sqlstate:
|
||||
return sqlstate
|
||||
|
||||
diag = getattr(error, "diag", None)
|
||||
if diag is not None:
|
||||
sqlstate = getattr(diag, "sqlstate", None)
|
||||
if sqlstate:
|
||||
return sqlstate
|
||||
return None
|
||||
|
||||
|
||||
def _is_replica_connection_failure(error: BaseException) -> bool:
|
||||
"""
|
||||
Return True only for replica failures where retrying on primary is safe.
|
||||
|
||||
Query cancellations, serialization failures, and deadlocks should surface to
|
||||
callers because replaying them can hide real query or concurrency problems.
|
||||
"""
|
||||
messages = []
|
||||
sqlstates = set()
|
||||
|
||||
for chained_error in _iter_exception_chain(error):
|
||||
sqlstate = _get_exception_sqlstate(chained_error)
|
||||
if sqlstate:
|
||||
sqlstates.add(sqlstate)
|
||||
messages.append(str(chained_error).lower())
|
||||
|
||||
if sqlstates & REPLICA_NON_FAILOVER_SQLSTATES:
|
||||
return False
|
||||
if any(
|
||||
sqlstate.startswith(REPLICA_CONNECTION_SQLSTATE_PREFIXES)
|
||||
or sqlstate in REPLICA_CONNECTION_SQLSTATES
|
||||
for sqlstate in sqlstates
|
||||
):
|
||||
return True
|
||||
|
||||
message = " ".join(messages)
|
||||
if any(marker in message for marker in REPLICA_NON_FAILOVER_ERROR_MESSAGES):
|
||||
return False
|
||||
|
||||
return any(marker in message for marker in REPLICA_CONNECTION_ERROR_MESSAGES)
|
||||
|
||||
|
||||
def _strip_leading_sql_comments(sql: str) -> str:
|
||||
if not isinstance(sql, str):
|
||||
return ""
|
||||
|
||||
sql_text = sql.lstrip()
|
||||
while True:
|
||||
if sql_text.startswith("--"):
|
||||
newline_index = sql_text.find("\n")
|
||||
if newline_index == -1:
|
||||
return ""
|
||||
sql_text = sql_text[newline_index + 1 :].lstrip()
|
||||
continue
|
||||
|
||||
if sql_text.startswith("/*"):
|
||||
comment_end_index = sql_text.find("*/", 2)
|
||||
if comment_end_index == -1:
|
||||
return ""
|
||||
sql_text = sql_text[comment_end_index + 2 :].lstrip()
|
||||
continue
|
||||
|
||||
return sql_text
|
||||
|
||||
|
||||
def _is_safe_primary_replay(sql: str, many: bool) -> bool:
|
||||
if many:
|
||||
return False
|
||||
|
||||
sql_text = _strip_leading_sql_comments(sql)
|
||||
if not re.match(r"(?is)^SELECT\b", sql_text):
|
||||
return False
|
||||
|
||||
return not any(
|
||||
re.search(pattern, sql_text, re.IGNORECASE | re.DOTALL)
|
||||
for pattern in (
|
||||
r"\bINTO\b",
|
||||
r"\bFOR\s+(?:NO\s+KEY\s+)?UPDATE\b",
|
||||
r"\bFOR\s+(?:KEY\s+)?SHARE\b",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
@@ -77,14 +211,36 @@ def rls_transaction(
|
||||
retry_on_replica: bool = True,
|
||||
):
|
||||
"""
|
||||
Creates a new database transaction setting the given configuration value for Postgres RLS. It validates the
|
||||
if the value is a valid UUID.
|
||||
Context manager that opens an RLS-scoped database transaction.
|
||||
|
||||
Sets a Postgres configuration variable (``set_config``) so that Row-Level
|
||||
Security policies can filter by tenant. When *using* points to a read
|
||||
replica and *retry_on_replica* is True, replica failures are handled in two
|
||||
places:
|
||||
|
||||
1. **Pre-yield** (connection-setup failures): the function retries
|
||||
up to ``REPLICA_MAX_ATTEMPTS`` times on the replica, then falls
|
||||
back to the primary DB.
|
||||
2. **Post-yield** (mid-query failures): an ``execute_wrapper``
|
||||
intercepts connection-level ``OperationalError`` during
|
||||
``cursor.execute()`` calls and falls back directly to the primary DB
|
||||
for single ``SELECT`` statements. The primary fallback transaction is
|
||||
read-only, and unsafe statements keep raising the original error.
|
||||
The wrapper swaps the inner cursor so ``fetchall()`` / ``fetchone()``
|
||||
read from the new connection transparently.
|
||||
|
||||
Limitation: server-side cursors (``.iterator()``) fetch rows via
|
||||
``fetchmany()``, which the wrapper does not intercept. Call sites
|
||||
that iterate large result sets with ``.iterator()`` on the replica
|
||||
should add their own retry logic.
|
||||
|
||||
Args:
|
||||
value (str): Database configuration parameter value.
|
||||
parameter (str): Database configuration parameter name, by default is 'api.tenant_id'.
|
||||
using (str | None): Optional database alias to run the transaction against. Defaults to the
|
||||
active read alias (if any) or Django's default connection.
|
||||
value: Database configuration parameter value (must be a valid UUID).
|
||||
parameter: Database configuration parameter name.
|
||||
using: Optional database alias. Defaults to the active read
|
||||
alias or Django's default connection.
|
||||
retry_on_replica: Whether replica setup failures can retry and
|
||||
connection-level mid-query failures can fall back to primary.
|
||||
"""
|
||||
requested_alias = using or get_read_db_alias()
|
||||
db_alias = requested_alias or DEFAULT_DB_ALIAS
|
||||
@@ -92,54 +248,121 @@ def rls_transaction(
|
||||
db_alias = DEFAULT_DB_ALIAS
|
||||
|
||||
alias = db_alias
|
||||
is_replica = READ_REPLICA_ALIAS and alias == READ_REPLICA_ALIAS
|
||||
max_attempts = REPLICA_MAX_ATTEMPTS if is_replica and retry_on_replica else 1
|
||||
is_replica = bool(READ_REPLICA_ALIAS and alias == READ_REPLICA_ALIAS)
|
||||
can_failover = is_replica and retry_on_replica
|
||||
replica_alias = alias # captured before the loop mutates alias
|
||||
max_attempts = (REPLICA_MAX_ATTEMPTS + 1) if can_failover else 1
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
router_token = None
|
||||
yielded_cursor = False
|
||||
# State shared between the generator and the _query_failover closure.
|
||||
# The fallback transaction.atomic() is registered into fallback_stack
|
||||
# via enter_context so its __exit__ runs when the outer with-ExitStack
|
||||
# block exits, with the right exc_info. No manual __enter__/__exit__.
|
||||
_fallback = {"succeeded": False, "token": None, "caller_exited_cleanly": False}
|
||||
|
||||
# On final attempt, fallback to primary
|
||||
if attempt == max_attempts and is_replica:
|
||||
logger.warning(
|
||||
f"RLS transaction failed after {attempt - 1} attempts on replica, "
|
||||
f"falling back to primary DB"
|
||||
)
|
||||
alias = DEFAULT_DB_ALIAS
|
||||
with ExitStack() as fallback_stack:
|
||||
|
||||
conn = connections[alias]
|
||||
try:
|
||||
if alias != DEFAULT_DB_ALIAS:
|
||||
router_token = set_read_db_alias(alias)
|
||||
def _query_failover(execute, sql, params, many, context):
|
||||
"""execute_wrapper: replay failed replica queries on the primary DB."""
|
||||
try:
|
||||
return execute(sql, params, many, context)
|
||||
except OperationalError as err:
|
||||
if not _is_replica_connection_failure(err):
|
||||
raise
|
||||
if not _is_safe_primary_replay(sql, many):
|
||||
raise
|
||||
|
||||
with transaction.atomic(using=alias):
|
||||
with conn.cursor() as cursor:
|
||||
try:
|
||||
# just in case the value is a UUID object
|
||||
uuid.UUID(str(value))
|
||||
except ValueError:
|
||||
raise ValidationError("Must be a valid UUID")
|
||||
cursor.execute(SET_CONFIG_QUERY, [parameter, value])
|
||||
yielded_cursor = True
|
||||
yield cursor
|
||||
return
|
||||
except OperationalError as e:
|
||||
if yielded_cursor:
|
||||
raise
|
||||
# If on primary or max attempts reached, raise
|
||||
if not is_replica or attempt == max_attempts:
|
||||
raise
|
||||
try:
|
||||
connections[replica_alias].close()
|
||||
except Exception:
|
||||
pass # Best-effort; connection may already be dead
|
||||
|
||||
# Retry with exponential backoff
|
||||
delay = REPLICA_RETRY_BASE_DELAY * (2 ** (attempt - 1))
|
||||
logger.info(
|
||||
f"RLS transaction failed on replica (attempt {attempt}/{max_attempts}), "
|
||||
f"retrying in {delay}s. Error: {e}"
|
||||
)
|
||||
time.sleep(delay)
|
||||
finally:
|
||||
if router_token is not None:
|
||||
reset_read_db_alias(router_token)
|
||||
logger.warning(
|
||||
"Mid-query replica connection failure, falling back to primary DB"
|
||||
)
|
||||
primary = connections[DEFAULT_DB_ALIAS]
|
||||
primary.ensure_connection()
|
||||
fallback_stack.enter_context(transaction.atomic(using=DEFAULT_DB_ALIAS))
|
||||
|
||||
fallback_cursor = primary.cursor()
|
||||
fallback_stack.callback(fallback_cursor.close)
|
||||
fallback_cursor.execute(SET_TRANSACTION_READ_ONLY_QUERY)
|
||||
fallback_cursor.execute(SET_CONFIG_QUERY, [parameter, value])
|
||||
_fallback["token"] = set_read_db_alias(DEFAULT_DB_ALIAS)
|
||||
|
||||
fallback_cursor.execute(sql, params)
|
||||
|
||||
context["cursor"].db = primary
|
||||
context["cursor"].cursor = fallback_cursor.cursor
|
||||
_fallback["succeeded"] = True
|
||||
return None
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
router_token = None
|
||||
yielded_cursor = False
|
||||
|
||||
# On final attempt, fall back to primary
|
||||
if attempt == max_attempts and can_failover:
|
||||
if attempt > 1:
|
||||
logger.warning(
|
||||
f"RLS transaction failed after {attempt - 1} attempts on replica, "
|
||||
f"falling back to primary DB"
|
||||
)
|
||||
alias = DEFAULT_DB_ALIAS
|
||||
|
||||
conn = connections[alias]
|
||||
try:
|
||||
if alias != DEFAULT_DB_ALIAS:
|
||||
router_token = set_read_db_alias(alias)
|
||||
|
||||
with transaction.atomic(using=alias):
|
||||
with conn.cursor() as cursor:
|
||||
try:
|
||||
uuid.UUID(str(value))
|
||||
except ValueError:
|
||||
raise ValidationError("Must be a valid UUID")
|
||||
cursor.execute(SET_CONFIG_QUERY, [parameter, value])
|
||||
|
||||
wrapper_cm = (
|
||||
conn.execute_wrapper(_query_failover)
|
||||
if can_failover and alias == replica_alias
|
||||
else nullcontext()
|
||||
)
|
||||
with wrapper_cm:
|
||||
yielded_cursor = True
|
||||
yield cursor
|
||||
_fallback["caller_exited_cleanly"] = True
|
||||
return
|
||||
except OperationalError as e:
|
||||
if yielded_cursor:
|
||||
if _fallback["succeeded"] and _fallback["caller_exited_cleanly"]:
|
||||
# Caller's queries succeeded on primary via failover.
|
||||
# This error is transaction.atomic() cleanup on the
|
||||
# dead replica connection, suppress it.
|
||||
return
|
||||
raise
|
||||
|
||||
if not can_failover or attempt == max_attempts:
|
||||
raise
|
||||
|
||||
try:
|
||||
connections[alias].close()
|
||||
except Exception:
|
||||
pass # Best-effort; connection may already be dead
|
||||
|
||||
# Retry with exponential backoff
|
||||
delay = REPLICA_RETRY_BASE_DELAY * (2 ** (attempt - 1))
|
||||
logger.info(
|
||||
f"RLS transaction failed on replica (attempt {attempt}/{max_attempts}), "
|
||||
f"retrying in {delay}s. Error: {e}"
|
||||
)
|
||||
time.sleep(delay)
|
||||
finally:
|
||||
if _fallback["token"] is not None:
|
||||
reset_read_db_alias(_fallback["token"])
|
||||
_fallback["token"] = None
|
||||
|
||||
if router_token is not None:
|
||||
reset_read_db_alias(router_token)
|
||||
|
||||
|
||||
class CustomUserManager(BaseUserManager):
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -34,7 +34,7 @@ class HasPermissions(BasePermission):
|
||||
if not tenant_id:
|
||||
return False
|
||||
|
||||
user_roles = (
|
||||
user_roles = list(
|
||||
User.objects.using(MainRouter.admin_db)
|
||||
.get(id=request.user.id)
|
||||
.roles.using(MainRouter.admin_db)
|
||||
@@ -43,11 +43,10 @@ class HasPermissions(BasePermission):
|
||||
if not user_roles:
|
||||
return False
|
||||
|
||||
for perm in required_permissions:
|
||||
if not getattr(user_roles[0], perm.value, False):
|
||||
return False
|
||||
|
||||
return True
|
||||
return all(
|
||||
any(getattr(role, permission.value, False) for role in user_roles)
|
||||
for permission in required_permissions
|
||||
)
|
||||
|
||||
|
||||
def get_role(user: User, tenant_id: str) -> Role:
|
||||
@@ -84,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.34.2
|
||||
version: 1.38.0
|
||||
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
|
||||
@@ -14,6 +17,19 @@ from rest_framework_simplejwt.token_blacklist.models import (
|
||||
OutstandingToken,
|
||||
)
|
||||
|
||||
PASSWORD_CHANGE_PASSWORD = "InitialSecret123@"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def password_change_user(tenants_fixture):
|
||||
user = User.objects.create_user(
|
||||
name="password_change_user",
|
||||
email=f"password-change-{uuid4()}@prowler.com",
|
||||
password=PASSWORD_CHANGE_PASSWORD,
|
||||
)
|
||||
Membership.objects.create(user=user, tenant=tenants_fixture[0])
|
||||
return user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_basic_authentication():
|
||||
@@ -109,16 +125,16 @@ def test_refresh_token(create_test_user, tenants_fixture):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_password_change_invalidates_existing_tokens(create_test_user, tenants_fixture):
|
||||
def test_password_change_invalidates_existing_tokens(password_change_user):
|
||||
client = APIClient()
|
||||
new_password = "ChangedSecret123@"
|
||||
|
||||
access_token, refresh_token = get_api_tokens(
|
||||
client, create_test_user.email, TEST_PASSWORD
|
||||
client, password_change_user.email, PASSWORD_CHANGE_PASSWORD
|
||||
)
|
||||
auth_headers = get_authorization_header(access_token)
|
||||
outstanding_token_ids = list(
|
||||
OutstandingToken.objects.filter(user=create_test_user).values_list(
|
||||
OutstandingToken.objects.filter(user=password_change_user).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
@@ -130,12 +146,12 @@ def test_password_change_invalidates_existing_tokens(create_test_user, tenants_f
|
||||
password_change_payload = {
|
||||
"data": {
|
||||
"type": "users",
|
||||
"id": str(create_test_user.id),
|
||||
"id": str(password_change_user.id),
|
||||
"attributes": {"password": new_password},
|
||||
}
|
||||
}
|
||||
password_change_response = client.patch(
|
||||
reverse("user-detail", kwargs={"pk": create_test_user.id}),
|
||||
reverse("user-detail", kwargs={"pk": password_change_user.id}),
|
||||
data=json.dumps(password_change_payload),
|
||||
headers=auth_headers,
|
||||
content_type="application/vnd.api+json",
|
||||
@@ -160,7 +176,9 @@ def test_password_change_invalidates_existing_tokens(create_test_user, tenants_f
|
||||
)
|
||||
assert old_refresh_response.status_code == 400
|
||||
|
||||
new_access_token, _ = get_api_tokens(client, create_test_user.email, new_password)
|
||||
new_access_token, _ = get_api_tokens(
|
||||
client, password_change_user.email, new_password
|
||||
)
|
||||
new_access_response = client.get(
|
||||
reverse("user-me"), headers=get_authorization_header(new_access_token)
|
||||
)
|
||||
@@ -169,13 +187,13 @@ def test_password_change_invalidates_existing_tokens(create_test_user, tenants_f
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_password_change_invalidates_rotated_refresh_token(
|
||||
create_test_user, tenants_fixture
|
||||
password_change_user,
|
||||
):
|
||||
client = APIClient()
|
||||
new_password = "ChangedSecret123@"
|
||||
|
||||
access_token, refresh_token = get_api_tokens(
|
||||
client, create_test_user.email, TEST_PASSWORD
|
||||
client, password_change_user.email, PASSWORD_CHANGE_PASSWORD
|
||||
)
|
||||
rotated_refresh_response = client.post(
|
||||
reverse("token-refresh"),
|
||||
@@ -195,12 +213,12 @@ def test_password_change_invalidates_rotated_refresh_token(
|
||||
password_change_payload = {
|
||||
"data": {
|
||||
"type": "users",
|
||||
"id": str(create_test_user.id),
|
||||
"id": str(password_change_user.id),
|
||||
"attributes": {"password": new_password},
|
||||
}
|
||||
}
|
||||
password_change_response = client.patch(
|
||||
reverse("user-detail", kwargs={"pk": create_test_user.id}),
|
||||
reverse("user-detail", kwargs={"pk": password_change_user.id}),
|
||||
data=json.dumps(password_change_payload),
|
||||
headers=get_authorization_header(access_token),
|
||||
content_type="application/vnd.api+json",
|
||||
@@ -304,9 +322,8 @@ def test_user_me_when_inviting_users(create_test_user, tenants_fixture, roles_fi
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestTokenSwitchTenant:
|
||||
def test_switch_tenant_with_valid_token(self, tenants_fixture, providers_fixture):
|
||||
def test_switch_tenant_with_valid_token(self, tenants_fixture, aws_provider):
|
||||
client = APIClient()
|
||||
aws_provider = providers_fixture[0]
|
||||
assert aws_provider
|
||||
|
||||
test_user = "test_email@prowler.com"
|
||||
@@ -611,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()
|
||||
@@ -803,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:
|
||||
@@ -1515,14 +1647,13 @@ class TestAPIKeyMultiTenantWorkflows:
|
||||
assert me_response2.json()["data"]["id"] == str(user.id)
|
||||
|
||||
def test_api_key_cannot_access_different_tenant_resources(
|
||||
self, tenants_fixture, providers_fixture
|
||||
self, tenants_fixture, aws_provider
|
||||
):
|
||||
"""API key from one tenant cannot access resources from another tenant.
|
||||
|
||||
Verifies RLS enforcement after authentication ensures tenant isolation.
|
||||
"""
|
||||
client = APIClient()
|
||||
aws_provider = providers_fixture[0]
|
||||
assert aws_provider
|
||||
|
||||
user1 = User.objects.create_user(
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""Tests for rls_transaction retry and fallback logic."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from api.db_utils import rls_transaction
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from api.db_utils import POSTGRES_TENANT_VAR, rls_transaction
|
||||
from conftest import TEST_REPLICA_ALIAS
|
||||
from django.db import DEFAULT_DB_ALIAS, OperationalError, connections
|
||||
from psycopg2 import OperationalError as Psycopg2OperationalError
|
||||
from rest_framework_json_api.serializers import ValidationError
|
||||
|
||||
|
||||
@@ -36,3 +40,35 @@ class TestRLSTransaction:
|
||||
cursor.execute("SELECT current_setting(%s, true)", [custom_param])
|
||||
result = cursor.fetchone()
|
||||
assert result == (str(tenant.id),)
|
||||
|
||||
@pytest.mark.requires_test_replica_alias
|
||||
@pytest.mark.django_db(
|
||||
transaction=True, databases=[DEFAULT_DB_ALIAS, TEST_REPLICA_ALIAS]
|
||||
)
|
||||
def test_mid_query_replica_connection_loss_falls_back_to_primary(self, tenant):
|
||||
"""Real Django connection state: closed replica atomic falls back to primary."""
|
||||
replica = connections[TEST_REPLICA_ALIAS]
|
||||
sql = "SELECT current_setting(%s, true), %s"
|
||||
params = [POSTGRES_TENANT_VAR, 42]
|
||||
failed_once = {"value": False}
|
||||
|
||||
def close_replica_and_raise(execute, sql_arg, params_arg, many, context):
|
||||
if not failed_once["value"] and sql_arg == sql:
|
||||
failed_once["value"] = True
|
||||
replica.close()
|
||||
try:
|
||||
raise Psycopg2OperationalError("SSL SYSCALL error: EOF detected")
|
||||
except Psycopg2OperationalError as psycopg_error:
|
||||
raise OperationalError(
|
||||
"SSL SYSCALL error: EOF detected"
|
||||
) from psycopg_error
|
||||
return execute(sql_arg, params_arg, many, context)
|
||||
|
||||
with patch("api.db_utils.READ_REPLICA_ALIAS", TEST_REPLICA_ALIAS):
|
||||
with rls_transaction(str(tenant.id), using=TEST_REPLICA_ALIAS) as cursor:
|
||||
with replica.execute_wrapper(close_replica_and_raise):
|
||||
cursor.execute(sql, params)
|
||||
result = cursor.fetchone()
|
||||
|
||||
assert failed_once["value"]
|
||||
assert result == (str(tenant.id), 42)
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user