mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 17:40:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c076df74f9 | ||
|
|
5e812c614b | ||
|
|
cd3d5630b7 | ||
|
|
e3adb38bb6 | ||
|
|
1d3cefa490 | ||
|
|
5f94be2611 | ||
|
|
065ce48c0f | ||
|
|
8ee4ae2f46 | ||
|
|
2d535b19cc | ||
|
|
d480679638 | ||
|
|
6e24a49128 | ||
|
|
a9a60c9448 | ||
|
|
a180d8a775 | ||
|
|
43ecebc849 | ||
|
|
a70a69a7d0 | ||
|
|
6d7b1ef153 | ||
|
|
f26c814e5a | ||
|
|
587f9bcfa6 | ||
|
|
b12aa854d6 | ||
|
|
407ce0d4ca | ||
|
|
fbbf9fe1cd | ||
|
|
ca9feed805 | ||
|
|
234e69da3f | ||
|
|
f1b3b76efc | ||
|
|
5cd673787b | ||
|
|
b9aa863e52 | ||
|
|
39cbcbfe2b | ||
|
|
f19106281b | ||
|
|
137b388df9 | ||
|
|
e0cb18bdbd | ||
|
|
b08d2eb472 | ||
|
|
74a760517e | ||
|
|
2db3bebd15 | ||
|
|
b56df840fc | ||
|
|
bd9fa88d2a | ||
|
|
9adc248c05 | ||
|
|
ea0c7eb271 | ||
|
|
88c666a0d2 | ||
|
|
7275b46707 | ||
|
|
60bc06271a | ||
|
|
8abd72e857 | ||
|
|
9185b043da | ||
|
|
3dd6b29477 | ||
|
|
6db407ed3c | ||
|
|
0b98a34687 | ||
|
|
fc0204a40d | ||
|
|
7a62926a09 | ||
|
|
7a6a35afec | ||
|
|
b7281a5221 | ||
|
|
5c4b0ba1fe | ||
|
|
f3b8ac1dbb | ||
|
|
8dac2a7ccf | ||
|
|
6192b8ac32 | ||
|
|
e49babb9e7 | ||
|
|
f8be9afa7c | ||
|
|
ecf7ec8e85 | ||
|
|
a57a507cee | ||
|
|
9b565586da | ||
|
|
17f726f816 | ||
|
|
3fd748994a | ||
|
|
976220dbf5 | ||
|
|
a77e56b5a0 | ||
|
|
34e4d25576 | ||
|
|
d4a33c0d1c | ||
|
|
4c3017e2ed | ||
|
|
03f2ab46c9 | ||
|
|
7f1cdb82ae | ||
|
|
1bb3fc9bda | ||
|
|
463e8309f7 | ||
|
|
59a7d30a3e | ||
|
|
2ae1062e76 | ||
|
|
1218b0920f | ||
|
|
06ea61ffbf | ||
|
|
8eeb37aea4 | ||
|
|
7ba96ab2e2 | ||
|
|
4987d8a08e | ||
|
|
2bd89ceb97 | ||
|
|
0d4a21b5a4 | ||
|
|
1460f7b188 | ||
|
|
e9bbde2f01 | ||
|
|
76239b7076 | ||
|
|
0c0f150cd4 | ||
|
|
43de7709cb | ||
|
|
bb18dcb882 | ||
|
|
f5fe9c7b40 | ||
|
|
60c81f6549 | ||
|
|
f18c2841a8 | ||
|
|
6b21e31a28 | ||
|
|
294e665d9e | ||
|
|
94e14660da | ||
|
|
339930ef13 | ||
|
|
d933a8ecab | ||
|
|
da09ad9813 | ||
|
|
2298d4a3f8 | ||
|
|
066d53467a | ||
|
|
cf433128ed | ||
|
|
0b782fcb8c | ||
|
|
b80e3a7bfb | ||
|
|
885555e080 | ||
|
|
641c418816 | ||
|
|
10d173f8da | ||
|
|
34de660755 | ||
|
|
fb75146e34 | ||
|
|
9f5ef80e69 | ||
|
|
2f6aedf291 | ||
|
|
dcf2736e8d | ||
|
|
7f0dc9b7da | ||
|
|
ff45f46047 | ||
|
|
3bd13d173d | ||
|
|
2b7f7e7dc0 | ||
|
|
98015dafef | ||
|
|
d70a7e3d02 | ||
|
|
7d2a22c45a | ||
|
|
bf82e9ff3d | ||
|
|
eece938350 | ||
|
|
2b0e34818c | ||
|
|
f587dbf419 | ||
|
|
2d684c1996 | ||
|
|
7f9d64a996 | ||
|
|
e943ded978 | ||
|
|
bb20f69a63 | ||
|
|
97233189c3 | ||
|
|
b624818b8e | ||
|
|
cb31856025 | ||
|
|
13a9caa803 | ||
|
|
4e22289a19 | ||
|
|
e035e0ff62 | ||
|
|
dd81793480 | ||
|
|
457297a5f6 | ||
|
|
4da5aed519 | ||
|
|
95521d26cb | ||
|
|
cbe06314ca | ||
|
|
4b72cc8dd4 | ||
|
|
ce9d46065a | ||
|
|
35b3ff2c8e | ||
|
|
d7d4cc4849 | ||
|
|
1459046985 | ||
|
|
84c3c9ce0a | ||
|
|
8271659fda | ||
|
|
9916e1ac66 | ||
|
|
ccec96ac5f | ||
|
|
f5ea116763 | ||
|
|
99ca260855 | ||
|
|
bfc6b9e577 | ||
|
|
bc3c922177 | ||
|
|
0e20d2388e | ||
|
|
e0ddc0de27 | ||
|
|
cf840588c7 | ||
|
|
e1c2e9373c | ||
|
|
641a11e4a0 | ||
|
|
59b13778e2 | ||
|
|
7d8c64d35b | ||
|
|
a022ad5c7a | ||
|
|
0f31f1a3c2 | ||
|
|
1ee71c1f20 | ||
|
|
0dc424031b | ||
|
|
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 | ||
|
|
76a2d7bfe6 | ||
|
|
ac3f289de6 | ||
|
|
838f82b255 | ||
|
|
5e00c4bfcf | ||
|
|
f0ae56b8ea | ||
|
|
4484c2f192 | ||
|
|
e6bbcb8043 | ||
|
|
ad04e69c35 | ||
|
|
6cae37174c | ||
|
|
aa6de57430 | ||
|
|
a48aa37f87 | ||
|
|
3cc8f86780 | ||
|
|
4cb02a0ead | ||
|
|
221c558cee | ||
|
|
81c3152ebb | ||
|
|
855e9a043e | ||
|
|
7b5d724bb7 | ||
|
|
441f2a3c48 | ||
|
|
efb86bb7ab | ||
|
|
398a0a484f | ||
|
|
55924d8150 | ||
|
|
0cf6f2f83e | ||
|
|
cf18093261 | ||
|
|
1850e209e6 | ||
|
|
2e37188c9f |
@@ -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
|
||||
@@ -145,19 +145,20 @@ DJANGO_BROKER_VISIBILITY_TIMEOUT=86400
|
||||
DJANGO_SENTRY_DSN=
|
||||
DJANGO_THROTTLE_TOKEN_OBTAIN=50/minute
|
||||
|
||||
# Sentry for the web app (server + browser). Empty/unset UI_SENTRY_DSN ⇒
|
||||
# Sentry disabled, zero egress. SENTRY_RELEASE (unprefixed) feeds the web app's
|
||||
# server/edge SDKs.
|
||||
# Sentry for the web app (server + browser). The UI_SENTRY_* values load only
|
||||
# when UI_SENTRY_ENABLED="true"; without it they are ignored (default off, zero
|
||||
# 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=
|
||||
UI_SENTRY_ENVIRONMENT=local
|
||||
SENTRY_RELEASE=local
|
||||
# Reserved runtime public config (registered now; no UI consumer yet)
|
||||
# POSTHOG_KEY=
|
||||
# POSTHOG_HOST=
|
||||
# UI_POSTHOG_KEY=
|
||||
# UI_POSTHOG_HOST=
|
||||
# REO_DEV_CLIENT_ID=
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.32.2
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.37.2
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -54,7 +54,7 @@ runs:
|
||||
trivy-db-${{ runner.os }}-
|
||||
|
||||
- name: Run Trivy vulnerability scan (JSON)
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ inputs.image-name }}:${{ inputs.image-tag }}
|
||||
format: 'json'
|
||||
@@ -67,7 +67,7 @@ runs:
|
||||
|
||||
- name: Run Trivy vulnerability scan (SARIF)
|
||||
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
|
||||
uses: aquasecurity/trivy-action@e368e328979b113139d6f9068e03accaed98a518 # 0.34.1
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||
with:
|
||||
image-ref: ${{ inputs.image-name }}:${{ inputs.image-tag }}
|
||||
format: 'sarif'
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# v5
|
||||
# - package-ecosystem: "pip"
|
||||
# directory: "/"
|
||||
# schedule:
|
||||
# interval: "monthly"
|
||||
# open-pull-requests-limit: 25
|
||||
# target-branch: master
|
||||
# labels:
|
||||
# - "dependencies"
|
||||
# - "pip"
|
||||
# cooldown:
|
||||
# default-days: 7
|
||||
|
||||
# Dependabot Updates are temporary disabled - 2025/03/19
|
||||
# - package-ecosystem: "pip"
|
||||
# directory: "/api"
|
||||
# schedule:
|
||||
# interval: "daily"
|
||||
# open-pull-requests-limit: 10
|
||||
# target-branch: master
|
||||
# labels:
|
||||
# - "dependencies"
|
||||
# - "pip"
|
||||
# - "component/api"
|
||||
|
||||
# Dependabot version updates disabled - migrated to Renovate - 2026/07/02
|
||||
# - package-ecosystem: "github-actions"
|
||||
# directory: "/"
|
||||
# schedule:
|
||||
# interval: "monthly"
|
||||
# open-pull-requests-limit: 25
|
||||
# target-branch: master
|
||||
# labels:
|
||||
# - "dependencies"
|
||||
# - "github_actions"
|
||||
# cooldown:
|
||||
# default-days: 7
|
||||
|
||||
# Dependabot Updates are temporary disabled - 2025/03/19
|
||||
# - package-ecosystem: "npm"
|
||||
# directory: "/ui"
|
||||
# schedule:
|
||||
# interval: "daily"
|
||||
# open-pull-requests-limit: 10
|
||||
# target-branch: master
|
||||
# labels:
|
||||
# - "dependencies"
|
||||
# - "npm"
|
||||
# - "component/ui"
|
||||
|
||||
# Dependabot version updates disabled - migrated to Renovate - 2026/07/02
|
||||
# - package-ecosystem: "docker"
|
||||
# directory: "/"
|
||||
# schedule:
|
||||
# interval: "monthly"
|
||||
# open-pull-requests-limit: 25
|
||||
# target-branch: master
|
||||
# labels:
|
||||
# - "dependencies"
|
||||
# - "docker"
|
||||
# cooldown:
|
||||
# default-days: 7
|
||||
|
||||
# - package-ecosystem: "pre-commit"
|
||||
# directory: "/"
|
||||
# schedule:
|
||||
# interval: "monthly"
|
||||
# open-pull-requests-limit: 25
|
||||
# target-branch: master
|
||||
# labels:
|
||||
# - "dependencies"
|
||||
# - "pre-commit"
|
||||
# cooldown:
|
||||
# default-days: 7
|
||||
|
||||
# Dependabot Updates are temporary disabled - 2025/04/15
|
||||
# v4.6
|
||||
# - package-ecosystem: "pip"
|
||||
# directory: "/"
|
||||
# schedule:
|
||||
# interval: "weekly"
|
||||
# open-pull-requests-limit: 10
|
||||
# target-branch: v4.6
|
||||
# labels:
|
||||
# - "dependencies"
|
||||
# - "pip"
|
||||
# - "v4"
|
||||
|
||||
# - package-ecosystem: "github-actions"
|
||||
# directory: "/"
|
||||
# schedule:
|
||||
# interval: "weekly"
|
||||
# open-pull-requests-limit: 10
|
||||
# target-branch: v4.6
|
||||
# labels:
|
||||
# - "dependencies"
|
||||
# - "github_actions"
|
||||
# - "v4"
|
||||
|
||||
# - package-ecosystem: "docker"
|
||||
# directory: "/"
|
||||
# schedule:
|
||||
# interval: "weekly"
|
||||
# open-pull-requests-limit: 10
|
||||
# target-branch: v4.6
|
||||
# labels:
|
||||
# - "dependencies"
|
||||
# - "docker"
|
||||
# - "v4"
|
||||
|
||||
# Dependabot Updates are temporary disabled - 2025/03/19
|
||||
# v3
|
||||
# - package-ecosystem: "pip"
|
||||
# directory: "/"
|
||||
# schedule:
|
||||
# interval: "monthly"
|
||||
# open-pull-requests-limit: 10
|
||||
# target-branch: v3
|
||||
# labels:
|
||||
# - "dependencies"
|
||||
# - "pip"
|
||||
# - "v3"
|
||||
|
||||
# - package-ecosystem: "github-actions"
|
||||
# directory: "/"
|
||||
# schedule:
|
||||
# interval: "monthly"
|
||||
# open-pull-requests-limit: 10
|
||||
# target-branch: v3
|
||||
# labels:
|
||||
# - "dependencies"
|
||||
# - "github_actions"
|
||||
# - "v3"
|
||||
@@ -18,8 +18,9 @@ Please add a detailed description of how to review this PR.
|
||||
|
||||
<summary><b>Community Checklist</b></summary>
|
||||
|
||||
- [ ] This feature/issue is listed in [here](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 issue/feature in [here](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or [Prowler Community Slack](goto.prowler.com/slack)
|
||||
- [ ] 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,8 +28,8 @@ 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)
|
||||
- [ ] Ensure new entries are added to [CHANGELOG.md](https://github.com/prowler-cloud/prowler/blob/master/prowler/CHANGELOG.md), if applicable.
|
||||
- [ ] 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
|
||||
- Are there new checks included in this PR? Yes / No
|
||||
@@ -40,7 +41,7 @@ Please add a detailed description of how to review this PR.
|
||||
- [ ] Screenshots/Video of the functionality flow (if applicable) - Mobile (X < 640px)
|
||||
- [ ] Screenshots/Video of the functionality flow (if applicable) - Table (640px > X < 1024px)
|
||||
- [ ] Screenshots/Video of the functionality flow (if applicable) - Desktop (X > 1024px)
|
||||
- [ ] Ensure new entries are added to [CHANGELOG.md](https://github.com/prowler-cloud/prowler/blob/master/ui/CHANGELOG.md), if applicable.
|
||||
- [ ] Ensure a changelog fragment is added under [ui/changelog.d/](https://github.com/prowler-cloud/prowler/tree/master/ui/changelog.d), if applicable.
|
||||
|
||||
#### API
|
||||
- [ ] All issue/task requirements work as expected on the API
|
||||
@@ -50,7 +51,11 @@ Please add a detailed description of how to review this PR.
|
||||
- [ ] Any other relevant evidence of the implementation (if applicable)
|
||||
- [ ] Verify if API specs need to be regenerated.
|
||||
- [ ] Check if version updates are required (e.g., specs, uv, etc.).
|
||||
- [ ] Ensure new entries are added to [CHANGELOG.md](https://github.com/prowler-cloud/prowler/blob/master/api/CHANGELOG.md), if applicable.
|
||||
- [ ] Ensure a changelog fragment is added under [api/changelog.d/](https://github.com/prowler-cloud/prowler/tree/master/api/changelog.d), if applicable.
|
||||
|
||||
#### MCP Server
|
||||
- [ ] All issue/task requirements work as expected on the MCP Server
|
||||
- [ ] Ensure a changelog fragment is added under [mcp_server/changelog.d/](https://github.com/prowler-cloud/prowler/tree/master/mcp_server/changelog.d), if applicable.
|
||||
|
||||
### License
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rename changelog fragments to their PR number before running towncrier.
|
||||
|
||||
For every <slug>.<type>.md in <component_dir>/changelog.d/, find the commit that
|
||||
added it, resolve its PR via the GitHub API (falling back to the squash-commit
|
||||
subject), and `git mv` it to <PR>.<type>.md so towncrier renders the PR link.
|
||||
Unresolvable fragments become +<slug>.<type>.md orphans (rendered without link).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
FRAGMENT_RE = re.compile(
|
||||
r"^(?P<slug>[A-Za-z0-9][A-Za-z0-9._-]*?)"
|
||||
r"\.(?P<type>added|changed|deprecated|removed|fixed|security)"
|
||||
r"(?:\.(?P<counter>[0-9]+))?\.md$"
|
||||
)
|
||||
SUBJECT_PR_RE = re.compile(r" \(#([0-9]+)\)$")
|
||||
IGNORED_FILES = {".gitkeep", "README.md"}
|
||||
API_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
def git(*args: str) -> str:
|
||||
result = subprocess.run(["git", *args], check=True, capture_output=True, text=True)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def find_adding_commit(path: str) -> str | None:
|
||||
"""Find the commit that added a file, following renames.
|
||||
|
||||
Falls back to a plain (no --follow) lookup: rename detection can lose the
|
||||
add event for degenerate content (e.g. files identical to many others).
|
||||
"""
|
||||
sha = git("log", "--follow", "--diff-filter=A", "--format=%H", "-1", "--", path)
|
||||
if not sha:
|
||||
sha = git("log", "--diff-filter=A", "--format=%H", "-1", "--", path)
|
||||
return sha or None
|
||||
|
||||
|
||||
def pr_from_api(repo: str, sha: str) -> int | None:
|
||||
"""Resolve the PR associated with a commit via the GitHub API.
|
||||
|
||||
Returns None on any network/API failure so the caller can fall back to
|
||||
parsing the squash-commit subject.
|
||||
"""
|
||||
url = f"https://api.github.com/repos/{repo}/commits/{sha}/pulls"
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "prowler-changelog-attribution",
|
||||
}
|
||||
token = os.environ.get("GITHUB_TOKEN")
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
request = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=API_TIMEOUT_SECONDS) as response:
|
||||
pulls = json.load(response)
|
||||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
|
||||
return None
|
||||
if isinstance(pulls, list) and pulls:
|
||||
return pulls[0].get("number")
|
||||
return None
|
||||
|
||||
|
||||
def pr_from_subject(sha: str) -> int | None:
|
||||
subject = git("log", "-1", "--format=%s", sha)
|
||||
match = SUBJECT_PR_RE.search(subject)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def unique_destination(directory: str, base_name: str, fragment_type: str) -> str:
|
||||
"""Return a non-colliding fragment path, appending a numeric counter if needed."""
|
||||
candidate = os.path.join(directory, f"{base_name}.{fragment_type}.md")
|
||||
counter = 0
|
||||
while os.path.exists(candidate):
|
||||
counter += 1
|
||||
candidate = os.path.join(directory, f"{base_name}.{fragment_type}.{counter}.md")
|
||||
return candidate
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("component_dir", help="Component directory, e.g. prowler")
|
||||
parser.add_argument("--repo", default="prowler-cloud/prowler")
|
||||
parser.add_argument(
|
||||
"--no-api",
|
||||
action="store_true",
|
||||
help="Skip the GitHub API and resolve PRs from commit subjects only",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
fragments_dir = os.path.join(args.component_dir, "changelog.d")
|
||||
if not os.path.isdir(fragments_dir):
|
||||
print(f"::error::Fragments directory not found: {fragments_dir}")
|
||||
return 1
|
||||
|
||||
malformed = []
|
||||
to_process = []
|
||||
for name in sorted(os.listdir(fragments_dir)):
|
||||
if name in IGNORED_FILES or name.startswith("+"):
|
||||
continue
|
||||
match = FRAGMENT_RE.match(name)
|
||||
if not match:
|
||||
malformed.append(name)
|
||||
continue
|
||||
if match.group("slug").isdigit():
|
||||
continue
|
||||
to_process.append((name, match))
|
||||
|
||||
if malformed:
|
||||
for name in malformed:
|
||||
print(
|
||||
f"::error::Malformed fragment filename in {fragments_dir}: {name} "
|
||||
"(expected <slug>.<type>.md with type one of added|changed|"
|
||||
"deprecated|removed|fixed|security)"
|
||||
)
|
||||
return 1
|
||||
|
||||
for name, match in to_process:
|
||||
slug, fragment_type = match.group("slug"), match.group("type")
|
||||
|
||||
path = os.path.join(fragments_dir, name)
|
||||
sha = find_adding_commit(path)
|
||||
pr_number = None
|
||||
if sha:
|
||||
if not args.no_api:
|
||||
pr_number = pr_from_api(args.repo, sha)
|
||||
if pr_number is None:
|
||||
pr_number = pr_from_subject(sha)
|
||||
|
||||
if pr_number is not None:
|
||||
destination = unique_destination(
|
||||
fragments_dir, str(pr_number), fragment_type
|
||||
)
|
||||
else:
|
||||
destination = unique_destination(fragments_dir, f"+{slug}", fragment_type)
|
||||
print(
|
||||
f"::warning::Could not resolve a PR for {path}; renamed to "
|
||||
f"{os.path.basename(destination)} (entry will render without a PR link)"
|
||||
)
|
||||
git("mv", path, destination)
|
||||
print(f"{path} -> {destination}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{% set category_order = definitions.keys() %}
|
||||
{% for section, _ in sections.items() %}
|
||||
{% for category in category_order if category in sections[section] %}
|
||||
### {{ definitions[category]['name'] }}
|
||||
|
||||
{% for text, values in sections[section][category].items() -%}
|
||||
- {{ text }}{% if values %} {{ values|join(', ') }}{% endif %}{{ "\n" }}
|
||||
{%- endfor %}
|
||||
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
---
|
||||
{{ "\n" }}
|
||||
@@ -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: >
|
||||
@@ -62,6 +62,7 @@ jobs:
|
||||
api/docs/**
|
||||
api/README.md
|
||||
api/CHANGELOG.md
|
||||
api/changelog.d/**
|
||||
api/AGENTS.md
|
||||
|
||||
- name: Setup Python with uv
|
||||
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
- 'api/**'
|
||||
- '.github/workflows/api-codeql.yml'
|
||||
- '.github/codeql/api-codeql-config.yml'
|
||||
- '!api/CHANGELOG.md'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
@@ -17,6 +18,7 @@ on:
|
||||
- 'api/**'
|
||||
- '.github/workflows/api-codeql.yml'
|
||||
- '.github/codeql/api-codeql-config.yml'
|
||||
- '!api/CHANGELOG.md'
|
||||
schedule:
|
||||
- cron: '00 12 * * *'
|
||||
|
||||
@@ -44,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,13 +105,25 @@ 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
|
||||
api/CHANGELOG.md
|
||||
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: >
|
||||
@@ -77,6 +77,7 @@ jobs:
|
||||
api/docs/**
|
||||
api/README.md
|
||||
api/CHANGELOG.md
|
||||
api/changelog.d/**
|
||||
api/AGENTS.md
|
||||
|
||||
- name: Setup Python with uv
|
||||
|
||||
@@ -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,10 +107,12 @@ jobs:
|
||||
files: |
|
||||
api/**
|
||||
.github/workflows/api-tests.yml
|
||||
codecov.yml
|
||||
files_ignore: |
|
||||
api/docs/**
|
||||
api/README.md
|
||||
api/CHANGELOG.md
|
||||
api/changelog.d/**
|
||||
api/AGENTS.md
|
||||
|
||||
- name: Setup Python with uv
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
name: 'Tools: Compile Changelogs'
|
||||
|
||||
run-name: 'Compile changelogs for Prowler ${{ inputs.prowler_version }}'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
prowler_version:
|
||||
description: 'Prowler version being released (e.g., 5.31.0)'
|
||||
required: true
|
||||
type: string
|
||||
target_branch:
|
||||
description: 'Branch to compile on (master for minor releases, v5.X for patches)'
|
||||
required: true
|
||||
type: string
|
||||
sdk_version:
|
||||
description: 'SDK version override (empty = mirrors prowler_version; "skip" = hold this component back)'
|
||||
required: false
|
||||
type: string
|
||||
api_version:
|
||||
description: 'API version override (empty = auto-derive 1.<prowler_minor + 1>.<prowler_patch>; "skip" = hold back)'
|
||||
required: false
|
||||
type: string
|
||||
ui_version:
|
||||
description: 'UI version override (empty = auto-derive 1.<prowler_minor>.<prowler_patch>; "skip" = hold back)'
|
||||
required: false
|
||||
type: string
|
||||
mcp_version:
|
||||
description: 'MCP Server version override (empty = auto-derive from pending fragment types; "skip" = hold back)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ inputs.prowler_version }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
PROWLER_VERSION: ${{ inputs.prowler_version }}
|
||||
TARGET_BRANCH: ${{ inputs.target_branch }}
|
||||
SDK_VERSION: ${{ inputs.sdk_version }}
|
||||
API_VERSION: ${{ inputs.api_version }}
|
||||
UI_VERSION: ${{ inputs.ui_version }}
|
||||
MCP_VERSION: ${{ inputs.mcp_version }}
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
compile-changelogs:
|
||||
if: github.event_name == 'workflow_dispatch' && github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Block outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.github.com:443
|
||||
github.com:443
|
||||
objects.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.target_branch }}
|
||||
fetch-depth: 0 # PR attribution resolves each fragment's adding commit from history
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install towncrier
|
||||
run: pip install --no-cache-dir towncrier==25.8.0
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name 'prowler-bot'
|
||||
git config --global user.email '179230569+prowler-bot@users.noreply.github.com'
|
||||
|
||||
- name: Validate version inputs
|
||||
run: |
|
||||
if [[ ! "$PROWLER_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Invalid prowler_version syntax: '$PROWLER_VERSION' (must be N.N.N)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$TARGET_BRANCH" != "master" ] && [[ ! "$TARGET_BRANCH" =~ ^v[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Invalid target_branch syntax: '$TARGET_BRANCH' (must be 'master' or vN.N, e.g. v5.31)"
|
||||
exit 1
|
||||
fi
|
||||
IFS=. read -r prowler_major prowler_minor prowler_patch <<< "$PROWLER_VERSION"
|
||||
prowler_major=$((10#$prowler_major))
|
||||
prowler_minor=$((10#$prowler_minor))
|
||||
prowler_patch=$((10#$prowler_patch))
|
||||
if [ "$prowler_patch" -eq 0 ]; then
|
||||
if [ "$TARGET_BRANCH" != "master" ]; then
|
||||
echo "::error::target_branch must be 'master' for Prowler ${PROWLER_VERSION}; got '${TARGET_BRANCH}'"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
expected_target_branch="v${prowler_major}.${prowler_minor}"
|
||||
if [ "$TARGET_BRANCH" != "$expected_target_branch" ]; then
|
||||
echo "::error::target_branch must be '${expected_target_branch}' for Prowler ${PROWLER_VERSION}; got '${TARGET_BRANCH}'"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
for pair in "sdk_version:$SDK_VERSION" "api_version:$API_VERSION" "ui_version:$UI_VERSION" "mcp_version:$MCP_VERSION"; do
|
||||
input_name="${pair%%:*}"
|
||||
input_value="${pair#*:}"
|
||||
if [ -n "$input_value" ] && [ "$input_value" != "skip" ] && [[ ! "$input_value" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::Invalid $input_name syntax: '$input_value' (must be N.N.N, empty for auto-derivation, or 'skip')"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Compile changelogs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
component_input() {
|
||||
case "$1" in
|
||||
prowler) echo "$SDK_VERSION" ;;
|
||||
api) echo "$API_VERSION" ;;
|
||||
ui) echo "$UI_VERSION" ;;
|
||||
mcp_server) echo "$MCP_VERSION" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
version_key() {
|
||||
local version="$1"
|
||||
local major minor patch
|
||||
IFS=. read -r major minor patch <<< "$version"
|
||||
printf '%06d.%06d.%06d' "$((10#$major))" "$((10#$minor))" "$((10#$patch))"
|
||||
}
|
||||
|
||||
pending_fragments() {
|
||||
find "$1/changelog.d" -maxdepth 1 -type f ! -name '.gitkeep' ! -name 'README.md' | sort
|
||||
}
|
||||
|
||||
# The component's last released version is the first stamped heading
|
||||
# of its CHANGELOG.md, the same source prepare-release.yml greps.
|
||||
latest_released_version() {
|
||||
grep -m1 -E '^## \[v?[0-9]+\.[0-9]+\.[0-9]+\]' "$1/CHANGELOG.md" | sed -E 's/^## \[v?([0-9]+\.[0-9]+\.[0-9]+)\].*/\1/'
|
||||
}
|
||||
|
||||
has_removed_fragments() {
|
||||
echo "$1" | grep -qE '\.removed(\.[0-9]+)?\.md$'
|
||||
}
|
||||
|
||||
# Resolve every component's effective version before compiling
|
||||
# anything, so a wrong input cannot leave the tree half-compiled.
|
||||
# Empty input = auto-derive (latest released version + semver bump
|
||||
# from the pending fragment types). 'skip' = hold the component back.
|
||||
errors=0
|
||||
compiling=""
|
||||
for component in prowler api ui mcp_server; do
|
||||
input=$(component_input "$component")
|
||||
fragments=$(pending_fragments "$component")
|
||||
|
||||
if [ "$input" = "skip" ]; then
|
||||
if [ -n "$fragments" ]; then
|
||||
echo "::warning::${component}: held back by request; these pending fragments stay for a future release:"
|
||||
echo "$fragments"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
if [ -n "$input" ] && [ -z "$fragments" ]; then
|
||||
echo "::error::${component}: version input '$input' provided but ${component}/changelog.d/ has no pending fragments (wrong input?)"
|
||||
errors=1
|
||||
continue
|
||||
fi
|
||||
if [ -z "$fragments" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
removed_fragments=false
|
||||
if has_removed_fragments "$fragments"; then
|
||||
removed_fragments=true
|
||||
fi
|
||||
current=$(latest_released_version "$component")
|
||||
if [ -z "$current" ]; then
|
||||
echo "::error::${component}: could not read the latest released version from ${component}/CHANGELOG.md; restore the released heading before compiling"
|
||||
errors=1
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -n "$input" ]; then
|
||||
effective="$input"
|
||||
mode="explicit"
|
||||
current_key=$(version_key "$current")
|
||||
effective_key=$(version_key "$effective")
|
||||
if [[ "$effective_key" < "$current_key" || "$effective_key" == "$current_key" ]]; then
|
||||
echo "::error::${component}: explicit version '${effective}' must be greater than the latest released version (${current})"
|
||||
errors=1
|
||||
continue
|
||||
fi
|
||||
else
|
||||
if [ "$removed_fragments" = "true" ]; then
|
||||
echo "::error::${component}: pending 'removed' fragments imply a major bump (breaking change); provide its version input explicitly"
|
||||
errors=1
|
||||
continue
|
||||
fi
|
||||
# SDK, UI, and API versions are deterministic mirrors of the
|
||||
# Prowler version (the scheme bump-version.yml codifies): the SDK
|
||||
# mirrors it directly, the UI tracks 1.<minor>.<patch>, and the
|
||||
# API is the independent 1.<minor + 1>.<patch> stream. Only the
|
||||
# MCP Server has its own cadence, derived from fragment types.
|
||||
IFS=. read -r _ prowler_minor prowler_patch <<< "$PROWLER_VERSION"
|
||||
prowler_minor=$((10#$prowler_minor))
|
||||
prowler_patch=$((10#$prowler_patch))
|
||||
case "$component" in
|
||||
prowler) effective="$PROWLER_VERSION" ;;
|
||||
ui) effective="1.${prowler_minor}.${prowler_patch}" ;;
|
||||
api) effective="1.$((prowler_minor + 1)).${prowler_patch}" ;;
|
||||
mcp_server)
|
||||
IFS=. read -r major minor patch <<< "$current"
|
||||
major=$((10#$major))
|
||||
minor=$((10#$minor))
|
||||
patch=$((10#$patch))
|
||||
# Prowler patch releases (vN.N target) are maintenance
|
||||
# releases, so the MCP Server bumps patch regardless of
|
||||
# fragment types; a deliberate exception needs the explicit
|
||||
# version input.
|
||||
if [ "$TARGET_BRANCH" != "master" ]; then
|
||||
effective="${major}.${minor}.$((patch + 1))"
|
||||
if echo "$fragments" | grep -qE '\.(added|deprecated)(\.[0-9]+)?\.md$'; then
|
||||
echo "::warning::${component}: 'added'/'deprecated' fragments are shipping in a Prowler patch; auto-derived a patch bump (${current} -> ${effective}), pass the version input to override"
|
||||
fi
|
||||
elif echo "$fragments" | grep -qE '\.(added|changed|deprecated)(\.[0-9]+)?\.md$'; then
|
||||
effective="${major}.$((minor + 1)).0"
|
||||
else
|
||||
effective="${major}.${minor}.$((patch + 1))"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
current_key=$(version_key "$current")
|
||||
effective_key=$(version_key "$effective")
|
||||
if [[ "$effective_key" < "$current_key" || "$effective_key" == "$current_key" ]]; then
|
||||
echo "::error::${component}: auto-derived version '${effective}' is not greater than the latest released version (${current}); check prowler_version or pass the version input explicitly"
|
||||
errors=1
|
||||
continue
|
||||
fi
|
||||
mode="auto"
|
||||
echo "::notice::${component}: version auto-derived ${current} -> ${effective}"
|
||||
fi
|
||||
|
||||
if [ "$removed_fragments" = "true" ]; then
|
||||
IFS=. read -r current_major _ <<< "$current"
|
||||
current_major=$((10#$current_major))
|
||||
IFS=. read -r effective_major effective_minor effective_patch <<< "$effective"
|
||||
effective_major=$((10#$effective_major))
|
||||
effective_minor=$((10#$effective_minor))
|
||||
effective_patch=$((10#$effective_patch))
|
||||
if [ "$effective_major" -le "$current_major" ] || [ "$effective_minor" -ne 0 ] || [ "$effective_patch" -ne 0 ]; then
|
||||
echo "::error::${component}: removed fragments require a major component release (${current} -> X.0.0 with X > ${current_major}); got ${effective}"
|
||||
errors=1
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
# Without the marker the build would insert the new block above the
|
||||
# file header instead of below it.
|
||||
if ! grep -q '^<!-- changelog: release notes start -->$' "$component/CHANGELOG.md"; then
|
||||
echo "::error::${component}/CHANGELOG.md is missing the '<!-- changelog: release notes start -->' marker; restore it after the intro line before compiling"
|
||||
errors=1
|
||||
continue
|
||||
fi
|
||||
# A hand-written UNRELEASED block means someone followed the old
|
||||
# convention; its entries would be left out of the compiled block
|
||||
# and out of the release notes extraction.
|
||||
if grep -q '(Prowler UNRELEASED)' "$component/CHANGELOG.md"; then
|
||||
echo "::error::${component}/CHANGELOG.md contains a hand-written '(Prowler UNRELEASED)' block; convert its entries to fragments in ${component}/changelog.d/ and delete the block before compiling"
|
||||
errors=1
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "${effective} ${mode}" > "${RUNNER_TEMP}/version-${component}.txt"
|
||||
compiling="${compiling}${component} "
|
||||
done
|
||||
if [ "$errors" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$compiling" ]; then
|
||||
echo "::error::Nothing to compile: no component has pending fragments to release"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
body_file="${RUNNER_TEMP}/compile-changelogs-pr-body.md"
|
||||
{
|
||||
echo "### Description"
|
||||
echo ""
|
||||
echo "Compiles the pending changelog fragments into the per-component \`CHANGELOG.md\` files for Prowler v${PROWLER_VERSION}, replacing the manual stamping PR."
|
||||
echo ""
|
||||
echo "| Component | Version | Fragments consumed |"
|
||||
echo "|---|---|---|"
|
||||
} > "$body_file"
|
||||
|
||||
compiled_components=""
|
||||
for component in prowler api ui mcp_server; do
|
||||
if [ ! -f "${RUNNER_TEMP}/version-${component}.txt" ]; then
|
||||
echo "Skipping ${component} (no pending fragments or held back)"
|
||||
echo "| \`${component}\` | - | 0 |" >> "$body_file"
|
||||
continue
|
||||
fi
|
||||
read -r version mode < "${RUNNER_TEMP}/version-${component}.txt"
|
||||
version_label="$version"
|
||||
if [ "$mode" = "auto" ]; then
|
||||
version_label="${version} (auto)"
|
||||
fi
|
||||
|
||||
count=$(pending_fragments "$component" | wc -l | tr -d ' ')
|
||||
echo "Compiling ${component} ${version} (${count} fragments, ${mode} version)..."
|
||||
|
||||
# Captured before attribution renames them: these original paths are
|
||||
# what the forward-sync deletes on master (backports copy fragments
|
||||
# verbatim, so filenames match across branches).
|
||||
pending_fragments "$component" > "${RUNNER_TEMP}/consumed-${component}.txt"
|
||||
pre_lines=$(wc -l < "$component/CHANGELOG.md")
|
||||
|
||||
# Attribution must run before the build: towncrier renders the
|
||||
# first dotted segment of each filename as the PR number.
|
||||
python .github/scripts/changelog_attribution.py "$component"
|
||||
towncrier build --config "$component/towncrier.toml" --version "$version" --name "Prowler v${PROWLER_VERSION}" --yes
|
||||
|
||||
# The build only inserts lines right after the marker, so the new
|
||||
# stamped block is exactly the added lines following it. Captured
|
||||
# for the forward-sync to master.
|
||||
post_lines=$(wc -l < "$component/CHANGELOG.md")
|
||||
delta=$((post_lines - pre_lines))
|
||||
marker_line=$(grep -n -m1 '^<!-- changelog: release notes start -->$' "$component/CHANGELOG.md" | cut -d: -f1)
|
||||
sed -n "$((marker_line + 1)),$((marker_line + delta))p" "$component/CHANGELOG.md" > "${RUNNER_TEMP}/block-${component}.md"
|
||||
|
||||
compiled_components="${compiled_components}${component} "
|
||||
echo "| \`${component}\` | ${version_label} | ${count} |" >> "$body_file"
|
||||
done
|
||||
echo "COMPILED_COMPONENTS=${compiled_components}" >> "$GITHUB_ENV"
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "Review that no pending fragment was dropped (the diff must delete every consumed fragment) and that each new version block is correct, then squash-merge."
|
||||
echo ""
|
||||
echo "### License"
|
||||
echo ""
|
||||
echo "By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license."
|
||||
} >> "$body_file"
|
||||
|
||||
echo "PR_BODY_FILE=${body_file}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Create compile PR
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
commit-message: 'chore(changelog): v${{ env.PROWLER_VERSION }}'
|
||||
branch: compile-changelogs-${{ env.PROWLER_VERSION }}
|
||||
base: ${{ env.TARGET_BRANCH }}
|
||||
title: 'chore(changelog): v${{ env.PROWLER_VERSION }}'
|
||||
body-path: ${{ env.PR_BODY_FILE }}
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
labels: |
|
||||
no-changelog
|
||||
skip-sync
|
||||
|
||||
# Patch compiles (target_branch = v5.X) leave master holding the consumed
|
||||
# fragments and missing the new version block. This applies the equivalent
|
||||
# change to master: insert the same stamped blocks under the marker and
|
||||
# delete the consumed fragments, so the next minor compile cannot
|
||||
# re-release entries that already shipped in the patch.
|
||||
- name: Apply forward-sync to master
|
||||
if: env.TARGET_BRANCH != 'master'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git checkout -B master origin/master
|
||||
|
||||
version_key() {
|
||||
local version="$1"
|
||||
local major minor patch
|
||||
IFS=. read -r major minor patch <<< "$version"
|
||||
printf '%06d.%06d.%06d' "$((10#$major))" "$((10#$minor))" "$((10#$patch))"
|
||||
}
|
||||
|
||||
release_from_heading() {
|
||||
local heading="$1"
|
||||
echo "$heading" | sed -E 's/^## \[[^]]+\] \(Prowler v?([0-9]+\.[0-9]+\.[0-9]+)\).*/\1/'
|
||||
}
|
||||
|
||||
insert_changelog_block_ordered() {
|
||||
local component="$1"
|
||||
local block_file="$2"
|
||||
local changelog="${component}/CHANGELOG.md"
|
||||
local incoming_heading incoming_release incoming_key
|
||||
local marker_line insertion_line duplicate_line total_lines
|
||||
local line heading existing_release existing_key
|
||||
|
||||
marker_line=$(grep -n -m1 '^<!-- changelog: release notes start -->$' "$changelog" | cut -d: -f1)
|
||||
incoming_heading=$(grep -m1 -E '^## \[[^]]+\] \(Prowler v?[0-9]+\.[0-9]+\.[0-9]+\)' "$block_file" || true)
|
||||
if [ -z "$incoming_heading" ]; then
|
||||
echo "::error::${block_file} does not contain a stamped Prowler release heading"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
incoming_release=$(release_from_heading "$incoming_heading")
|
||||
incoming_key=$(version_key "$incoming_release")
|
||||
insertion_line=""
|
||||
duplicate_line=""
|
||||
|
||||
while IFS=: read -r line heading; do
|
||||
existing_release=$(release_from_heading "$heading")
|
||||
existing_key=$(version_key "$existing_release")
|
||||
if [[ "$incoming_key" == "$existing_key" ]]; then
|
||||
duplicate_line="$line"
|
||||
break
|
||||
fi
|
||||
if [[ "$incoming_key" > "$existing_key" ]]; then
|
||||
insertion_line="$line"
|
||||
break
|
||||
fi
|
||||
done < <(grep -n -E '^## \[[^]]+\] \(Prowler v?[0-9]+\.[0-9]+\.[0-9]+\)' "$changelog" || true)
|
||||
|
||||
if [ -n "$duplicate_line" ]; then
|
||||
echo "::error::${changelog} already contains a block for Prowler v${incoming_release} at line ${duplicate_line}; refusing to insert a duplicate"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$insertion_line" ]; then
|
||||
insertion_line=$(($(wc -l < "$changelog") + 1))
|
||||
fi
|
||||
if [ "$insertion_line" -le "$marker_line" ]; then
|
||||
insertion_line=$((marker_line + 1))
|
||||
fi
|
||||
|
||||
# The captured block window can be off by one blank line on either
|
||||
# end (towncrier re-emits the blank after the marker), so strip the
|
||||
# outer blank lines and pad exactly one on each side: the block
|
||||
# must never glue to the marker above or the next heading below.
|
||||
awk '
|
||||
/[^[:space:]]/ { for (i = 0; i < pending; i++) print ""; pending = 0; print; started = 1; next }
|
||||
started { pending++ }
|
||||
' "$block_file" > "${RUNNER_TEMP}/block-normalized.md"
|
||||
|
||||
total_lines=$(wc -l < "$changelog")
|
||||
{
|
||||
head -n "$((insertion_line - 1))" "$changelog"
|
||||
if [ "$insertion_line" -gt 1 ] && [ -n "$(sed -n "$((insertion_line - 1))p" "$changelog")" ]; then
|
||||
echo ""
|
||||
fi
|
||||
cat "${RUNNER_TEMP}/block-normalized.md"
|
||||
if [ "$insertion_line" -le "$total_lines" ]; then
|
||||
echo ""
|
||||
fi
|
||||
tail -n +"$insertion_line" "$changelog"
|
||||
} > "${RUNNER_TEMP}/changelog.tmp"
|
||||
mv "${RUNNER_TEMP}/changelog.tmp" "$changelog"
|
||||
|
||||
echo "::notice::Inserted ${component} changelog block for Prowler v${incoming_release} at line ${insertion_line}"
|
||||
}
|
||||
|
||||
sync_body="${RUNNER_TEMP}/forward-sync-pr-body.md"
|
||||
{
|
||||
echo "### Description"
|
||||
echo ""
|
||||
echo "Forward-syncs the v${PROWLER_VERSION} compiled changelogs from \`${TARGET_BRANCH}\` to \`master\`: inserts the same stamped version blocks under the insertion marker and deletes the consumed fragments, so the next minor compile cannot re-release entries that already shipped in this patch. Opened automatically by the same run that opened the compile PR; review and squash-merge after it."
|
||||
echo ""
|
||||
echo "| Component | Fragments deleted on master | Skipped (only on ${TARGET_BRANCH}) |"
|
||||
echo "|---|---|---|"
|
||||
} > "$sync_body"
|
||||
|
||||
for component in $COMPILED_COMPONENTS; do
|
||||
block_file="${RUNNER_TEMP}/block-${component}.md"
|
||||
consumed_file="${RUNNER_TEMP}/consumed-${component}.txt"
|
||||
|
||||
if ! grep -qm1 '^<!-- changelog: release notes start -->$' "$component/CHANGELOG.md"; then
|
||||
echo "::error::${component}/CHANGELOG.md on master is missing the insertion marker; cannot forward-sync"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
deleted=0
|
||||
skipped=0
|
||||
while IFS= read -r fragment; do
|
||||
if [ -z "$fragment" ]; then
|
||||
continue
|
||||
fi
|
||||
if [ -f "$fragment" ]; then
|
||||
git rm -q "$fragment"
|
||||
deleted=$((deleted + 1))
|
||||
else
|
||||
echo "::notice::${fragment} does not exist on master (change landed only on ${TARGET_BRANCH}); skipping its deletion"
|
||||
skipped=$((skipped + 1))
|
||||
fi
|
||||
done < "$consumed_file"
|
||||
|
||||
insert_changelog_block_ordered "$component" "$block_file"
|
||||
|
||||
echo "| \`${component}\` | ${deleted} | ${skipped} |" >> "$sync_body"
|
||||
done
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "### License"
|
||||
echo ""
|
||||
echo "By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license."
|
||||
} >> "$sync_body"
|
||||
|
||||
echo "SYNC_BODY_FILE=${sync_body}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Create forward-sync PR
|
||||
if: env.TARGET_BRANCH != 'master'
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
commit-message: 'chore(changelog): v${{ env.PROWLER_VERSION }} forward-sync to master'
|
||||
branch: forward-sync-changelogs-${{ env.PROWLER_VERSION }}
|
||||
base: master
|
||||
title: 'chore(changelog): v${{ env.PROWLER_VERSION }} forward-sync to master'
|
||||
body-path: ${{ env.SYNC_BODY_FILE }}
|
||||
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
|
||||
labels: |
|
||||
no-changelog
|
||||
skip-sync
|
||||
@@ -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,10 +98,13 @@ 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
|
||||
mcp_server/changelog.d/**
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -19,6 +19,60 @@ concurrency:
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
test-changelog-attribution:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
api.github.com:443
|
||||
github.com:443
|
||||
objects.githubusercontent.com:443
|
||||
pypi.org:443
|
||||
files.pythonhosted.org:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Fetch PR base ref for tj-actions/changed-files
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
run: git fetch --depth=1 origin "${BASE_REF}"
|
||||
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: |
|
||||
.github/scripts/changelog_attribution.py
|
||||
.github/workflows/pr-check-changelog.yml
|
||||
.github/workflows/compile-changelogs.yml
|
||||
.github/towncrier/template.md.jinja
|
||||
*/towncrier.toml
|
||||
tests/github/**
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Test changelog attribution
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
run: |
|
||||
python3 -m pip install --user --disable-pip-version-check pytest==9.0.3 towncrier==25.8.0
|
||||
python3 -m pytest tests/github
|
||||
|
||||
check-changelog:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'no-changelog') == false
|
||||
runs-on: ubuntu-latest
|
||||
@@ -31,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: >
|
||||
@@ -62,53 +116,160 @@ jobs:
|
||||
uv.lock
|
||||
pyproject.toml
|
||||
|
||||
- name: Check for folder changes and changelog presence
|
||||
- name: Check for folder changes and changelog fragment presence
|
||||
id: check-folders
|
||||
run: |
|
||||
missing_changelogs=""
|
||||
fragment_name_re='^[A-Za-z0-9][A-Za-z0-9._-]*\.(added|changed|deprecated|removed|fixed|security)(\.[0-9]+)?\.md$'
|
||||
manual_pr_link_re='(\[\(#[0-9]+\)\]|\[#[0-9]+\]\(|\(#[0-9]+\)|github\.com/[^[:space:]/]+/[^[:space:]/]+/(pull|issues)/[0-9]+)'
|
||||
folder_alt=$(echo "$MONITORED_FOLDERS" | tr ' ' '|')
|
||||
|
||||
missing_fragments=""
|
||||
invalid_fragments=""
|
||||
linked_fragments=""
|
||||
handwritten_changelogs=""
|
||||
|
||||
all_changed=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n')
|
||||
added=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" | tr ' ' '\n')
|
||||
added_or_renamed=$(printf '%s\n%s' "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES}" | tr ' ' '\n')
|
||||
added_modified_or_renamed=$(printf '%s\n%s\n%s' "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_MODIFIED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES}" | tr ' ' '\n')
|
||||
|
||||
# Returns success if the folder has a valid fragment added, modified, or renamed.
|
||||
has_changelog_update() {
|
||||
local folder="$1"
|
||||
if echo "$added_modified_or_renamed" | grep "^${folder}/changelog.d/" | sed "s|^${folder}/changelog.d/||" | grep -qE "$fragment_name_re"; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ "${STEPS_CHANGED_FILES_OUTPUTS_ANY_CHANGED}" == "true" ]]; then
|
||||
# Check monitored folders
|
||||
for folder in $MONITORED_FOLDERS; do
|
||||
# Get files changed in this folder
|
||||
changed_in_folder=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | grep "^${folder}/" || true)
|
||||
if echo "$all_changed" | grep -q "^${folder}/CHANGELOG.md$"; then
|
||||
echo "Direct CHANGELOG.md edits are not allowed for ${folder}/"
|
||||
handwritten_changelogs="${handwritten_changelogs}- \`${folder}/CHANGELOG.md\`"$'\n'
|
||||
fi
|
||||
|
||||
changed_in_folder=$(echo "$all_changed" | grep "^${folder}/" | grep -v "^${folder}/CHANGELOG.md$" || true)
|
||||
|
||||
if [ -n "$changed_in_folder" ]; then
|
||||
echo "Detected changes in ${folder}/"
|
||||
|
||||
# Check if CHANGELOG.md was updated
|
||||
if ! echo "$changed_in_folder" | grep -q "^${folder}/CHANGELOG.md$"; then
|
||||
echo "No changelog update found for ${folder}/"
|
||||
missing_changelogs="${missing_changelogs}- \`${folder}\`"$'\n'
|
||||
if ! has_changelog_update "$folder"; then
|
||||
echo "No changelog fragment found for ${folder}/"
|
||||
missing_fragments="${missing_fragments}- \`${folder}\`"$'\n'
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Check root-level dependency files (uv.lock, pyproject.toml)
|
||||
# These are associated with the prowler folder changelog
|
||||
root_deps_changed=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | grep -E "^(uv\.lock|pyproject\.toml)$" || true)
|
||||
root_deps_changed=$(echo "$all_changed" | grep -E "^(uv\.lock|pyproject\.toml)$" || true)
|
||||
if [ -n "$root_deps_changed" ]; then
|
||||
echo "Detected changes in root dependency files: $root_deps_changed"
|
||||
# Check if prowler/CHANGELOG.md was already updated (might have been caught above)
|
||||
prowler_changelog_updated=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | grep "^prowler/CHANGELOG.md$" || true)
|
||||
if [ -z "$prowler_changelog_updated" ]; then
|
||||
if ! has_changelog_update "prowler"; then
|
||||
# Only add if prowler wasn't already flagged
|
||||
if ! echo "$missing_changelogs" | grep -q "prowler"; then
|
||||
echo "No changelog update found for root dependency changes"
|
||||
missing_changelogs="${missing_changelogs}- \`prowler\` (root dependency files changed)"$'\n'
|
||||
if ! echo "$missing_fragments" | grep -q "prowler"; then
|
||||
echo "No changelog fragment found for root dependency changes"
|
||||
missing_fragments="${missing_fragments}- \`prowler\` (root dependency files changed)"$'\n'
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Validate the filename of every fragment added by this PR
|
||||
added_fragments=$(echo "$added_or_renamed" | grep -E "^(${folder_alt})/changelog\.d/" || true)
|
||||
for fragment in $added_fragments; do
|
||||
name=$(basename "$fragment")
|
||||
if [ "$name" = ".gitkeep" ] || [ "$name" = "README.md" ]; then
|
||||
continue
|
||||
fi
|
||||
if ! echo "$name" | grep -qE "$fragment_name_re"; then
|
||||
echo "Invalid fragment filename: $fragment"
|
||||
invalid_fragments="${invalid_fragments}- \`${fragment}\`"$'\n'
|
||||
fi
|
||||
done
|
||||
|
||||
# Lint fragment content: the PR link is attached automatically at
|
||||
# compile time, so a hand-written PR or issue link would be wrong
|
||||
touched_fragments=$(echo "$added_modified_or_renamed" | grep -E "^(${folder_alt})/changelog\.d/" || true)
|
||||
for fragment in $touched_fragments; do
|
||||
name=$(basename "$fragment")
|
||||
if [ "$name" = ".gitkeep" ] || [ "$name" = "README.md" ] || [ ! -f "$fragment" ]; then
|
||||
continue
|
||||
fi
|
||||
if grep -qE "$manual_pr_link_re" "$fragment"; then
|
||||
echo "Fragment contains a hand-written PR or issue link: $fragment"
|
||||
linked_fragments="${linked_fragments}- \`${fragment}\`"$'\n'
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
{
|
||||
echo "missing_changelogs<<EOF"
|
||||
echo -e "${missing_changelogs}"
|
||||
echo "EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
# Suggest a slug derived from the branch name for the bot comment
|
||||
suggested_slug=$(echo "$HEAD_REF" | tr '[:upper:]' '[:lower:]' | sed 's|.*/||; s/[^a-z0-9._-]/-/g; s/^[^a-z0-9]*//')
|
||||
if [ -z "$suggested_slug" ]; then
|
||||
suggested_slug="my-change"
|
||||
fi
|
||||
|
||||
fragment_help="A changelog fragment is a small Markdown file named \`<slug>.<type>.md\` under \`<component>/changelog.d/\`, where \`<type>\` is one of \`added\`, \`changed\`, \`deprecated\`, \`removed\`, \`fixed\` or \`security\`. Its content is the changelog entry text, without the PR link (added automatically at release time) and without a trailing period. For example:
|
||||
|
||||
\`\`\`
|
||||
echo 'Entry text describing the change' > <component>/changelog.d/${suggested_slug}.fixed.md
|
||||
\`\`\`
|
||||
|
||||
If this PR does not need a changelog entry, add the \`no-changelog\` label instead."
|
||||
|
||||
if [ -n "$missing_fragments" ] || [ -n "$invalid_fragments" ] || [ -n "$linked_fragments" ] || [ -n "$handwritten_changelogs" ]; then
|
||||
comment_body=""
|
||||
if [ -n "$missing_fragments" ]; then
|
||||
comment_body="⚠️ **Changes detected in the following folders without a changelog fragment:**"$'\n\n'"${missing_fragments}"$'\n'
|
||||
fi
|
||||
if [ -n "$invalid_fragments" ]; then
|
||||
comment_body="${comment_body}⚠️ **Changelog fragment filenames that do not follow the naming convention:**"$'\n\n'"${invalid_fragments}"$'\n'
|
||||
fi
|
||||
if [ -n "$linked_fragments" ]; then
|
||||
comment_body="${comment_body}⚠️ **Changelog fragments containing a hand-written PR or issue link (remove it; the PR link is attached automatically at release time):**"$'\n\n'"${linked_fragments}"$'\n'
|
||||
fi
|
||||
if [ -n "$handwritten_changelogs" ]; then
|
||||
comment_body="${comment_body}⚠️ **Direct \`CHANGELOG.md\` edits are not allowed in regular PRs:**"$'\n\n'"${handwritten_changelogs}"$'\n'
|
||||
fi
|
||||
comment_body="${comment_body}${fragment_help}"
|
||||
else
|
||||
comment_body="✅ All required changelog fragments are present."
|
||||
fi
|
||||
|
||||
write_multiline_output() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
local delimiter
|
||||
|
||||
while true; do
|
||||
delimiter="EOF_$(openssl rand -hex 16)"
|
||||
if ! grep -qxF "$delimiter" <<< "$value"; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
{
|
||||
echo "${name}<<${delimiter}"
|
||||
if [ -n "$value" ]; then
|
||||
printf '%s\n' "$value"
|
||||
fi
|
||||
echo "${delimiter}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
}
|
||||
|
||||
write_multiline_output "missing_fragments" "$missing_fragments"
|
||||
write_multiline_output "invalid_fragments" "$invalid_fragments"
|
||||
write_multiline_output "linked_fragments" "$linked_fragments"
|
||||
write_multiline_output "handwritten_changelogs" "$handwritten_changelogs"
|
||||
write_multiline_output "comment_body" "$comment_body"
|
||||
env:
|
||||
STEPS_CHANGED_FILES_OUTPUTS_ANY_CHANGED: ${{ steps.changed-files.outputs.any_changed }}
|
||||
STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
|
||||
STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES: ${{ steps.changed-files.outputs.added_files }}
|
||||
STEPS_CHANGED_FILES_OUTPUTS_MODIFIED_FILES: ${{ steps.changed-files.outputs.modified_files }}
|
||||
STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES: ${{ steps.changed-files.outputs.renamed_files }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
|
||||
- name: Find existing changelog comment
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
@@ -128,14 +289,10 @@ jobs:
|
||||
edit-mode: replace
|
||||
body: |
|
||||
<!-- changelog-check -->
|
||||
${{ steps.check-folders.outputs.missing_changelogs != '' && format('⚠️ **Changes detected in the following folders without a corresponding update to the `CHANGELOG.md`:**
|
||||
${{ steps.check-folders.outputs.comment_body }}
|
||||
|
||||
{0}
|
||||
|
||||
Please add an entry to the corresponding `CHANGELOG.md` file to maintain a clear history of changes.', steps.check-folders.outputs.missing_changelogs) || '✅ All necessary `CHANGELOG.md` files have been updated.' }}
|
||||
|
||||
- name: Fail if changelog is missing
|
||||
if: steps.check-folders.outputs.missing_changelogs != ''
|
||||
- name: Fail if changelog fragment is missing or invalid
|
||||
if: steps.check-folders.outputs.missing_fragments != '' || steps.check-folders.outputs.invalid_fragments != '' || steps.check-folders.outputs.linked_fragments != '' || steps.check-folders.outputs.handwritten_changelogs != ''
|
||||
run: |
|
||||
echo "::error::Missing changelog updates in some folders"
|
||||
echo "::error::Missing, invalid, or disallowed changelog updates"
|
||||
exit 1
|
||||
|
||||
@@ -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: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
gh variable set RELEASE_FREEZE --body true --repo "${GITHUB_REPOSITORY}"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -382,3 +389,4 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
rm -f prowler_changelog.md api_changelog.md ui_changelog.md mcp_changelog.md combined_changelog.md
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: 'Tools: Release Freeze Gate'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
merge_group:
|
||||
branches:
|
||||
- 'master'
|
||||
types:
|
||||
- checks_requested
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
release-freeze-gate:
|
||||
name: release-freeze-gate
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check release freeze status
|
||||
env:
|
||||
RELEASE_FREEZE: ${{ vars.RELEASE_FREEZE }}
|
||||
run: |
|
||||
case "${RELEASE_FREEZE}" in
|
||||
true|TRUE|True)
|
||||
echo "::error::Release freeze is active. Merges to master are temporarily blocked."
|
||||
echo "Set the RELEASE_FREEZE repository variable to false when the release is complete."
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Release freeze is not active."
|
||||
;;
|
||||
esac
|
||||
@@ -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: >
|
||||
@@ -55,6 +55,7 @@ jobs:
|
||||
files_ignore: |
|
||||
.github/**
|
||||
prowler/CHANGELOG.md
|
||||
prowler/changelog.d/**
|
||||
docs/**
|
||||
permissions/**
|
||||
api/**
|
||||
|
||||
@@ -12,6 +12,7 @@ on:
|
||||
- '.github/workflows/sdk-codeql.yml'
|
||||
- '.github/codeql/sdk-codeql-config.yml'
|
||||
- '!prowler/CHANGELOG.md'
|
||||
- '!prowler/changelog.d/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
@@ -23,6 +24,7 @@ on:
|
||||
- '.github/workflows/sdk-codeql.yml'
|
||||
- '.github/codeql/sdk-codeql-config.yml'
|
||||
- '!prowler/CHANGELOG.md'
|
||||
- '!prowler/changelog.d/**'
|
||||
schedule:
|
||||
- cron: '00 12 * * *'
|
||||
|
||||
@@ -51,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,8 +113,10 @@ jobs:
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
.github/workflows/sdk-container-checks.yml
|
||||
.github/actions/trivy-scan/**
|
||||
files_ignore: |
|
||||
prowler/CHANGELOG.md
|
||||
prowler/changelog.d/**
|
||||
**/AGENTS.md
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
@@ -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: >
|
||||
@@ -73,6 +73,7 @@ jobs:
|
||||
.github/scripts/osv-scan.sh
|
||||
files_ignore: |
|
||||
prowler/CHANGELOG.md
|
||||
prowler/changelog.d/**
|
||||
**/AGENTS.md
|
||||
|
||||
- name: Setup Python with uv
|
||||
|
||||
@@ -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: >
|
||||
@@ -77,6 +77,7 @@ jobs:
|
||||
files_ignore: |
|
||||
.github/**
|
||||
prowler/CHANGELOG.md
|
||||
prowler/changelog.d/**
|
||||
docs/**
|
||||
permissions/**
|
||||
api/**
|
||||
@@ -614,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: >
|
||||
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- '.github/workflows/ui-codeql.yml'
|
||||
- '.github/codeql/ui-codeql-config.yml'
|
||||
- '!ui/CHANGELOG.md'
|
||||
- '!ui/changelog.d/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
@@ -19,6 +20,7 @@ on:
|
||||
- '.github/workflows/ui-codeql.yml'
|
||||
- '.github/codeql/ui-codeql-config.yml'
|
||||
- '!ui/CHANGELOG.md'
|
||||
- '!ui/changelog.d/**'
|
||||
schedule:
|
||||
- cron: '00 12 * * *'
|
||||
|
||||
@@ -47,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,9 +99,12 @@ 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/**
|
||||
ui/README.md
|
||||
ui/AGENTS.md
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ on:
|
||||
- '.github/test-impact.yml'
|
||||
- 'ui/**'
|
||||
- 'api/**' # API changes can affect UI E2E
|
||||
- '!ui/CHANGELOG.md'
|
||||
- '!api/CHANGELOG.md'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -34,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:
|
||||
@@ -94,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
|
||||
|
||||
@@ -116,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:
|
||||
@@ -302,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
|
||||
@@ -314,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: >
|
||||
@@ -64,6 +64,7 @@ jobs:
|
||||
.github/workflows/ui-tests.yml
|
||||
files_ignore: |
|
||||
ui/CHANGELOG.md
|
||||
ui/changelog.d/**
|
||||
ui/README.md
|
||||
ui/AGENTS.md
|
||||
|
||||
@@ -180,9 +181,9 @@ jobs:
|
||||
if: steps.check-changes.outputs.any_changed == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
- name: Run browser tests
|
||||
- name: Run integration tests
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: pnpm run test:browser
|
||||
run: pnpm run test:integration
|
||||
|
||||
- name: Build application
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -173,3 +173,5 @@ GEMINI.md
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
docker-compose-dev.override.yml
|
||||
# Local Pi runtime state
|
||||
.atl/
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
# Findings excluded from the Grype gate, each with a reason.
|
||||
# Anything not listed here blocks the pull request at critical or high severity.
|
||||
# Pairs are explicit: a new CVE against an already-listed package still blocks.
|
||||
#
|
||||
# Every entry below has a published fix we cannot take. Findings with no fix at all are
|
||||
# not listed: the scan runs with only-fixed, so they never reach the gate.
|
||||
|
||||
ignore:
|
||||
|
||||
# Modules compiled into the Trivy binary we ship.
|
||||
# Only a Trivy rebuild by its vendor can change these; the version is pinned in our Dockerfile.
|
||||
- vulnerability: CVE-2026-56852
|
||||
package:
|
||||
name: golang.org/x/text
|
||||
- vulnerability: GHSA-hrxh-6v49-42gf
|
||||
package:
|
||||
name: google.golang.org/grpc
|
||||
- vulnerability: CVE-2026-50151
|
||||
package:
|
||||
name: oras.land/oras-go/v2
|
||||
|
||||
# Shipped inside the PowerShell tarball, in its bundled MicrosoftTeams module.
|
||||
# Not a dependency we declare, and not one we can upgrade independently.
|
||||
- vulnerability: CVE-2026-26127
|
||||
package:
|
||||
name: Microsoft.Bcl.Memory
|
||||
|
||||
|
||||
# The CPython interpreter, compiled into the official base image.
|
||||
# TEMPORARY, unlike the entries above: moving to Python 3.13 clears seven of these, and
|
||||
# that is a runtime upgrade pending its own evaluation. The remaining three need 3.15 and
|
||||
# are unfixable either way -- the MCP image already runs 3.13.14 and still reports them.
|
||||
- vulnerability: CVE-2026-11940
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-11972
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-15308
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-3298
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-3644
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-4224
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-4786
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-6100
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-7210
|
||||
package:
|
||||
name: python
|
||||
- vulnerability: CVE-2026-9669
|
||||
package:
|
||||
name: python
|
||||
+14
-6
@@ -72,13 +72,13 @@ repos:
|
||||
exclude: contrib
|
||||
priority: 30
|
||||
|
||||
## PYTHON — SDK (prowler/, tests/, dashboard/, util/, scripts/)
|
||||
## PYTHON — SDK (prowler/, tests/, dashboard/, util/, scripts/, docs/scripts/)
|
||||
- repo: https://github.com/myint/autoflake
|
||||
rev: v2.3.3
|
||||
hooks:
|
||||
- id: autoflake
|
||||
name: "SDK - autoflake"
|
||||
files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] }
|
||||
files: { glob: ["{prowler,tests,dashboard,util,scripts,docs/scripts}/**/*.py"] }
|
||||
args: ["--in-place", "--remove-all-unused-imports", "--remove-unused-variable"]
|
||||
priority: 20
|
||||
|
||||
@@ -87,7 +87,7 @@ repos:
|
||||
hooks:
|
||||
- id: isort
|
||||
name: "SDK - isort"
|
||||
files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] }
|
||||
files: { glob: ["{prowler,tests,dashboard,util,scripts,docs/scripts}/**/*.py"] }
|
||||
args: ["--profile", "black"]
|
||||
stages: ["pre-commit"]
|
||||
priority: 20
|
||||
@@ -97,7 +97,7 @@ repos:
|
||||
hooks:
|
||||
- id: black
|
||||
name: "SDK - black"
|
||||
files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] }
|
||||
files: { glob: ["{prowler,tests,dashboard,util,scripts,docs/scripts}/**/*.py"] }
|
||||
priority: 20
|
||||
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
@@ -105,7 +105,7 @@ repos:
|
||||
hooks:
|
||||
- id: flake8
|
||||
name: "SDK - flake8"
|
||||
files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] }
|
||||
files: { glob: ["{prowler,tests,dashboard,util,scripts,docs/scripts}/**/*.py"] }
|
||||
args: ["--ignore=E266,W503,E203,E501,W605"]
|
||||
priority: 30
|
||||
|
||||
@@ -142,6 +142,14 @@ repos:
|
||||
files: { glob: ["mcp_server/**/*.py"] }
|
||||
priority: 20
|
||||
|
||||
- id: generate-provider-cards
|
||||
name: "Docs - regenerate provider cards snippet"
|
||||
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
|
||||
priority: 20
|
||||
|
||||
## PYTHON — uv (API + SDK)
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.11.14
|
||||
@@ -183,7 +191,7 @@ repos:
|
||||
entry: pylint --disable=W,C,R,E -j 0 -rn -sn
|
||||
language: system
|
||||
types: [python]
|
||||
files: { glob: ["{prowler,tests,dashboard,util,scripts}/**/*.py"] }
|
||||
files: { glob: ["{prowler,tests,dashboard,util,scripts,docs/scripts}/**/*.py"] }
|
||||
priority: 30
|
||||
|
||||
- id: trufflehog
|
||||
|
||||
+48
-88
@@ -1,98 +1,58 @@
|
||||
# Trivy ignore file for prowlercloud/prowler SDK container image.
|
||||
# Trivy ignore file for the prowlercloud/prowler SDK and API container images.
|
||||
# Each entry below documents (a) the affected package and why it ships in the
|
||||
# image, (b) why the CVE is not exploitable in Prowler's runtime, and (c) the
|
||||
# upstream fix status. Entries carry an expiry so they auto-force re-review.
|
||||
# Entries are scoped per-package so suppressions cannot drift onto unrelated
|
||||
# packages that may be assigned the same CVE in the future.
|
||||
# The `pkg:` selector on each line is documentation only. Trivy's classic
|
||||
# .trivyignore format parses the CVE ID and ignores the rest, so each entry
|
||||
# suppresses its CVE across every package in the image, not just the one named.
|
||||
# Verified against Trivy 0.65.0: an entry written `pkg:zlib1g` still suppressed
|
||||
# the finding on perl-base. Real per-package scoping needs .trivyignore.yaml
|
||||
# with purls — tracked in PROWLER-2327.
|
||||
# `exp:` IS honoured: an entry dated in the past correctly lapses.
|
||||
#
|
||||
# Keep expiries staggered, and only suppress packages the images actually install.
|
||||
#
|
||||
# Scanned by: .github/actions/trivy-scan via .github/workflows/sdk-container-checks.yml
|
||||
# and .github/workflows/api-container-checks.yml
|
||||
|
||||
# CVE-2026-42496 — perl-archive-tar path traversal via crafted symlinks.
|
||||
# CVE-2026-8376 — perl heap buffer overflow when compiling regex.
|
||||
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
|
||||
# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
|
||||
# removed without breaking dpkg. The Prowler SDK does not invoke perl at runtime;
|
||||
# neither vulnerable code path (Archive::Tar parsing or regex compilation of
|
||||
# attacker-controlled input) is reachable from Prowler. No Debian bookworm fix
|
||||
# is available yet.
|
||||
CVE-2026-42496 pkg:perl exp:2026-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
|
||||
# perl-base is Debian "Essential: yes". Trivy spreads src:perl CVEs across every
|
||||
# binary package, so perl-base gets flagged for modules only perl-modules-5.40 ships.
|
||||
# Neither image installs perl-modules-5.40, and nothing in either invokes perl.
|
||||
#
|
||||
# Why these four cannot be fixed rather than accepted (reviewed 2026-07-31):
|
||||
#
|
||||
# 1. No fix exists. All four report no fixed version on perl-base 5.40.1-6.
|
||||
# Debian's tracker marks CVE-2026-42496 "fix_deferred" and the other three
|
||||
# "affected". Updating the base image, apt upgrade, or moving to a newer
|
||||
# Debian release changes nothing, because Debian has not shipped a fix.
|
||||
# 2. The package cannot be removed. "Essential: yes" means removal needs
|
||||
# dpkg --force-remove-essential, which also breaks apt for anything built
|
||||
# downstream from these images.
|
||||
# 3. Changing base distribution was evaluated and rejected. Alpine removes
|
||||
# perl entirely, but PowerShell publishes no linux-musl-arm64 build in any
|
||||
# release, so M365 scanning would break on arm64 — which is what we run in
|
||||
# production. Wolfi keeps glibc and drops perl, but pinnable versioned tags
|
||||
# are a paid tier, so builds would not be reproducibly pinnable.
|
||||
#
|
||||
# Not-invoked claim verified by sweeping both images for: files with a perl
|
||||
# shebang, shell/python callers of perl, ELF binaries containing "perl", and
|
||||
# .pl/.pm files or perl subprocess calls anywhere in site-packages. The only
|
||||
# consumers found are dpkg/debconf/adduser/pam tooling, none of which runs at
|
||||
# runtime, plus one build-time script inside the ExchangeOnlineManagement
|
||||
# PowerShell module that is never invoked (it generates that module's manifest,
|
||||
# and lives in its netFramework/ path, while Linux loads netCore/).
|
||||
|
||||
# CVE-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
|
||||
# Archive::Tar path traversal. Not installed: `perl -MArchive::Tar -e1` cannot locate it.
|
||||
CVE-2026-42496 pkg:perl-base exp:2027-01-31
|
||||
|
||||
# CVE-2026-43185 — Linux kernel ksmbd signedness bug.
|
||||
# Package: linux-libc-dev.
|
||||
# Why ignored: linux-libc-dev ships kernel headers for build-time compilation,
|
||||
# not a running kernel. Containers execute against the host kernel, so these
|
||||
# headers are inert at runtime. The upstream fix landed in kernel 7.0-rc2 and
|
||||
# has not been backported to Debian's 6.1 LTS line.
|
||||
CVE-2026-43185 pkg:linux-libc-dev exp:2026-07-15
|
||||
# Storable integer overflow. Not installed: `perl -MStorable -e1` cannot locate it.
|
||||
CVE-2026-57433 pkg:perl-base exp:2027-01-31
|
||||
|
||||
# CVE-2023-45853 — zlib MiniZip integer overflow / heap overflow in
|
||||
# zipOpenNewFileInZip4_64.
|
||||
# Packages: zlib1g, zlib1g-dev.
|
||||
# Why ignored: Debian Security Tracker status for bookworm is <ignored>, with
|
||||
# the published rationale "contrib/minizip not built and src:zlib not producing
|
||||
# binary packages" — i.e. the vulnerable symbol is not present in the libz.so
|
||||
# shipped by Debian. Real-not-affected, not unpatched. Upstream fix is in
|
||||
# zlib 1.3.1, available in Debian trixie (13); migrating the base image would
|
||||
# clear it fully.
|
||||
# Ref: https://security-tracker.debian.org/tracker/CVE-2023-45853
|
||||
CVE-2023-45853 pkg:zlib1g exp:2026-07-15
|
||||
CVE-2023-45853 pkg:zlib1g-dev exp:2026-07-15
|
||||
# Regex heap overflow on 32-bit builds only; both published arches are 64-bit.
|
||||
CVE-2026-8376 pkg:perl-base exp:2027-01-31
|
||||
|
||||
# CVE-2026-55200 — libssh2 out-of-bounds write in ssh2_transport_read() due to
|
||||
# an unchecked packet_length field in transport.c (heap corruption, possible RCE).
|
||||
# Package: libssh2-1.
|
||||
# Why ignored: libssh2-1 is pulled in only as a transitive dependency of libcurl4
|
||||
# (installed in the SDK Dockerfile for the networking/PowerShell stack). The
|
||||
# vulnerable path is reached exclusively when libssh2 acts as an SSH/SCP/SFTP
|
||||
# client parsing transport packets from a server. Prowler never uses libcurl's
|
||||
# SSH/SCP/SFTP transports; it talks to cloud provider HTTPS endpoints only, so the
|
||||
# affected code is unreachable at runtime. Fixed upstream in libssh2 commit
|
||||
# 97acf3df (PR #2052); no Debian bookworm fix is available yet.
|
||||
# Ref: https://security-tracker.debian.org/tracker/CVE-2026-55200
|
||||
CVE-2026-55200 pkg:libssh2-1 exp:2026-07-15
|
||||
|
||||
# --- API container image (api/Dockerfile) ---
|
||||
# The entries below are specific to the Prowler API image, which ships
|
||||
# PowerShell and additional build tooling on top of the same bookworm base.
|
||||
|
||||
# CVE-2026-7210 — CPython/Expat hash-flooding denial of service in
|
||||
# `xml.parsers.expat` and `xml.etree.ElementTree`.
|
||||
# Packages: the Debian system Python 3.11 (python3.11*, libpython3.11*).
|
||||
# Why ignored: the API runs under the Python 3.12 interpreter shipped in its
|
||||
# `.venv`; the system `python3.11` is only present because `python3-dev` is
|
||||
# pulled in to compile native extensions (xmlsec, lxml) and is never executed
|
||||
# at runtime. The vulnerable path requires parsing attacker-controlled XML with
|
||||
# the affected interpreter, which Prowler does not do with the system Python.
|
||||
# Full mitigation also needs libexpat >= 2.8.0; no Debian bookworm fix yet.
|
||||
CVE-2026-7210 pkg:python3.11 exp:2026-07-15
|
||||
CVE-2026-7210 pkg:python3.11-dev exp:2026-07-15
|
||||
CVE-2026-7210 pkg:python3.11-minimal exp:2026-07-15
|
||||
CVE-2026-7210 pkg:libpython3.11 exp:2026-07-15
|
||||
CVE-2026-7210 pkg:libpython3.11-dev exp:2026-07-15
|
||||
CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-07-15
|
||||
CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-07-15
|
||||
|
||||
# CVE-2026-33278 — Unbound DNSSEC validator use-after-free (DoS, possible RCE).
|
||||
# CVE-2026-42960 — Unbound DNS cache poisoning via promiscuous additional records.
|
||||
# Package: libunbound8.
|
||||
# Why ignored: libunbound8 is a transitive apt dependency of the TLS/networking
|
||||
# stack (GnuTLS DANE support); only the shared library ships in the image. Both
|
||||
# vulnerabilities require operating a live Unbound recursive DNSSEC validator
|
||||
# that processes attacker-influenced DNS responses. Prowler never starts an
|
||||
# Unbound resolver, so neither code path is reachable. No Debian bookworm fix yet.
|
||||
CVE-2026-33278 pkg:libunbound8 exp:2026-07-15
|
||||
CVE-2026-42960 pkg:libunbound8 exp:2026-07-15
|
||||
# Regex trie bug giving silently wrong matches above 65535 alternation branches. Now on
|
||||
# perl 5.40.1, which is in range (the 5.36-predates-it argument no longer applies), so this
|
||||
# rests on nothing invoking perl. Short expiry to force a re-look.
|
||||
# Ref: https://github.com/Perl/perl5/issues/23388
|
||||
CVE-2026-13221 pkg:perl-base exp:2026-11-30
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# Trivy suppressions for the prowlercloud/prowler SDK and API container images.
|
||||
#
|
||||
# This file replaces the classic .trivyignore, which parsed only the CVE id: the
|
||||
# `pkg:` selector written on each line was documentation and the entry suppressed
|
||||
# its CVE across every package in the image. The `purls` field below is honoured,
|
||||
# so each entry is scoped to the package it names. Verified against Trivy 0.71.2:
|
||||
# an entry given the wrong purl leaves the finding reported, where the classic
|
||||
# format suppressed it.
|
||||
#
|
||||
# `expired_at` forces re-review. Keep the dates staggered.
|
||||
#
|
||||
# The four entries below are currently redundant: the scan runs with ignore-unfixed,
|
||||
# and none of them has a published fix, so they never reach the gate either way. They
|
||||
# are kept because the reasoning is what justifies accepting them, and because they
|
||||
# apply again the moment any of them gains a fix we do not take.
|
||||
#
|
||||
# perl-base is Debian "Essential: yes". Trivy spreads src:perl CVEs across every
|
||||
# binary package built from that source, so perl-base is flagged for modules only
|
||||
# perl-modules-* ships. Neither image installs those, and nothing in either
|
||||
# invokes perl.
|
||||
#
|
||||
# Why these four are accepted rather than fixed (reviewed 2026-07-31):
|
||||
#
|
||||
# 1. No fix exists. All four report no fixed version on perl-base 5.40.1-6.
|
||||
# Debian marks CVE-2026-42496 "fix_deferred" and the other three "affected".
|
||||
# A newer base image, apt upgrade, or a newer Debian release changes nothing.
|
||||
# 2. The package cannot be removed. "Essential: yes" means removal needs
|
||||
# dpkg --force-remove-essential, which breaks apt for anything built
|
||||
# downstream from these images.
|
||||
# 3. Changing base distribution was evaluated and rejected. Alpine drops perl
|
||||
# entirely, but PowerShell publishes no linux-musl-arm64 build in any
|
||||
# release, so M365 scanning would break on arm64 -- which is what we run in
|
||||
# production. Wolfi keeps glibc and drops perl, but pinnable versioned tags
|
||||
# are a paid tier, so builds would not be reproducibly pinnable.
|
||||
#
|
||||
# Not-invoked claim verified by sweeping both images for files with a perl
|
||||
# shebang, shell/python callers of perl, ELF binaries containing "perl", and
|
||||
# .pl/.pm files or perl subprocess calls anywhere in site-packages. The only
|
||||
# consumers found are dpkg/debconf/adduser/pam tooling, none of which runs at
|
||||
# runtime, plus one build-time script inside the ExchangeOnlineManagement
|
||||
# PowerShell module that is never invoked.
|
||||
|
||||
vulnerabilities:
|
||||
# Archive::Tar path traversal. Not installed: `perl -MArchive::Tar -e1` cannot locate it.
|
||||
- id: CVE-2026-42496
|
||||
purls:
|
||||
- "pkg:deb/debian/perl-base"
|
||||
expired_at: 2027-01-31
|
||||
|
||||
# Storable integer overflow. Not installed: `perl -MStorable -e1` cannot locate it.
|
||||
- id: CVE-2026-57433
|
||||
purls:
|
||||
- "pkg:deb/debian/perl-base"
|
||||
expired_at: 2027-01-31
|
||||
|
||||
# Regex heap overflow on 32-bit builds only; both published arches are 64-bit.
|
||||
- id: CVE-2026-8376
|
||||
purls:
|
||||
- "pkg:deb/debian/perl-base"
|
||||
expired_at: 2027-01-31
|
||||
|
||||
# Regex trie bug giving silently wrong matches above 65535 alternation branches.
|
||||
# perl 5.40.1 is in range, so this rests on nothing invoking perl. Short expiry
|
||||
# to force a re-look. Ref: https://github.com/Perl/perl5/issues/23388
|
||||
- id: CVE-2026-13221
|
||||
purls:
|
||||
- "pkg:deb/debian/perl-base"
|
||||
expired_at: 2026-11-30
|
||||
|
||||
# Declared in the SPDX manifest that ships inside PowerShell's MicrosoftTeams module
|
||||
# (Modules/MicrosoftTeams/7.9.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that
|
||||
# SBOM and reports what it declares, which is not the same as what the image contains:
|
||||
# there is no Node runtime and no node_modules anywhere in the image, and the .NET
|
||||
# assemblies target net472, a Windows-only framework. Nothing here is reachable, and none
|
||||
# of it is a dependency we declare -- only Microsoft can change the module's contents.
|
||||
- id: CVE-2020-0606
|
||||
purls:
|
||||
- "pkg:nuget/Microsoft.WindowsDesktop.App.Ref"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2019-0820
|
||||
purls:
|
||||
- "pkg:nuget/System.Text.RegularExpressions"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-47302
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-47304
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-50525
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-50527
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-50648
|
||||
purls:
|
||||
- "pkg:nuget/System.Security.Cryptography.Xml"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-13676
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-16221
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-18446
|
||||
purls:
|
||||
- "pkg:npm/fast-uri"
|
||||
expired_at: 2027-01-31
|
||||
- id: CVE-2026-69192
|
||||
purls:
|
||||
- "pkg:npm/ip-address"
|
||||
expired_at: 2027-01-31
|
||||
|
||||
# Modules compiled into the Trivy binary the images ship. The binary is pinned by version
|
||||
# and verified by checksum in the Dockerfile; only a rebuild by its vendor moves these.
|
||||
- id: CVE-2026-56852
|
||||
purls:
|
||||
- "pkg:golang/golang.org/x/text"
|
||||
expired_at: 2026-12-31
|
||||
- id: GHSA-hrxh-6v49-42gf
|
||||
purls:
|
||||
- "pkg:golang/google.golang.org/grpc"
|
||||
expired_at: 2026-12-31
|
||||
- id: CVE-2026-50151
|
||||
purls:
|
||||
- "pkg:golang/oras.land/oras-go/v2"
|
||||
expired_at: 2026-12-31
|
||||
- id: CVE-2026-50163
|
||||
purls:
|
||||
- "pkg:golang/oras.land/oras-go/v2"
|
||||
expired_at: 2026-12-31
|
||||
- id: CVE-2026-39822
|
||||
purls:
|
||||
- "pkg:golang/stdlib"
|
||||
expired_at: 2026-12-31
|
||||
|
||||
@@ -62,6 +62,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Action | Skill |
|
||||
|--------|-------|
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| Adding ConfigRequirements guardrails to compliance requirements | `prowler-compliance` |
|
||||
| Adding DRF pagination or permissions | `django-drf` |
|
||||
| Adding a compliance output formatter (per-provider class + table dispatcher) | `prowler-compliance` |
|
||||
| Adding indexes or constraints to database tables | `django-migration-psql` |
|
||||
@@ -84,6 +85,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
|
||||
| Creating Zod schemas | `zod-4` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating a universal (multi-provider) compliance framework | `prowler-compliance` |
|
||||
| Creating new checks | `prowler-sdk-check` |
|
||||
| Creating new skills | `skill-creator` |
|
||||
| Creating or reviewing Django migrations | `django-migration-psql` |
|
||||
|
||||
+20
-5
@@ -1,12 +1,14 @@
|
||||
FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build
|
||||
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/prowler"
|
||||
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
|
||||
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
ARG POWERSHELL_VERSION=7.5.9
|
||||
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
|
||||
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
|
||||
ENV POWERSHELL_TELEMETRY_OPTOUT=1
|
||||
|
||||
ARG TRIVY_VERSION=0.71.2
|
||||
ARG TRIVY_VERSION=0.72.0
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
ARG ZIZMOR_VERSION=1.24.1
|
||||
@@ -14,7 +16,7 @@ ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
|
||||
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget libicu72 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
|
||||
wget libicu76 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
|
||||
build-essential pkg-config libzstd-dev zlib1g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -87,7 +89,7 @@ ENV HOME='/home/prowler'
|
||||
ENV PATH="${HOME}/.local/bin:${PATH}"
|
||||
#hadolint ignore=DL3013
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir uv==0.11.14
|
||||
pip install --no-cache-dir uv==0.12.0
|
||||
|
||||
RUN uv sync --locked --compile-bytecode && \
|
||||
rm -rf ~/.cache/uv
|
||||
@@ -103,6 +105,9 @@ RUN apt-get purge -y --auto-remove \
|
||||
pkg-config \
|
||||
libzstd-dev \
|
||||
zlib1g-dev \
|
||||
wget \
|
||||
gnupg \
|
||||
apt-transport-https \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER prowler
|
||||
@@ -111,5 +116,15 @@ USER prowler
|
||||
RUN pip uninstall dash-html-components -y && \
|
||||
pip uninstall dash-core-components -y
|
||||
|
||||
USER root
|
||||
|
||||
# pip is build-only; the entrypoint runs the venv directly.
|
||||
RUN rm -rf /usr/local/lib/python3.12/site-packages/pip \
|
||||
/usr/local/lib/python3.12/site-packages/pip-*.dist-info \
|
||||
/home/prowler/.local/lib/python3.12/site-packages/pip \
|
||||
/home/prowler/.local/lib/python3.12/site-packages/pip-*.dist-info \
|
||||
/usr/local/bin/pip /usr/local/bin/pip3 /usr/local/bin/pip3.12 \
|
||||
/home/prowler/.local/bin/pip /home/prowler/.local/bin/pip3 /home/prowler/.local/bin/pip3.12
|
||||
|
||||
USER prowler
|
||||
ENTRYPOINT ["/home/prowler/.venv/bin/prowler"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,137 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
## [1.38.1] (Prowler v5.37.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Entra Conditional Access guest-user checks no longer report false FAILs in M365 scans: microsoft-kiota packages overridden to 1.9.10 so `guestOrExternalUserTypes` (a flags enum Graph serializes as a comma-separated string) deserializes correctly instead of returning an empty list [(#12315)](https://github.com/prowler-cloud/prowler/pull/12315)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- The API container image now builds on Debian 13 (trixie), taking its critical CVE count from 18 to 4 [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Bumped PowerShell, Trivy and uv in the API container image, clearing 14 high-severity CVEs [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Bumped `workos` and `pyopenssl` so the API can move to `cryptography` 48.0.1 [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Removed `gnupg` and `apt-transport-https` from the API container image [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- The API container image no longer ships `git`; removing it also dropped `perl`, `perl-modules`, `libperl` and `liberror-perl`, clearing 12 critical CVEs. Only `perl-base` remains, which Debian marks Essential and cannot be removed [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Removed `pip` from the API container image, clearing two high-severity CVEs in the vendored copies of `setuptools` and `msgpack` [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
- Bumped `pillow` to 12.3.0, `httplib2` to 0.32.0 and `pyasn1` to 0.6.4 to resolve known CVEs [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
|
||||
|
||||
---
|
||||
|
||||
## [1.38.0] (Prowler v5.37.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Attack Paths: four AWS privilege-escalation detection queries from pathfinding.cloud: cross-account role trust (STS-002), wildcard role trust (STS-003), user permissions-boundary removal (IAM-022), and IAM Identity Center permission-set escalation (SSO-001) [(#11460)](https://github.com/prowler-cloud/prowler/pull/11460)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Attack Paths IAM privilege-escalation queries no longer build an all-nodes × all-resource-items cartesian product, fixing runtime errors and timeouts on accounts with many IAM roles, users, or groups [(#12136)](https://github.com/prowler-cloud/prowler/pull/12136)
|
||||
- `task_args` serialization no longer returns HTTP 500 errors when Celery truncates stored task keyword arguments [(#12165)](https://github.com/prowler-cloud/prowler/pull/12165)
|
||||
- Attack Paths predefined queries on migrated graphs are now scoped with the provider label, letting the graph database seed from its label index instead of a global label scan and preventing query timeouts on Neptune [(#12167)](https://github.com/prowler-cloud/prowler/pull/12167)
|
||||
- Authentication with an API key whose owning user was deleted now returns `401` instead of an unhandled `AttributeError`, and user deletion now revokes the user's API keys across all their tenants [(#12210)](https://github.com/prowler-cloud/prowler/pull/12210)
|
||||
- AWS Security Hub integrations now persist successful connection checks during finding delivery so their connection status and last checked timestamp stay current [(#12212)](https://github.com/prowler-cloud/prowler/pull/12212)
|
||||
- SAML users without a `userType` attribute and without an existing role in the SAML tenant now receive a least-privilege `read_only` fallback role; a numeric suffix is used when that name belongs to a role with different permissions [(#12223)](https://github.com/prowler-cloud/prowler/pull/12223)
|
||||
- Social signups create users and authentication records in one database transaction, preventing incomplete accounts when provisioning fails [(#12245)](https://github.com/prowler-cloud/prowler/pull/12245)
|
||||
- Requesting integrations with a sparse fieldset that leaves out `configuration` no longer returns HTTP 500 errors when the tenant has a Jira integration [(#12261)](https://github.com/prowler-cloud/prowler/pull/12261)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Provider deletion and connection checks, scan creation, provider secrets, provider groups, and daily schedules now respect role provider-group visibility [(#12216)](https://github.com/prowler-cloud/prowler/pull/12216)
|
||||
|
||||
---
|
||||
|
||||
## [1.37.0] (Prowler v5.36.0)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- OCI provider secrets no longer require `region`; legacy `region` input is accepted for backwards compatibility but ignored before storing or scanning [(#11741)](https://github.com/prowler-cloud/prowler/pull/11741)
|
||||
- Compliance overview ingest now runs in a single transaction per scan with a configurable `COPY` batch size (`DJANGO_COMPLIANCE_COPY_BATCH_SIZE`, default 2000), reducing write pressure on the database [(#11875)](https://github.com/prowler-cloud/prowler/pull/11875)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Scan findings now recover resources missing from the in-memory cache after resource pre-resolution, preventing valid findings from being skipped [(#12002)](https://github.com/prowler-cloud/prowler/pull/12002)
|
||||
- Tenant-wide integrations that are not attached to any provider, such as Jira, are now visible and manageable by roles with `manage_integrations` and without unlimited visibility [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Output generation now removes the scan's temporary output directory before writing, so a re-run of the task for the same scan (e.g. broker redelivery after a worker is killed mid-run) no longer appends to the previous run's files and duplicates finding rows in the exported CSV and other outputs [(#12097)](https://github.com/prowler-cloud/prowler/pull/12097)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Integration responses no longer disclose providers outside the visibility of the role, including the resources sideloaded through `?include=providers` [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Integration connection checks, Jira issue type lookups and Jira dispatches now resolve the integration through the provider visibility of the role instead of the whole tenant [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Roles without unlimited visibility can no longer attach an integration to providers they cannot see, nor edit or delete an integration bound to them [(#12060)](https://github.com/prowler-cloud/prowler/pull/12060)
|
||||
- Kubernetes kubeconfig validation now rejects legacy `auth-provider.config.cmd-path` command authentication in Prowler Cloud/API [(#12091)](https://github.com/prowler-cloud/prowler/pull/12091)
|
||||
|
||||
---
|
||||
|
||||
## [1.36.0] (Prowler v5.35.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `attack-paths-scan-perform` Celery tasks now use the configurable long-task time limits instead of the six-hour defaults [(#12009)](https://github.com/prowler-cloud/prowler/pull/12009)
|
||||
- Attack Paths scans handle provider deletion races cleanly, detect stale tasks after 16 hours, use backend-specific graph synchronization batches, and report exhausted Neptune write retries with the original database error [(#12019)](https://github.com/prowler-cloud/prowler/pull/12019)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Jira integration credentials only accept bare Atlassian site names containing letters, numbers, and hyphens [(#12012)](https://github.com/prowler-cloud/prowler/pull/12012)
|
||||
- Social account linking requires a verified matching email from both the identity provider and the existing user account without sending account connection notifications [(#12013)](https://github.com/prowler-cloud/prowler/pull/12013)
|
||||
|
||||
---
|
||||
|
||||
## [1.35.0] (Prowler v5.34.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `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
|
||||
|
||||
- Attack Paths graph mutations now retry transient Neptune concurrency and deadline failures, while Neo4j mutations use managed transaction retries [(#11968)](https://github.com/prowler-cloud/prowler/pull/11968)
|
||||
- Attack Paths scans now use bounded child node identifiers for normalized list values in Neo4j and Neptune, preventing Neo4j RANGE index key size failures [(#11969)](https://github.com/prowler-cloud/prowler/pull/11969)
|
||||
- `scan-summary` aggregation now upserts summaries in deterministic conflict-key order, preventing PostgreSQL deadlocks during concurrent reaggregation [(#11971)](https://github.com/prowler-cloud/prowler/pull/11971)
|
||||
|
||||
---
|
||||
|
||||
## [1.34.1] (Prowler v5.33.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Session tokens are rejected after account password updates [(#11914)](https://github.com/prowler-cloud/prowler/pull/11914)
|
||||
- Jira dispatch task results now surface user-facing Jira failure messages [(#11925)](https://github.com/prowler-cloud/prowler/pull/11925)
|
||||
- AWS Attack Paths privilege escalation queries no longer fail on Neo4j with `Aggregation column contains implicit grouping expressions` [(#11939)](https://github.com/prowler-cloud/prowler/pull/11939)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- OpenAI-compatible Lighthouse provider base URLs are restricted before connection checks [(#11940)](https://github.com/prowler-cloud/prowler/pull/11940)
|
||||
- `LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS` environment variable to allow internal hosts as OpenAI-compatible Lighthouse AI base URLs [(#11942)](https://github.com/prowler-cloud/prowler/pull/11942)
|
||||
|
||||
---
|
||||
|
||||
## [1.34.0] (Prowler v5.33.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Compliance PDF reports no longer require provider credentials: findings are enriched from the provider metadata stored in the database, so reports generate even after the provider secret is deleted or its credentials become invalid [(#11845)](https://github.com/prowler-cloud/prowler/pull/11845)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Provider scans now queue behind active provider scans instead of dispatching concurrently, and resource failed-finding counters retry database conflicts with stable row locking [(#11848)](https://github.com/prowler-cloud/prowler/pull/11848)
|
||||
|
||||
---
|
||||
|
||||
## [1.33.1] (Prowler v5.32.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
@@ -12,6 +143,7 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
### 🔐 Security
|
||||
|
||||
- User profile updates now allow users to update their own account while requiring user-management permissions to update other users in the same tenant [(#11792)](https://github.com/prowler-cloud/prowler/pull/11792)
|
||||
- Kubernetes provider credentials now reject kubeconfigs using `exec` authentication in Prowler Cloud, preventing user-supplied commands from running on Cloud workers [(#11753)](https://github.com/prowler-cloud/prowler/pull/11753)
|
||||
|
||||
---
|
||||
|
||||
@@ -32,6 +164,8 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
- Attack Paths: Provider graph cleanup now deletes Neo4j and Neptune relationships in directed batches before deleting nodes [(#11755)](https://github.com/prowler-cloud/prowler/pull/11755)
|
||||
- `scan-perform` no longer reports an error when a provider is deleted during a running scan [(#11696)](https://github.com/prowler-cloud/prowler/pull/11696)
|
||||
|
||||
---
|
||||
|
||||
## [1.32.1] (Prowler v5.31.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
+24
-7
@@ -1,13 +1,13 @@
|
||||
FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build
|
||||
FROM python:3.12.13-slim-trixie@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/api"
|
||||
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
ARG POWERSHELL_VERSION=7.5.9
|
||||
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
|
||||
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
|
||||
ENV POWERSHELL_TELEMETRY_OPTOUT=1
|
||||
|
||||
ARG TRIVY_VERSION=0.71.2
|
||||
ARG TRIVY_VERSION=0.72.0
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
ARG ZIZMOR_VERSION=1.24.1
|
||||
@@ -17,7 +17,7 @@ ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget \
|
||||
git \
|
||||
libicu72 \
|
||||
libicu76 \
|
||||
gcc \
|
||||
g++ \
|
||||
make \
|
||||
@@ -28,7 +28,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libtool \
|
||||
libxslt1-dev \
|
||||
python3-dev \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PowerShell
|
||||
@@ -94,7 +93,7 @@ RUN mkdir -p /tmp/prowler_api_output
|
||||
COPY --chown=prowler:prowler pyproject.toml uv.lock ./
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir uv==0.11.14
|
||||
pip install --no-cache-dir uv==0.12.0
|
||||
|
||||
ENV PATH="/home/prowler/.local/bin:$PATH"
|
||||
|
||||
@@ -102,23 +101,41 @@ ENV PATH="/home/prowler/.local/bin:$PATH"
|
||||
RUN uv sync --locked --no-install-project && \
|
||||
rm -rf ~/.cache/uv
|
||||
|
||||
RUN .venv/bin/python .venv/lib/python3.12/site-packages/prowler/providers/m365/lib/powershell/m365_powershell.py
|
||||
# Invoked as a module so the base image's Python minor version is not baked
|
||||
# into a site-packages path.
|
||||
RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell
|
||||
|
||||
USER root
|
||||
|
||||
# Remove build-only packages from the final image after Python dependencies are installed.
|
||||
# git is only needed by uv sync for the `prowler @ git+...` dependency; purging it drops perl too.
|
||||
# wget stays: the compose healthcheck shells out to it.
|
||||
RUN apt-get purge -y --auto-remove \
|
||||
gcc \
|
||||
g++ \
|
||||
git \
|
||||
make \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libxmlsec1-openssl \
|
||||
libxmlsec1t64 \
|
||||
libxmlsec1t64-openssl \
|
||||
pkg-config \
|
||||
libtool \
|
||||
libxslt1-dev \
|
||||
python3-dev \
|
||||
gnupg \
|
||||
apt-transport-https \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# pip is build-only; the entrypoint runs uv against the prepared venv. uv stays.
|
||||
RUN rm -rf /usr/local/lib/python3.12/site-packages/pip \
|
||||
/usr/local/lib/python3.12/site-packages/pip-*.dist-info \
|
||||
/home/prowler/.local/lib/python3.12/site-packages/pip \
|
||||
/home/prowler/.local/lib/python3.12/site-packages/pip-*.dist-info \
|
||||
/usr/local/bin/pip /usr/local/bin/pip3 /usr/local/bin/pip3.12 \
|
||||
/home/prowler/.local/bin/pip /home/prowler/.local/bin/pip3 /home/prowler/.local/bin/pip3.12
|
||||
|
||||
USER prowler
|
||||
|
||||
COPY --chown=prowler:prowler src/backend/ ./backend/
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Changelog fragments
|
||||
|
||||
Each PR adds one small file here instead of editing `CHANGELOG.md` directly, so concurrent PRs never conflict.
|
||||
|
||||
- Filename: `<slug>.<type>.md`, e.g. `my-new-check.added.md` (slug is free-form: letters, digits, `.`, `_`, `-`)
|
||||
- `<type>` is one of: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security`
|
||||
- Content: one line with the changelog entry text, without the PR link and without a trailing period (the PR link is attached automatically at release time)
|
||||
- A PR adds as many fragment files as entries it needs, freely mixing types (one file per entry); same-type entries just use different slugs
|
||||
|
||||
Fragments are compiled into `CHANGELOG.md` when a release is prepared. Full conventions: `skills/prowler-changelog/SKILL.md`.
|
||||
@@ -0,0 +1 @@
|
||||
Upgrade cryptography to 50.0.0, closing CVE-2026-69247 and CVE-2026-69249
|
||||
+38
-25
@@ -45,7 +45,7 @@ dependencies = [
|
||||
"gunicorn==26.0.0",
|
||||
"uvloop==0.22.1",
|
||||
"lxml==6.1.0",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.32",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.37",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
@@ -63,7 +63,7 @@ dependencies = [
|
||||
"werkzeug (==3.1.7)",
|
||||
"sqlparse (==0.5.5)",
|
||||
"fonttools (==4.62.1)",
|
||||
"uvicorn-worker (==0.4.0)",
|
||||
"uvicorn-worker (==0.4.0)"
|
||||
]
|
||||
description = "Prowler's API (Django/DRF)"
|
||||
license = "Apache-2.0"
|
||||
@@ -71,7 +71,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.33.2"
|
||||
version = "1.38.2"
|
||||
|
||||
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
|
||||
# target-version tracks this project's lowest supported Python.
|
||||
@@ -92,6 +92,8 @@ extend-select = [
|
||||
|
||||
[tool.uv]
|
||||
# Transitive pins matching master to avoid silent drift; bump deliberately.
|
||||
# workos and pyopenssl run ahead of master: the versions master pins cap cryptography
|
||||
# below 48, so both were bumped to versions that allow it (PROWLER-2310).
|
||||
constraint-dependencies = [
|
||||
"about-time==4.2.1",
|
||||
"adal==1.2.7",
|
||||
@@ -128,7 +130,7 @@ constraint-dependencies = [
|
||||
"alibabacloud-sls20201230==5.9.0",
|
||||
"alibabacloud-sts20150401==1.1.6",
|
||||
"alibabacloud-tea==0.4.3",
|
||||
"alibabacloud-tea-openapi==0.4.4",
|
||||
"alibabacloud-tea-openapi==0.4.5",
|
||||
"alibabacloud-tea-util==0.3.14",
|
||||
"alibabacloud-tea-xml==0.0.3",
|
||||
"alibabacloud-vpc20160428==6.13.0",
|
||||
@@ -210,9 +212,9 @@ constraint-dependencies = [
|
||||
"coverage==7.5.4",
|
||||
"cron-descriptor==1.4.5",
|
||||
"crowdstrike-falconpy==1.6.0",
|
||||
"cryptography==46.0.7",
|
||||
"cryptography==50.0.0",
|
||||
"cycler==0.12.1",
|
||||
"darabonba-core==1.0.5",
|
||||
"darabonba-core==1.0.8",
|
||||
"dash==3.1.1",
|
||||
"dash-bootstrap-components==2.0.3",
|
||||
"debugpy==1.8.20",
|
||||
@@ -277,7 +279,7 @@ constraint-dependencies = [
|
||||
"h2==4.3.0",
|
||||
"hpack==4.1.0",
|
||||
"httpcore==1.0.9",
|
||||
"httplib2==0.31.2",
|
||||
"httplib2==0.32.0",
|
||||
"httpx==0.28.1",
|
||||
"humanfriendly==10.0",
|
||||
"hyperframe==6.1.0",
|
||||
@@ -314,13 +316,13 @@ constraint-dependencies = [
|
||||
"matplotlib==3.10.8",
|
||||
"mccabe==0.7.0",
|
||||
"mdurl==0.1.2",
|
||||
"microsoft-kiota-abstractions==1.9.9",
|
||||
"microsoft-kiota-authentication-azure==1.9.9",
|
||||
"microsoft-kiota-http==1.9.9",
|
||||
"microsoft-kiota-serialization-form==1.9.9",
|
||||
"microsoft-kiota-serialization-json==1.9.9",
|
||||
"microsoft-kiota-serialization-multipart==1.9.9",
|
||||
"microsoft-kiota-serialization-text==1.9.9",
|
||||
"microsoft-kiota-abstractions==1.9.10",
|
||||
"microsoft-kiota-authentication-azure==1.9.10",
|
||||
"microsoft-kiota-http==1.9.10",
|
||||
"microsoft-kiota-serialization-form==1.9.10",
|
||||
"microsoft-kiota-serialization-json==1.9.10",
|
||||
"microsoft-kiota-serialization-multipart==1.9.10",
|
||||
"microsoft-kiota-serialization-text==1.9.10",
|
||||
"microsoft-security-utilities-secret-masker==1.0.0b4",
|
||||
"msal==1.35.0b1",
|
||||
"msal-extensions==1.2.0",
|
||||
@@ -337,7 +339,7 @@ constraint-dependencies = [
|
||||
"nltk==3.9.4",
|
||||
"numpy==2.2.6",
|
||||
"oauthlib==3.3.1",
|
||||
"oci==2.169.0",
|
||||
"oci==2.183.0",
|
||||
"openai==1.109.1",
|
||||
"openstacksdk==4.2.0",
|
||||
"opentelemetry-api==1.39.1",
|
||||
@@ -349,7 +351,7 @@ constraint-dependencies = [
|
||||
"pagerduty==6.1.0",
|
||||
"pandas==2.2.3",
|
||||
"pbr==7.0.3",
|
||||
"pillow==12.2.0",
|
||||
"pillow==12.3.0",
|
||||
"pkginfo==1.12.1.2",
|
||||
"platformdirs==4.5.1",
|
||||
"plotly==6.5.2",
|
||||
@@ -365,8 +367,8 @@ constraint-dependencies = [
|
||||
"psycopg2-binary==2.9.9",
|
||||
"py-deviceid==0.1.1",
|
||||
"py-iam-expand==0.3.0",
|
||||
"py-ocsf-models==0.8.1",
|
||||
"pyasn1==0.6.3",
|
||||
"py-ocsf-models==0.10.0",
|
||||
"pyasn1==0.6.4",
|
||||
"pyasn1-modules==0.4.2",
|
||||
"pycodestyle==2.14.0",
|
||||
"pycparser==3.0",
|
||||
@@ -378,7 +380,7 @@ constraint-dependencies = [
|
||||
"pylint==3.2.5",
|
||||
"pymsalruntime==0.18.1",
|
||||
"pynacl==1.6.2",
|
||||
"pyopenssl==26.0.0",
|
||||
"pyopenssl==26.2.0",
|
||||
"pyparsing==3.3.2",
|
||||
"pyreadline3==3.5.4",
|
||||
"pysocks==1.7.1",
|
||||
@@ -447,7 +449,7 @@ constraint-dependencies = [
|
||||
"wcwidth==0.5.3",
|
||||
"websocket-client==1.9.0",
|
||||
"werkzeug==3.1.7",
|
||||
"workos==6.0.8",
|
||||
"workos==8.3.0",
|
||||
"wrapt==1.17.3",
|
||||
"xlsxwriter==3.2.9",
|
||||
"xmlsec==1.3.17",
|
||||
@@ -466,10 +468,13 @@ constraint-dependencies = [
|
||||
# 0.138.1 requires azure-mgmt-containerservice>=41.0.0. Attack Paths does not
|
||||
# ingest Azure today, so override the Cartography dependency to the Prowler pin.
|
||||
#
|
||||
# prowler@master hard-pins microsoft-kiota-abstractions==1.9.2 in [project.dependencies].
|
||||
# The microsoft-kiota-http security bump to 1.9.9 (GHSA-7j59-v9qr-6fq9) requires
|
||||
# microsoft-kiota-abstractions>=1.9.9, which a constraint cannot satisfy against the
|
||||
# SDK's hard pin; override it to the patched, kiota-aligned version.
|
||||
# prowler@master hard-pins the microsoft-kiota packages in [project.dependencies].
|
||||
# microsoft-kiota-serialization-json 1.9.10 fixes get_collection_of_enum_values
|
||||
# returning [] for flags enums serialized as CSV strings (microsoft/kiota-python#515),
|
||||
# which broke the Entra Conditional Access guest-user checks; the kiota packages
|
||||
# release in lockstep and 1.9.10 requires microsoft-kiota-abstractions>=1.9.10, which
|
||||
# a constraint cannot satisfy against the SDK's hard pins, so override the whole set
|
||||
# to 1.9.10 until the SDK bump propagates to the pinned master rev.
|
||||
#
|
||||
# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies].
|
||||
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0
|
||||
@@ -480,8 +485,16 @@ constraint-dependencies = [
|
||||
# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive.
|
||||
override-dependencies = [
|
||||
"okta==3.4.2",
|
||||
# alibabacloud-tea-openapi 0.4.5 caps cryptography below 49 and is the latest release.
|
||||
"cryptography==50.0.0",
|
||||
"azure-mgmt-containerservice==34.1.0",
|
||||
"microsoft-kiota-abstractions==1.9.9",
|
||||
"microsoft-kiota-abstractions==1.9.10",
|
||||
"microsoft-kiota-authentication-azure==1.9.10",
|
||||
"microsoft-kiota-http==1.9.10",
|
||||
"microsoft-kiota-serialization-form==1.9.10",
|
||||
"microsoft-kiota-serialization-json==1.9.10",
|
||||
"microsoft-kiota-serialization-multipart==1.9.10",
|
||||
"microsoft-kiota-serialization-text==1.9.10",
|
||||
"dulwich==1.2.5",
|
||||
"pyjwt[crypto]==2.13.0"
|
||||
]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from allauth.account.models import EmailAddress
|
||||
from allauth.core.exceptions import ImmediateHttpResponse
|
||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||
from api.db_router import MainRouter
|
||||
from api.db_router import MainRouter, write_db_alias
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import (
|
||||
Membership,
|
||||
@@ -11,6 +13,7 @@ from api.models import (
|
||||
)
|
||||
from api.utils import accept_invitation_for_user
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponseForbidden
|
||||
|
||||
|
||||
class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
@@ -38,8 +41,13 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
return None
|
||||
|
||||
def pre_social_login(self, request, sociallogin):
|
||||
# Link existing accounts with the same email address
|
||||
email = sociallogin.account.extra_data.get("email")
|
||||
# The provider account is already bound, so no email-based linking is needed.
|
||||
if sociallogin.account.pk:
|
||||
return
|
||||
|
||||
# Prefer the normalized email populated by allauth. GitHub can return the
|
||||
# primary email separately from the profile stored in extra_data.
|
||||
email = sociallogin.user.email or sociallogin.account.extra_data.get("email")
|
||||
if sociallogin.provider.id == "saml":
|
||||
# For SAML, the asserted NameID email cannot be trusted on its own:
|
||||
# any tenant can claim any email domain in its SAML configuration. To
|
||||
@@ -80,6 +88,17 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
if email:
|
||||
existing_user = self.get_user_by_email(email)
|
||||
if existing_user:
|
||||
email_is_verified = EmailAddress.objects.filter(
|
||||
user=existing_user,
|
||||
email__iexact=email,
|
||||
verified=True,
|
||||
).exists()
|
||||
provider_verified_email = any(
|
||||
address.verified and address.email.casefold() == email.casefold()
|
||||
for address in sociallogin.email_addresses
|
||||
)
|
||||
if not email_is_verified or not provider_verified_email:
|
||||
raise ImmediateHttpResponse(HttpResponseForbidden())
|
||||
sociallogin.connect(request, existing_user)
|
||||
|
||||
def save_user(self, request, sociallogin, form=None):
|
||||
@@ -88,7 +107,10 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
and is about to be saved to the DB for the first time.
|
||||
"""
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
user = super().save_user(request, sociallogin, form)
|
||||
# Allauth saves the user without an explicit alias. Route that save
|
||||
# through admin so every signup record shares this transaction.
|
||||
with write_db_alias(MainRouter.admin_db):
|
||||
user = super().save_user(request, sociallogin, form)
|
||||
provider = sociallogin.provider.id
|
||||
extra = sociallogin.account.extra_data
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from django.conf import (
|
||||
MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250)
|
||||
|
||||
TEMP_DB_PREFIX = "db-tmp-scan-"
|
||||
DATABASE_NOT_FOUND_CODE = "Neo.ClientError.Database.DatabaseNotFound"
|
||||
|
||||
|
||||
# Exceptions
|
||||
@@ -44,6 +45,10 @@ class GraphDatabaseQueryException(Exception):
|
||||
return self.message
|
||||
|
||||
|
||||
class NeptuneWriteRetryExhaustedException(GraphDatabaseQueryException):
|
||||
pass
|
||||
|
||||
|
||||
class WriteQueryNotAllowedException(GraphDatabaseQueryException):
|
||||
pass
|
||||
|
||||
|
||||
@@ -418,7 +418,8 @@ AWS_APPRUNNER_PRIVESC_UPDATE_SERVICE = AttackPathsQueryDefinition(
|
||||
// Find existing App Runner services with roles attached (potential targets)
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'tasks.apprunner.amazonaws.com'}})
|
||||
|
||||
WITH principal_paths + collect(DISTINCT path_target) AS paths
|
||||
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
|
||||
WITH principal_paths + target_paths AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
@@ -523,7 +524,8 @@ AWS_BEDROCK_PRIVESC_INVOKE_CODE_INTERPRETER = AttackPathsQueryDefinition(
|
||||
// Find roles that trust the Bedrock AgentCore service (already attached to existing code interpreters)
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'bedrock-agentcore.amazonaws.com'}})
|
||||
|
||||
WITH principal_paths + collect(DISTINCT path_target) AS paths
|
||||
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
|
||||
WITH principal_paths + target_paths AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
@@ -607,7 +609,8 @@ AWS_CLOUDFORMATION_PRIVESC_UPDATE_STACK = AttackPathsQueryDefinition(
|
||||
// Find roles that trust CloudFormation service (already attached to existing stacks)
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}})
|
||||
|
||||
WITH principal_paths + collect(DISTINCT path_target) AS paths
|
||||
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
|
||||
WITH principal_paths + target_paths AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
@@ -753,7 +756,8 @@ AWS_CLOUDFORMATION_PRIVESC_CHANGESET = AttackPathsQueryDefinition(
|
||||
// Find roles that trust CloudFormation service (already attached to existing stacks)
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'cloudformation.amazonaws.com'}})
|
||||
|
||||
WITH principal_paths + collect(DISTINCT path_target) AS paths
|
||||
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
|
||||
WITH principal_paths + target_paths AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
@@ -844,7 +848,8 @@ AWS_CODEBUILD_PRIVESC_START_BUILD = AttackPathsQueryDefinition(
|
||||
// Find roles that trust CodeBuild service (already attached to existing projects)
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}})
|
||||
|
||||
WITH principal_paths + collect(DISTINCT path_target) AS paths
|
||||
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
|
||||
WITH principal_paths + target_paths AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
@@ -880,7 +885,8 @@ AWS_CODEBUILD_PRIVESC_START_BUILD_BATCH = AttackPathsQueryDefinition(
|
||||
// Find roles that trust CodeBuild service (already attached to existing projects)
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'codebuild.amazonaws.com'}})
|
||||
|
||||
WITH principal_paths + collect(DISTINCT path_target) AS paths
|
||||
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
|
||||
WITH principal_paths + target_paths AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
@@ -1096,7 +1102,8 @@ AWS_EC2_PRIVESC_MODIFY_INSTANCE_ATTRIBUTE = AttackPathsQueryDefinition(
|
||||
// Find EC2 instances with instance profiles (potential targets)
|
||||
MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
|
||||
|
||||
WITH principal_paths + collect(DISTINCT path_target) AS paths
|
||||
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
|
||||
WITH principal_paths + target_paths AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
@@ -1187,7 +1194,8 @@ AWS_EC2_PRIVESC_LAUNCH_TEMPLATE = AttackPathsQueryDefinition(
|
||||
// Find launch templates in the account (potential targets)
|
||||
MATCH path_target = (aws)--(template:LaunchTemplate)
|
||||
|
||||
WITH principal_paths + collect(DISTINCT path_target) AS paths
|
||||
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
|
||||
WITH principal_paths + target_paths AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
@@ -1223,7 +1231,8 @@ AWS_EC2INSTANCECONNECT_PRIVESC_SEND_SSH_PUBLIC_KEY = AttackPathsQueryDefinition(
|
||||
// Find EC2 instances with attached roles (targets for credential theft via IMDS)
|
||||
MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
|
||||
|
||||
WITH principal_paths + collect(DISTINCT path_target) AS paths
|
||||
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
|
||||
WITH principal_paths + target_paths AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
@@ -1539,7 +1548,8 @@ AWS_ECS_PRIVESC_EXECUTE_COMMAND = AttackPathsQueryDefinition(
|
||||
// Target: roles already attached to running tasks (trust ECS tasks service)
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'ecs-tasks.amazonaws.com'}})
|
||||
|
||||
WITH principal_paths + collect(DISTINCT path_target) AS paths
|
||||
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
|
||||
WITH principal_paths + target_paths AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
@@ -1622,7 +1632,8 @@ AWS_GLUE_PRIVESC_UPDATE_DEV_ENDPOINT = AttackPathsQueryDefinition(
|
||||
// Find roles that trust Glue service (already attached to existing dev endpoints)
|
||||
MATCH path_target = (aws)--(target_role:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(:AWSPrincipal {{arn: 'glue.amazonaws.com'}})
|
||||
|
||||
WITH principal_paths + collect(DISTINCT path_target) AS paths
|
||||
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
|
||||
WITH principal_paths + target_paths AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
@@ -1916,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
|
||||
@@ -1964,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
|
||||
@@ -2008,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
|
||||
@@ -2086,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
|
||||
@@ -2322,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
|
||||
@@ -2362,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
|
||||
@@ -2451,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
|
||||
@@ -2585,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
|
||||
@@ -2636,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
|
||||
@@ -2687,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
|
||||
@@ -2739,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
|
||||
@@ -3337,7 +3443,8 @@ AWS_SSM_PRIVESC_START_SESSION = AttackPathsQueryDefinition(
|
||||
// Find EC2 instances with attached roles (targets for credential theft via IMDS)
|
||||
MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
|
||||
|
||||
WITH principal_paths + collect(DISTINCT path_target) AS paths
|
||||
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
|
||||
WITH principal_paths + target_paths AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
@@ -3373,7 +3480,8 @@ AWS_SSM_PRIVESC_SEND_COMMAND = AttackPathsQueryDefinition(
|
||||
// Find EC2 instances with attached roles (targets for credential theft via IMDS)
|
||||
MATCH path_target = (aws)--(ec2:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(target_role:AWSRole)
|
||||
|
||||
WITH principal_paths + collect(DISTINCT path_target) AS paths
|
||||
WITH principal_paths, collect(DISTINCT path_target) AS target_paths
|
||||
WITH principal_paths + target_paths AS paths
|
||||
UNWIND paths AS p
|
||||
UNWIND nodes(p) AS n
|
||||
|
||||
@@ -3428,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] = [
|
||||
@@ -3509,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,
|
||||
]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
@@ -8,18 +10,44 @@ 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` that retries `neo4j.exceptions.ServiceUnavailable` errors.
|
||||
"""
|
||||
"""Wrapper around ``neo4j.Session`` with a refreshable retry policy."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session_factory: Callable[[], neo4j.Session],
|
||||
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:
|
||||
@@ -50,30 +78,75 @@ 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:
|
||||
method = getattr(self._session, method_name)
|
||||
return method(*args, **kwargs)
|
||||
|
||||
except (
|
||||
BrokenPipeError,
|
||||
ConnectionResetError,
|
||||
neo4j.exceptions.ServiceUnavailable,
|
||||
) as exc: # pragma: no cover - depends on infra
|
||||
except Exception as exc:
|
||||
if not self._should_retry(exc):
|
||||
raise
|
||||
|
||||
last_exc = exc
|
||||
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
|
||||
|
||||
logger.warning(
|
||||
f"Neo4j session {method_name} failed with {type(exc).__name__} ({attempt}/{self._max_retries} attempts). Retrying..."
|
||||
)
|
||||
delay = self._retry_delay(attempt)
|
||||
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)
|
||||
|
||||
raise last_exc if last_exc else RuntimeError("Unexpected retry loop exit")
|
||||
|
||||
def _should_retry(self, exc: Exception) -> bool:
|
||||
if isinstance(
|
||||
exc,
|
||||
(
|
||||
BrokenPipeError,
|
||||
ConnectionResetError,
|
||||
neo4j.exceptions.ServiceUnavailable,
|
||||
),
|
||||
):
|
||||
return True
|
||||
return self._retry_if(exc) if self._retry_if else False
|
||||
|
||||
def _retry_delay(self, attempt: int) -> float:
|
||||
max_delay = self._initial_retry_delay_seconds * (2**attempt)
|
||||
return random.uniform(max_delay / 2, max_delay) if max_delay else 0
|
||||
|
||||
def _refresh_session(self) -> None:
|
||||
if self._session is not None:
|
||||
try:
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -42,6 +42,10 @@ def delete_batches(
|
||||
batch_size: int,
|
||||
drop_t0: float,
|
||||
) -> tuple[int, int]:
|
||||
def delete_batch(tx: Any) -> int:
|
||||
record = tx.run(query, {"batch_size": batch_size}).single()
|
||||
return (record[count_key] if record else 0) or 0
|
||||
|
||||
deleted_total = initial_total
|
||||
batches = 0
|
||||
while True:
|
||||
@@ -56,8 +60,7 @@ def delete_batches(
|
||||
deleted_total,
|
||||
time.perf_counter() - drop_t0,
|
||||
)
|
||||
record = session.run(query, {"batch_size": batch_size}).single()
|
||||
deleted = (record[count_key] if record else 0) or 0
|
||||
deleted = session.execute_write(delete_batch)
|
||||
if deleted == 0:
|
||||
return deleted_total, batches
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -355,7 +357,7 @@ class Neo4jSink(SinkDatabase):
|
||||
f"ON (n.`{PROVIDER_ELEMENT_ID_PROPERTY}`)"
|
||||
)
|
||||
with self.get_session(database) as session:
|
||||
session.run(query).consume()
|
||||
session.execute_write(lambda tx: tx.run(query).consume())
|
||||
|
||||
def write_nodes(
|
||||
self,
|
||||
@@ -377,7 +379,7 @@ class Neo4jSink(SinkDatabase):
|
||||
SET n += row.props
|
||||
"""
|
||||
with self.get_session(database) as session:
|
||||
session.run(query, {"rows": rows}).consume()
|
||||
session.execute_write(lambda tx: tx.run(query, {"rows": rows}).consume())
|
||||
|
||||
def write_relationships(
|
||||
self,
|
||||
@@ -403,7 +405,7 @@ class Neo4jSink(SinkDatabase):
|
||||
SET r += row.props
|
||||
"""
|
||||
with self.get_session(database) as session:
|
||||
session.run(query, {"rows": rows}).consume()
|
||||
session.execute_write(lambda tx: tx.run(query, {"rows": rows}).consume())
|
||||
|
||||
# For compatibility with test harnesses that patch the concrete driver
|
||||
def get_driver(self) -> neo4j.Driver:
|
||||
|
||||
@@ -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,
|
||||
@@ -59,20 +59,34 @@ CONNECTION_TIMEOUT = env.int("NEPTUNE_CONNECTION_TIMEOUT", default=10)
|
||||
# Roll connections hourly so SigV4 rotations and cert refreshes don't strand long-lived pool entries
|
||||
MAX_CONNECTION_LIFETIME = env.int("NEPTUNE_MAX_CONNECTION_LIFETIME", default=3600)
|
||||
MAX_CONNECTION_POOL_SIZE = env.int("NEPTUNE_MAX_CONNECTION_POOL_SIZE", default=50)
|
||||
NEPTUNE_WRITE_RETRY_DELAY_SECONDS = 2
|
||||
|
||||
READ_EXCEPTION_CODES = [
|
||||
"Neo.ClientError.Statement.AccessMode",
|
||||
"Neo.ClientError.Procedure.ProcedureNotFound",
|
||||
]
|
||||
CLIENT_STATEMENT_EXCEPTION_PREFIX = "Neo.ClientError.Statement."
|
||||
RETRYABLE_WRITE_ERROR_FRAGMENTS = (
|
||||
"Operation failed due to conflicting concurrent operations",
|
||||
"Operation terminated (deadline exceeded)",
|
||||
)
|
||||
|
||||
# Refresh 60s before the 5-minute SigV4 window closes
|
||||
SIGV4_TOKEN_LIFETIME_MINUTES = 4
|
||||
|
||||
|
||||
def _is_retryable_write_error(exc: Exception) -> bool:
|
||||
if not isinstance(exc, neo4j.exceptions.Neo4jError):
|
||||
return False
|
||||
message = exc.message or ""
|
||||
return any(fragment in message for fragment in RETRYABLE_WRITE_ERROR_FRAGMENTS)
|
||||
|
||||
|
||||
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
|
||||
@@ -194,6 +208,7 @@ class NeptuneSink(SinkDatabase):
|
||||
from api.attack_paths.database import (
|
||||
ClientStatementException,
|
||||
GraphDatabaseQueryException,
|
||||
NeptuneWriteRetryExhaustedException,
|
||||
WriteQueryNotAllowedException,
|
||||
)
|
||||
|
||||
@@ -205,14 +220,27 @@ class NeptuneSink(SinkDatabase):
|
||||
|
||||
session_wrapper: RetryableSession | None = None
|
||||
try:
|
||||
is_write_session = default_access_mode != neo4j.READ_ACCESS
|
||||
session_wrapper = RetryableSession(
|
||||
session_factory=lambda: driver.session(
|
||||
default_access_mode=default_access_mode
|
||||
),
|
||||
max_retries=SERVICE_UNAVAILABLE_MAX_RETRIES,
|
||||
retry_if=_is_retryable_write_error if is_write_session else None,
|
||||
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
|
||||
@@ -274,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,
|
||||
)
|
||||
@@ -313,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
|
||||
@@ -332,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,
|
||||
)
|
||||
|
||||
@@ -405,7 +433,7 @@ class NeptuneSink(SinkDatabase):
|
||||
SET n.`{PROVIDER_ELEMENT_ID_PROPERTY}` = row.provider_element_id
|
||||
"""
|
||||
with self.get_session() as session:
|
||||
session.run(query, {"rows": rows}).consume()
|
||||
session.execute_write(lambda tx: tx.run(query, {"rows": rows}).consume())
|
||||
|
||||
def write_relationships(
|
||||
self,
|
||||
@@ -429,7 +457,7 @@ class NeptuneSink(SinkDatabase):
|
||||
SET r += row.props
|
||||
"""
|
||||
with self.get_session() as session:
|
||||
session.run(query, {"rows": rows}).consume()
|
||||
session.execute_write(lambda tx: tx.run(query, {"rows": rows}).consume())
|
||||
|
||||
# Test helpers
|
||||
|
||||
|
||||
@@ -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.33.2
|
||||
version: 1.38.2
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
@@ -6629,8 +6629,10 @@ paths:
|
||||
/api/v1/integrations:
|
||||
get:
|
||||
operationId: api_v1_integrations_list
|
||||
description: Retrieve a list of all configured integrations with options for
|
||||
filtering by various criteria.
|
||||
description: |-
|
||||
Retrieve a list of all configured integrations with options for filtering by various criteria.
|
||||
|
||||
Integrations attached to one or more providers are only returned when the role can access at least one of those providers, and each integration lists only the providers visible to the role. Integrations not attached to any provider, such as Jira, are tenant-wide and are returned for every role.
|
||||
summary: List all integrations
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -6781,7 +6783,8 @@ paths:
|
||||
post:
|
||||
operationId: api_v1_integrations_create
|
||||
description: Register a new integration with the system, providing necessary
|
||||
configuration details.
|
||||
configuration details. Only providers visible to the role can be attached
|
||||
to the integration.
|
||||
summary: Create a new integration
|
||||
tags:
|
||||
- Integration
|
||||
@@ -6810,7 +6813,7 @@ paths:
|
||||
post:
|
||||
operationId: api_v1_integrations_jira_dispatches_create
|
||||
description: |-
|
||||
Send a set of filtered findings to the given integration. At least one finding filter must be provided.
|
||||
Send a set of filtered findings to the given integration. At least one finding filter must be provided. Jira integrations are tenant-wide and do not require unlimited visibility, while the findings sent are limited to the providers the role can access.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
@@ -6883,7 +6886,8 @@ paths:
|
||||
get:
|
||||
operationId: api_v1_integrations_jira_issue_types_retrieve
|
||||
description: Fetch the available issue types from Jira for a given project key
|
||||
and update the integration configuration.
|
||||
and update the integration configuration. Jira integrations are tenant-wide
|
||||
and do not require unlimited visibility.
|
||||
summary: Get available issue types for a Jira project
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -6924,7 +6928,8 @@ paths:
|
||||
get:
|
||||
operationId: api_v1_integrations_retrieve
|
||||
description: Fetch detailed information about a specific integration by its
|
||||
ID.
|
||||
ID. Integrations outside the provider visibility of the role are reported
|
||||
the same way as one that does not exist.
|
||||
summary: Retrieve integration details
|
||||
parameters:
|
||||
- in: query
|
||||
@@ -6978,7 +6983,8 @@ paths:
|
||||
patch:
|
||||
operationId: api_v1_integrations_partial_update
|
||||
description: Modify certain fields of an existing integration without affecting
|
||||
other settings.
|
||||
other settings. Integrations attached to providers outside the visibility
|
||||
of the role cannot be modified by it.
|
||||
summary: Partially update an integration
|
||||
parameters:
|
||||
- in: path
|
||||
@@ -7013,7 +7019,8 @@ paths:
|
||||
description: ''
|
||||
delete:
|
||||
operationId: api_v1_integrations_destroy
|
||||
description: Remove an integration from the system by its ID.
|
||||
description: Remove an integration from the system by its ID. Integrations attached
|
||||
to providers outside the visibility of the role cannot be deleted by it.
|
||||
summary: Delete an integration
|
||||
parameters:
|
||||
- in: path
|
||||
@@ -7033,7 +7040,9 @@ paths:
|
||||
/api/v1/integrations/{id}/connection:
|
||||
post:
|
||||
operationId: api_v1_integrations_connection_create
|
||||
description: Try to verify integration connection
|
||||
description: Try to verify integration connection. Integrations outside the
|
||||
provider visibility of the role are reported the same way as one that does
|
||||
not exist.
|
||||
summary: Check integration connection
|
||||
parameters:
|
||||
- in: path
|
||||
|
||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
||||
class SSEChannelManager(DefaultChannelManager):
|
||||
"""Connect `django-eventstream` to the platform's SSE viewsets."""
|
||||
|
||||
def get_channels_for_request(self, request: Request, view_kwargs: dict) -> set[str]: # noqa: vulture
|
||||
def get_channels_for_request(self, request: Request, view_kwargs: dict) -> set[str]:
|
||||
"""Return the request's channels scoped to the active JWT tenant.
|
||||
|
||||
Args:
|
||||
@@ -30,6 +30,7 @@ class SSEChannelManager(DefaultChannelManager):
|
||||
The subset of `request.sse_channels` whose embedded tenant
|
||||
matches the active request tenant.
|
||||
"""
|
||||
_ = view_kwargs
|
||||
try:
|
||||
request_tenant_id = UUID(str(getattr(request, "tenant_id", None)))
|
||||
except (TypeError, ValueError):
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import json
|
||||
import time
|
||||
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
|
||||
from rest_framework_simplejwt.token_blacklist.models import (
|
||||
BlacklistedToken,
|
||||
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
|
||||
@@ -103,6 +124,120 @@ def test_refresh_token(create_test_user, tenants_fixture):
|
||||
assert new_refresh_response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_password_change_invalidates_existing_tokens(password_change_user):
|
||||
client = APIClient()
|
||||
new_password = "ChangedSecret123@"
|
||||
|
||||
access_token, refresh_token = get_api_tokens(
|
||||
client, password_change_user.email, PASSWORD_CHANGE_PASSWORD
|
||||
)
|
||||
auth_headers = get_authorization_header(access_token)
|
||||
outstanding_token_ids = list(
|
||||
OutstandingToken.objects.filter(user=password_change_user).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
assert outstanding_token_ids
|
||||
assert not BlacklistedToken.objects.filter(
|
||||
token_id__in=outstanding_token_ids
|
||||
).exists()
|
||||
|
||||
password_change_payload = {
|
||||
"data": {
|
||||
"type": "users",
|
||||
"id": str(password_change_user.id),
|
||||
"attributes": {"password": new_password},
|
||||
}
|
||||
}
|
||||
password_change_response = client.patch(
|
||||
reverse("user-detail", kwargs={"pk": password_change_user.id}),
|
||||
data=json.dumps(password_change_payload),
|
||||
headers=auth_headers,
|
||||
content_type="application/vnd.api+json",
|
||||
)
|
||||
assert password_change_response.status_code == 200, password_change_response.json()
|
||||
assert BlacklistedToken.objects.filter(
|
||||
token_id__in=outstanding_token_ids
|
||||
).count() == len(outstanding_token_ids)
|
||||
|
||||
old_access_response = client.get(reverse("user-me"), headers=auth_headers)
|
||||
assert old_access_response.status_code == 401
|
||||
|
||||
old_refresh_response = client.post(
|
||||
reverse("token-refresh"),
|
||||
data={
|
||||
"data": {
|
||||
"type": "tokens-refresh",
|
||||
"attributes": {"refresh": refresh_token},
|
||||
}
|
||||
},
|
||||
format="vnd.api+json",
|
||||
)
|
||||
assert old_refresh_response.status_code == 400
|
||||
|
||||
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)
|
||||
)
|
||||
assert new_access_response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_password_change_invalidates_rotated_refresh_token(
|
||||
password_change_user,
|
||||
):
|
||||
client = APIClient()
|
||||
new_password = "ChangedSecret123@"
|
||||
|
||||
access_token, refresh_token = get_api_tokens(
|
||||
client, password_change_user.email, PASSWORD_CHANGE_PASSWORD
|
||||
)
|
||||
rotated_refresh_response = client.post(
|
||||
reverse("token-refresh"),
|
||||
data={
|
||||
"data": {
|
||||
"type": "tokens-refresh",
|
||||
"attributes": {"refresh": refresh_token},
|
||||
}
|
||||
},
|
||||
format="vnd.api+json",
|
||||
)
|
||||
assert rotated_refresh_response.status_code == 200
|
||||
rotated_refresh_token = rotated_refresh_response.json()["data"]["attributes"][
|
||||
"refresh"
|
||||
]
|
||||
|
||||
password_change_payload = {
|
||||
"data": {
|
||||
"type": "users",
|
||||
"id": str(password_change_user.id),
|
||||
"attributes": {"password": new_password},
|
||||
}
|
||||
}
|
||||
password_change_response = client.patch(
|
||||
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",
|
||||
)
|
||||
assert password_change_response.status_code == 200, password_change_response.json()
|
||||
|
||||
old_rotated_refresh_response = client.post(
|
||||
reverse("token-refresh"),
|
||||
data={
|
||||
"data": {
|
||||
"type": "tokens-refresh",
|
||||
"attributes": {"refresh": rotated_refresh_token},
|
||||
}
|
||||
},
|
||||
format="vnd.api+json",
|
||||
)
|
||||
assert old_rotated_refresh_response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_user_me_when_inviting_users(create_test_user, tenants_fixture, roles_fixture):
|
||||
client = APIClient()
|
||||
@@ -187,8 +322,9 @@ def test_user_me_when_inviting_users(create_test_user, tenants_fixture, roles_fi
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestTokenSwitchTenant:
|
||||
def test_switch_tenant_with_valid_token(self, tenants_fixture, providers_fixture):
|
||||
def test_switch_tenant_with_valid_token(self, tenants_fixture, aws_provider):
|
||||
client = APIClient()
|
||||
assert aws_provider
|
||||
|
||||
test_user = "test_email@prowler.com"
|
||||
test_password = "Test_password1@"
|
||||
@@ -492,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()
|
||||
@@ -684,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:
|
||||
@@ -1396,13 +1647,14 @@ 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()
|
||||
assert aws_provider
|
||||
|
||||
user1 = User.objects.create_user(
|
||||
name="tenant1_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("//")
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user