mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
Merge remote-tracking branch 'origin/master' into feature/eslint-typescript-flat
# Conflicts: # ui/components/compliance/compliance-accordion/client-accordion-wrapper.tsx
This commit is contained in:
@@ -14,6 +14,11 @@
|
||||
"repo": "github/gh-aw/actions/setup",
|
||||
"version": "v0.43.23",
|
||||
"sha": "9382be3ca9ac18917e111a99d4e6bbff58d0dccc"
|
||||
},
|
||||
"step-security/harden-runner@v2.20.0": {
|
||||
"repo": "step-security/harden-runner",
|
||||
"version": "v2.20.0",
|
||||
"sha": "bf7454d06d71f1098171f2acdf0cd4708d7b5920"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ Please add a detailed description of how to review this PR.
|
||||
|
||||
- [ ] This feature/issue is listed in the [open issues](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or roadmap.prowler.com
|
||||
- [ ] Is it assigned to me, if not, request it via the [open issues](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or [Prowler Community Slack](https://goto.prowler.com/slack)
|
||||
- [ ] I have reviewed the [open pull requests](https://github.com/prowler-cloud/prowler/pulls?q=sort%3Aupdated-desc+is%3Apr+is%3Aopen) and confirmed there is no existing PR that implements the same outcome
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -175,7 +175,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner (Block outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: 'Docs: Check Provider Cards Snippet'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'v5.*'
|
||||
paths:
|
||||
- 'docs/user-guide/providers/**/getting-started-*.mdx'
|
||||
- 'docs/scripts/generate_provider_cards.py'
|
||||
- 'docs/snippets/provider-cards.mdx'
|
||||
- 'api/src/backend/api/models.py'
|
||||
- '.github/workflows/docs-check-provider-cards.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
check-provider-cards:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
github.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify provider cards snippet is up to date
|
||||
run: |
|
||||
if ! python3 docs/scripts/generate_provider_cards.py; then
|
||||
echo "::error::docs/snippets/provider-cards.mdx is out of sync with the provider getting-started pages or the API ProviderChoices enum."
|
||||
echo "Run 'python3 docs/scripts/generate_provider_cards.py' locally and commit the regenerated snippet."
|
||||
echo "--- diff ---"
|
||||
git diff docs/snippets/provider-cards.mdx
|
||||
exit 1
|
||||
fi
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
# We can't block as Trufflehog needs to verify secrets against vendors
|
||||
egress-policy: audit
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -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
+38
-2
@@ -1,5 +1,5 @@
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"f3fa849cd645d1a648474caf20ea8291f35fb1b6801b8df2846b21487c789038","body_hash":"5a253c083c0c90f122591d8f2014dec00be2c10a75eff404e2a03bf1d7d60e36","compiler_version":"v0.81.6","agent_id":"copilot","engine_versions":{"copilot":"1.0.65"}}
|
||||
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/save","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/github-script","sha":"ed597411d8f924073f98dfc5c65a23a2325f34cd","version":"v8"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"ba6380cc6e5be5d21677bebe04d52fb48e3abec7","version":"v0.81.6"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11","digest":"sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11@sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11","digest":"sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11@sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11","digest":"sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11@sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.30","digest":"sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.30@sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]}
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"8237a612a8f5a8a197c2aab679f1177f5128eed8cb7cefbfdc0d635b57b91fa6","body_hash":"5a253c083c0c90f122591d8f2014dec00be2c10a75eff404e2a03bf1d7d60e36","compiler_version":"v0.81.6","agent_id":"copilot","engine_versions":{"copilot":"1.0.65"}}
|
||||
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache/restore","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/cache/save","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0","version":"v7.0.0"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/github-script","sha":"ed597411d8f924073f98dfc5c65a23a2325f34cd","version":"v8"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"ba6380cc6e5be5d21677bebe04d52fb48e3abec7","version":"v0.81.6"},{"repo":"step-security/harden-runner","sha":"bf7454d06d71f1098171f2acdf0cd4708d7b5920","version":"v2.20.0"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11","digest":"sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.11@sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11","digest":"sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.11@sha256:807e4831999b44513b0a66e5859d478dc4da7ae74ab1918cec967d513f95bf9d"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11","digest":"sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.11@sha256:ff27ea0525ad953a6adee28a5fbe9d2e22be47dbec755c15767af4ea3f91df7d"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.30","digest":"sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.30@sha256:35625d1a2269b1238606078c879f59a91cffc4ac33eb54bf39c6418822c1a8be"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b","pinned_image":"ghcr.io/github/gh-aw-node@sha256:529d02eb970b1161aa25c593a9c3df57fdfad5a8add328cb3b6eccef66f3183b"},{"image":"ghcr.io/github/github-mcp-server:v1.4.0","digest":"sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036","pinned_image":"ghcr.io/github/github-mcp-server:v1.4.0@sha256:2afb26356481d1a350e14544a6e160f7f7ec1561a1ea309b823665abf0309036"}]}
|
||||
# This file was automatically generated by gh-aw (v0.81.6). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
|
||||
#
|
||||
# ___ _ _
|
||||
@@ -46,6 +46,7 @@
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
|
||||
# - step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.27.11@sha256:979723c628182da7729333f2208bb249fd25ddee579645cf9a3892d681a929c7
|
||||
@@ -103,6 +104,11 @@ jobs:
|
||||
text: ${{ steps.sanitized.outputs.text }}
|
||||
title: ${{ steps.sanitized.outputs.title }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
|
||||
@@ -462,6 +468,11 @@ jobs:
|
||||
setup-trace-id: ${{ steps.setup.outputs.trace-id }}
|
||||
unknown_model_ai_credits: ${{ steps.parse-mcp-gateway.outputs.unknown_model_ai_credits || 'false' }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
|
||||
@@ -484,6 +495,11 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json"
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
@@ -1129,6 +1145,11 @@ jobs:
|
||||
tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
|
||||
total_count: ${{ steps.missing_tool.outputs.total_count }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
|
||||
@@ -1369,6 +1390,11 @@ jobs:
|
||||
detection_reason: ${{ steps.detection_conclusion.outputs.reason }}
|
||||
detection_success: ${{ steps.detection_conclusion.outputs.success }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
|
||||
@@ -1616,6 +1642,11 @@ jobs:
|
||||
setup-span-id: ${{ steps.setup.outputs.span-id }}
|
||||
setup-trace-id: ${{ steps.setup.outputs.trace-id }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
|
||||
@@ -1695,6 +1726,11 @@ jobs:
|
||||
process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }}
|
||||
process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@ba6380cc6e5be5d21677bebe04d52fb48e3abec7 # v0.81.6
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: >
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -29,7 +29,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
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -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: >
|
||||
@@ -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: >
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||
with:
|
||||
egress-policy: block
|
||||
allowed-endpoints: >
|
||||
|
||||
@@ -96,7 +96,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
|
||||
|
||||
@@ -316,7 +316,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: >
|
||||
|
||||
@@ -144,7 +144,7 @@ repos:
|
||||
|
||||
- id: generate-provider-cards
|
||||
name: "Docs - regenerate provider cards snippet"
|
||||
entry: python docs/scripts/generate_provider_cards.py
|
||||
entry: python3 docs/scripts/generate_provider_cards.py
|
||||
language: system
|
||||
files: { glob: ["docs/user-guide/providers/**/getting-started-*.mdx", "docs/scripts/generate_provider_cards.py", "docs/snippets/provider-cards.mdx", "api/src/backend/api/models.py"] }
|
||||
pass_filenames: false
|
||||
|
||||
@@ -4,6 +4,21 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
|
||||
## [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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Session tokens are rejected after account password updates
|
||||
@@ -0,0 +1 @@
|
||||
`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
|
||||
@@ -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
|
||||
|
||||
@@ -3337,7 +3348,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 +3385,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
|
||||
|
||||
|
||||
+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):
|
||||
|
||||
@@ -14,6 +14,19 @@ from rest_framework_simplejwt.token_blacklist.models import (
|
||||
OutstandingToken,
|
||||
)
|
||||
|
||||
PASSWORD_CHANGE_PASSWORD = "InitialSecret123@"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def password_change_user(tenants_fixture):
|
||||
user = User.objects.create_user(
|
||||
name="password_change_user",
|
||||
email=f"password-change-{uuid4()}@prowler.com",
|
||||
password=PASSWORD_CHANGE_PASSWORD,
|
||||
)
|
||||
Membership.objects.create(user=user, tenant=tenants_fixture[0])
|
||||
return user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_basic_authentication():
|
||||
@@ -109,16 +122,16 @@ def test_refresh_token(create_test_user, tenants_fixture):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_password_change_invalidates_existing_tokens(create_test_user, tenants_fixture):
|
||||
def test_password_change_invalidates_existing_tokens(password_change_user):
|
||||
client = APIClient()
|
||||
new_password = "ChangedSecret123@"
|
||||
|
||||
access_token, refresh_token = get_api_tokens(
|
||||
client, create_test_user.email, TEST_PASSWORD
|
||||
client, password_change_user.email, PASSWORD_CHANGE_PASSWORD
|
||||
)
|
||||
auth_headers = get_authorization_header(access_token)
|
||||
outstanding_token_ids = list(
|
||||
OutstandingToken.objects.filter(user=create_test_user).values_list(
|
||||
OutstandingToken.objects.filter(user=password_change_user).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
@@ -130,12 +143,12 @@ def test_password_change_invalidates_existing_tokens(create_test_user, tenants_f
|
||||
password_change_payload = {
|
||||
"data": {
|
||||
"type": "users",
|
||||
"id": str(create_test_user.id),
|
||||
"id": str(password_change_user.id),
|
||||
"attributes": {"password": new_password},
|
||||
}
|
||||
}
|
||||
password_change_response = client.patch(
|
||||
reverse("user-detail", kwargs={"pk": create_test_user.id}),
|
||||
reverse("user-detail", kwargs={"pk": password_change_user.id}),
|
||||
data=json.dumps(password_change_payload),
|
||||
headers=auth_headers,
|
||||
content_type="application/vnd.api+json",
|
||||
@@ -160,7 +173,9 @@ def test_password_change_invalidates_existing_tokens(create_test_user, tenants_f
|
||||
)
|
||||
assert old_refresh_response.status_code == 400
|
||||
|
||||
new_access_token, _ = get_api_tokens(client, create_test_user.email, new_password)
|
||||
new_access_token, _ = get_api_tokens(
|
||||
client, password_change_user.email, new_password
|
||||
)
|
||||
new_access_response = client.get(
|
||||
reverse("user-me"), headers=get_authorization_header(new_access_token)
|
||||
)
|
||||
@@ -169,13 +184,13 @@ def test_password_change_invalidates_existing_tokens(create_test_user, tenants_f
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_password_change_invalidates_rotated_refresh_token(
|
||||
create_test_user, tenants_fixture
|
||||
password_change_user,
|
||||
):
|
||||
client = APIClient()
|
||||
new_password = "ChangedSecret123@"
|
||||
|
||||
access_token, refresh_token = get_api_tokens(
|
||||
client, create_test_user.email, TEST_PASSWORD
|
||||
client, password_change_user.email, PASSWORD_CHANGE_PASSWORD
|
||||
)
|
||||
rotated_refresh_response = client.post(
|
||||
reverse("token-refresh"),
|
||||
@@ -195,12 +210,12 @@ def test_password_change_invalidates_rotated_refresh_token(
|
||||
password_change_payload = {
|
||||
"data": {
|
||||
"type": "users",
|
||||
"id": str(create_test_user.id),
|
||||
"id": str(password_change_user.id),
|
||||
"attributes": {"password": new_password},
|
||||
}
|
||||
}
|
||||
password_change_response = client.patch(
|
||||
reverse("user-detail", kwargs={"pk": create_test_user.id}),
|
||||
reverse("user-detail", kwargs={"pk": password_change_user.id}),
|
||||
data=json.dumps(password_change_payload),
|
||||
headers=get_authorization_header(access_token),
|
||||
content_type="application/vnd.api+json",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
from api.db_utils import (
|
||||
POSTGRES_TENANT_VAR,
|
||||
SET_CONFIG_QUERY,
|
||||
SET_TRANSACTION_READ_ONLY_QUERY,
|
||||
PostgresEnumMigration,
|
||||
_is_replica_connection_failure,
|
||||
_is_safe_primary_replay,
|
||||
_should_create_index_on_partition,
|
||||
batch_delete,
|
||||
create_objects_in_batches,
|
||||
@@ -392,10 +397,23 @@ class TestRlsTransaction:
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=None):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
mock_connections.__getitem__.return_value = mock_conn
|
||||
mock_replica_conn = MagicMock()
|
||||
mock_replica_cursor = MagicMock()
|
||||
mock_replica_conn.cursor.return_value.__enter__.return_value = (
|
||||
mock_replica_cursor
|
||||
)
|
||||
mock_primary_conn = MagicMock()
|
||||
mock_primary_cursor = MagicMock()
|
||||
mock_primary_conn.cursor.return_value.__enter__.return_value = (
|
||||
mock_primary_cursor
|
||||
)
|
||||
|
||||
def connections_getitem(alias):
|
||||
if alias == "replica":
|
||||
return mock_replica_conn
|
||||
return mock_primary_conn
|
||||
|
||||
mock_connections.__getitem__.side_effect = connections_getitem
|
||||
mock_connections.__contains__.return_value = True
|
||||
|
||||
with patch("api.db_utils.transaction.atomic"):
|
||||
@@ -525,7 +543,7 @@ class TestRlsTransaction:
|
||||
def atomic_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
if call_count < 4:
|
||||
raise OperationalError("Connection error")
|
||||
return MagicMock(
|
||||
__enter__=MagicMock(return_value=None),
|
||||
@@ -544,10 +562,11 @@ class TestRlsTransaction:
|
||||
with rls_transaction(tenant_id):
|
||||
pass
|
||||
|
||||
assert mock_sleep.call_count == 2
|
||||
assert mock_sleep.call_count == 3
|
||||
mock_sleep.assert_any_call(0.5)
|
||||
mock_sleep.assert_any_call(1.0)
|
||||
assert mock_logger.info.call_count == 2
|
||||
mock_sleep.assert_any_call(2.0)
|
||||
assert mock_logger.info.call_count == 3
|
||||
|
||||
def test_rls_transaction_operational_error_inside_context_no_retry(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
@@ -578,11 +597,12 @@ class TestRlsTransaction:
|
||||
raise OperationalError("Conflict with recovery")
|
||||
|
||||
mock_sleep.assert_not_called()
|
||||
mock_conn.close.assert_not_called()
|
||||
|
||||
def test_rls_transaction_max_three_attempts_for_replica(
|
||||
def test_rls_transaction_max_attempts_for_replica(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
):
|
||||
"""Test maximum 3 attempts for replica database."""
|
||||
"""Test REPLICA_MAX_ATTEMPTS replica tries + 1 primary fallback."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
@@ -606,7 +626,11 @@ class TestRlsTransaction:
|
||||
with rls_transaction(tenant_id):
|
||||
pass
|
||||
|
||||
assert mock_atomic.call_count == 3
|
||||
assert mock_atomic.call_args_list[-1] == call(
|
||||
using=DEFAULT_DB_ALIAS
|
||||
)
|
||||
# 3 replica + 1 primary = 4 total
|
||||
assert mock_atomic.call_count == 4
|
||||
|
||||
def test_rls_transaction_replica_no_retry_when_disabled(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
@@ -617,10 +641,23 @@ class TestRlsTransaction:
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
mock_conn = MagicMock()
|
||||
mock_cursor = MagicMock()
|
||||
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
mock_connections.__getitem__.return_value = mock_conn
|
||||
mock_replica_conn = MagicMock()
|
||||
mock_replica_cursor = MagicMock()
|
||||
mock_replica_conn.cursor.return_value.__enter__.return_value = (
|
||||
mock_replica_cursor
|
||||
)
|
||||
mock_primary_conn = MagicMock()
|
||||
mock_primary_cursor = MagicMock()
|
||||
mock_primary_conn.cursor.return_value.__enter__.return_value = (
|
||||
mock_primary_cursor
|
||||
)
|
||||
|
||||
def connections_getitem(alias):
|
||||
if alias == "replica":
|
||||
return mock_replica_conn
|
||||
return mock_primary_conn
|
||||
|
||||
mock_connections.__getitem__.side_effect = connections_getitem
|
||||
mock_connections.__contains__.return_value = True
|
||||
|
||||
with patch("api.db_utils.transaction.atomic") as mock_atomic:
|
||||
@@ -682,7 +719,7 @@ class TestRlsTransaction:
|
||||
def atomic_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
if call_count < 4:
|
||||
raise OperationalError("Replica error")
|
||||
return MagicMock(
|
||||
__enter__=MagicMock(return_value=None),
|
||||
@@ -691,7 +728,7 @@ class TestRlsTransaction:
|
||||
|
||||
with patch(
|
||||
"api.db_utils.transaction.atomic", side_effect=atomic_side_effect
|
||||
):
|
||||
) as mock_atomic:
|
||||
with patch("api.db_utils.time.sleep"):
|
||||
with patch(
|
||||
"api.db_utils.set_read_db_alias", return_value="token"
|
||||
@@ -701,6 +738,9 @@ class TestRlsTransaction:
|
||||
with rls_transaction(tenant_id):
|
||||
pass
|
||||
|
||||
assert mock_atomic.call_args_list[-1] == call(
|
||||
using=DEFAULT_DB_ALIAS
|
||||
)
|
||||
mock_logger.warning.assert_called_once()
|
||||
warning_msg = mock_logger.warning.call_args[0][0]
|
||||
assert "falling back to primary DB" in warning_msg
|
||||
@@ -725,7 +765,7 @@ class TestRlsTransaction:
|
||||
def atomic_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
if call_count < 4:
|
||||
raise OperationalError("Replica error")
|
||||
return MagicMock(
|
||||
__enter__=MagicMock(return_value=None),
|
||||
@@ -744,7 +784,7 @@ class TestRlsTransaction:
|
||||
with rls_transaction(tenant_id):
|
||||
pass
|
||||
|
||||
assert mock_logger.info.call_count == 2
|
||||
assert mock_logger.info.call_count == 3
|
||||
assert mock_logger.warning.call_count == 1
|
||||
|
||||
def test_rls_transaction_operational_error_raised_immediately_on_primary(
|
||||
@@ -910,6 +950,520 @@ class TestRlsTransaction:
|
||||
result = cursor.fetchone()
|
||||
assert result[0] == 1
|
||||
|
||||
# --- Mid-query failover tests ---
|
||||
|
||||
class _FakeDatabaseError(Exception):
|
||||
def __init__(self, message, pgcode=None):
|
||||
super().__init__(message)
|
||||
self.pgcode = pgcode
|
||||
|
||||
def _install_execute_wrapper(self, connection):
|
||||
connection.execute_wrappers = []
|
||||
|
||||
@contextmanager
|
||||
def _execute_wrapper(fn):
|
||||
connection.execute_wrappers.append(fn)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
connection.execute_wrappers.remove(fn)
|
||||
|
||||
connection.execute_wrapper = _execute_wrapper
|
||||
|
||||
def _mock_replica_and_primary_connections(self, mock_connections):
|
||||
mock_replica_conn = MagicMock()
|
||||
self._install_execute_wrapper(mock_replica_conn)
|
||||
mock_replica_cursor = MagicMock()
|
||||
mock_replica_conn.cursor.return_value.__enter__.return_value = (
|
||||
mock_replica_cursor
|
||||
)
|
||||
|
||||
mock_primary_conn = MagicMock()
|
||||
mock_primary_cursor = MagicMock()
|
||||
mock_primary_raw_cursor = MagicMock()
|
||||
mock_primary_cursor.cursor = mock_primary_raw_cursor
|
||||
mock_primary_conn.cursor.return_value = mock_primary_cursor
|
||||
|
||||
def connections_getitem(alias):
|
||||
if alias == "replica":
|
||||
return mock_replica_conn
|
||||
return mock_primary_conn
|
||||
|
||||
mock_connections.__getitem__.side_effect = connections_getitem
|
||||
mock_connections.__contains__.return_value = True
|
||||
|
||||
return mock_replica_conn, mock_primary_conn, mock_primary_cursor
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
_FakeDatabaseError("connection lost", pgcode="08006"),
|
||||
_FakeDatabaseError("terminating connection", pgcode="57P01"),
|
||||
OperationalError("SSL SYSCALL error: EOF detected"),
|
||||
OperationalError("server closed the connection unexpectedly"),
|
||||
OperationalError("database system is starting up"),
|
||||
],
|
||||
)
|
||||
def test_replica_connection_failure_detection_allows_failover(self, error):
|
||||
assert _is_replica_connection_failure(error)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
_FakeDatabaseError("canceling statement", pgcode="57014"),
|
||||
_FakeDatabaseError("could not serialize access", pgcode="40001"),
|
||||
_FakeDatabaseError("deadlock detected", pgcode="40P01"),
|
||||
OperationalError("deadlock detected"),
|
||||
],
|
||||
)
|
||||
def test_replica_connection_failure_detection_rejects_query_errors(self, error):
|
||||
assert not _is_replica_connection_failure(error)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sql", "many", "expected"),
|
||||
[
|
||||
("SELECT 1", False, True),
|
||||
(" -- leading comment\nSELECT 1", False, True),
|
||||
("/* leading comment */ SELECT 1", False, True),
|
||||
("SELECT 1", True, False),
|
||||
("SELECTING 1", False, False),
|
||||
("INSERT INTO fake_table (name) VALUES (%s)", False, False),
|
||||
("WITH rows AS (SELECT 1) SELECT * FROM rows", False, False),
|
||||
("SELECT * INTO fake_table_copy FROM fake_table", False, False),
|
||||
("SELECT * FROM fake_table FOR UPDATE", False, False),
|
||||
("SELECT * FROM fake_table FOR SHARE", False, False),
|
||||
],
|
||||
)
|
||||
def test_primary_replay_safety_detection(self, sql, many, expected):
|
||||
assert _is_safe_primary_replay(sql, many) is expected
|
||||
|
||||
def test_mid_query_failure_falls_directly_back_to_primary(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
):
|
||||
"""Mid-query replica connection loss is replayed once on primary."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
(
|
||||
mock_replica_conn,
|
||||
mock_primary_conn,
|
||||
mock_primary_cursor,
|
||||
) = self._mock_replica_and_primary_connections(mock_connections)
|
||||
|
||||
outer_atomic = MagicMock()
|
||||
outer_atomic.__enter__ = MagicMock(return_value=None)
|
||||
outer_atomic.__exit__ = MagicMock(return_value=False)
|
||||
fallback_atomic = MagicMock()
|
||||
fallback_atomic.__enter__ = MagicMock(return_value=None)
|
||||
fallback_atomic.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"api.db_utils.transaction.atomic",
|
||||
side_effect=[outer_atomic, fallback_atomic],
|
||||
) as mock_atomic:
|
||||
with patch("api.db_utils.time.sleep") as mock_sleep:
|
||||
with patch(
|
||||
"api.db_utils.set_read_db_alias",
|
||||
side_effect=["replica-token", "primary-token"],
|
||||
) as mock_set_alias:
|
||||
with patch(
|
||||
"api.db_utils.reset_read_db_alias"
|
||||
) as mock_reset_alias:
|
||||
with rls_transaction(tenant_id):
|
||||
wrapper = mock_replica_conn.execute_wrappers[0]
|
||||
context_cursor = MagicMock()
|
||||
mock_execute = MagicMock(
|
||||
side_effect=OperationalError(
|
||||
"SSL SYSCALL error: EOF detected"
|
||||
)
|
||||
)
|
||||
|
||||
wrapper(
|
||||
mock_execute,
|
||||
"SELECT %s",
|
||||
["value"],
|
||||
False,
|
||||
{"cursor": context_cursor},
|
||||
)
|
||||
|
||||
mock_sleep.assert_not_called()
|
||||
(
|
||||
mock_replica_conn.ensure_connection.assert_not_called()
|
||||
)
|
||||
mock_replica_conn.close.assert_called_once()
|
||||
(
|
||||
mock_primary_conn.ensure_connection.assert_called_once()
|
||||
)
|
||||
mock_primary_conn.cursor.assert_called_once_with()
|
||||
mock_primary_cursor.execute.assert_has_calls(
|
||||
[
|
||||
call(SET_TRANSACTION_READ_ONLY_QUERY),
|
||||
call(
|
||||
SET_CONFIG_QUERY,
|
||||
[POSTGRES_TENANT_VAR, tenant_id],
|
||||
),
|
||||
call("SELECT %s", ["value"]),
|
||||
]
|
||||
)
|
||||
assert context_cursor.db == mock_primary_conn
|
||||
assert (
|
||||
context_cursor.cursor
|
||||
== mock_primary_cursor.cursor
|
||||
)
|
||||
|
||||
mock_set_alias.assert_has_calls(
|
||||
[
|
||||
call(enable_read_replica),
|
||||
call(DEFAULT_DB_ALIAS),
|
||||
]
|
||||
)
|
||||
mock_reset_alias.assert_has_calls(
|
||||
[call("primary-token"), call("replica-token")]
|
||||
)
|
||||
assert mock_atomic.call_args_list == [
|
||||
call(using=enable_read_replica),
|
||||
call(using=DEFAULT_DB_ALIAS),
|
||||
]
|
||||
assert mock_replica_conn.execute_wrappers == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sql", "params", "many"),
|
||||
[
|
||||
("INSERT INTO fake_table (name) VALUES (%s)", [("one",), ("two",)], True),
|
||||
("INSERT INTO fake_table (name) VALUES (%s)", ["one"], False),
|
||||
("UPDATE fake_table SET name = %s", ["one"], False),
|
||||
("DELETE FROM fake_table WHERE id = %s", [1], False),
|
||||
(
|
||||
"WITH deleted AS (DELETE FROM fake_table RETURNING *) "
|
||||
"SELECT * FROM deleted",
|
||||
None,
|
||||
False,
|
||||
),
|
||||
("SELECT * INTO fake_table_copy FROM fake_table", None, False),
|
||||
],
|
||||
)
|
||||
def test_mid_query_fallback_rejects_unsafe_replay(
|
||||
self, tenants_fixture, enable_read_replica, sql, params, many
|
||||
):
|
||||
"""Only single SELECT statements are replayed on primary."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
(
|
||||
mock_replica_conn,
|
||||
mock_primary_conn,
|
||||
mock_primary_cursor,
|
||||
) = self._mock_replica_and_primary_connections(mock_connections)
|
||||
|
||||
with patch("api.db_utils.transaction.atomic") as mock_atomic:
|
||||
mock_atomic.return_value.__enter__ = MagicMock(return_value=None)
|
||||
mock_atomic.return_value.__exit__ = MagicMock(return_value=False)
|
||||
with patch(
|
||||
"api.db_utils.set_read_db_alias",
|
||||
side_effect=["replica-token", "primary-token"],
|
||||
):
|
||||
with patch("api.db_utils.reset_read_db_alias"):
|
||||
with rls_transaction(tenant_id):
|
||||
wrapper = mock_replica_conn.execute_wrappers[0]
|
||||
mock_execute = MagicMock(
|
||||
side_effect=OperationalError(
|
||||
"server closed the connection"
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(OperationalError):
|
||||
wrapper(
|
||||
mock_execute,
|
||||
sql,
|
||||
params,
|
||||
many,
|
||||
{"cursor": MagicMock()},
|
||||
)
|
||||
|
||||
mock_primary_conn.ensure_connection.assert_not_called()
|
||||
mock_primary_conn.cursor.assert_not_called()
|
||||
mock_primary_cursor.execute.assert_not_called()
|
||||
mock_primary_cursor.executemany.assert_not_called()
|
||||
|
||||
def test_mid_query_non_connection_error_does_not_fall_back(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
):
|
||||
"""Query/concurrency errors are not replayed on primary."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
(
|
||||
mock_replica_conn,
|
||||
mock_primary_conn,
|
||||
_mock_primary_cursor,
|
||||
) = self._mock_replica_and_primary_connections(mock_connections)
|
||||
|
||||
with patch("api.db_utils.transaction.atomic") as mock_atomic:
|
||||
mock_atomic.return_value.__enter__ = MagicMock(return_value=None)
|
||||
mock_atomic.return_value.__exit__ = MagicMock(return_value=False)
|
||||
with patch(
|
||||
"api.db_utils.set_read_db_alias", return_value="replica-token"
|
||||
):
|
||||
with patch("api.db_utils.reset_read_db_alias"):
|
||||
with rls_transaction(tenant_id):
|
||||
wrapper = mock_replica_conn.execute_wrappers[0]
|
||||
mock_execute = MagicMock(
|
||||
side_effect=OperationalError("deadlock detected")
|
||||
)
|
||||
|
||||
with pytest.raises(OperationalError):
|
||||
wrapper(
|
||||
mock_execute,
|
||||
"SELECT 1",
|
||||
None,
|
||||
False,
|
||||
{"cursor": MagicMock()},
|
||||
)
|
||||
|
||||
mock_replica_conn.close.assert_not_called()
|
||||
(
|
||||
mock_primary_conn.ensure_connection.assert_not_called()
|
||||
)
|
||||
|
||||
def test_mid_query_primary_replay_failure_propagates(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
):
|
||||
"""Primary fallback errors propagate as Django OperationalError."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
(
|
||||
mock_replica_conn,
|
||||
_mock_primary_conn,
|
||||
mock_primary_cursor,
|
||||
) = self._mock_replica_and_primary_connections(mock_connections)
|
||||
mock_primary_cursor.execute.side_effect = [
|
||||
None,
|
||||
None,
|
||||
OperationalError("primary down"),
|
||||
]
|
||||
|
||||
with patch("api.db_utils.transaction.atomic") as mock_atomic:
|
||||
mock_atomic.return_value.__enter__ = MagicMock(return_value=None)
|
||||
mock_atomic.return_value.__exit__ = MagicMock(return_value=False)
|
||||
with patch(
|
||||
"api.db_utils.set_read_db_alias",
|
||||
side_effect=["replica-token", "primary-token"],
|
||||
):
|
||||
with patch("api.db_utils.reset_read_db_alias"):
|
||||
with pytest.raises(OperationalError, match="primary down"):
|
||||
with rls_transaction(tenant_id):
|
||||
wrapper = mock_replica_conn.execute_wrappers[0]
|
||||
mock_execute = MagicMock(
|
||||
side_effect=OperationalError(
|
||||
"server closed the connection"
|
||||
)
|
||||
)
|
||||
wrapper(
|
||||
mock_execute,
|
||||
"SELECT 1",
|
||||
None,
|
||||
False,
|
||||
{"cursor": MagicMock()},
|
||||
)
|
||||
|
||||
mock_primary_cursor.close.assert_called_once()
|
||||
|
||||
def test_mid_query_fallback_suppresses_cleanup_error(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
):
|
||||
"""After successful primary fallback, replica cleanup error is suppressed."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
(
|
||||
mock_replica_conn,
|
||||
_mock_primary_conn,
|
||||
mock_primary_cursor,
|
||||
) = self._mock_replica_and_primary_connections(mock_connections)
|
||||
|
||||
# Replica's atomic.__exit__ raises on dead replica cleanup;
|
||||
# primary's atomic.__exit__ returns False (healthy commit).
|
||||
mock_outer_atomic = MagicMock()
|
||||
mock_outer_atomic.__enter__ = MagicMock(return_value=None)
|
||||
mock_outer_atomic.__exit__ = MagicMock(
|
||||
side_effect=OperationalError("cleanup failed on dead replica")
|
||||
)
|
||||
|
||||
mock_fallback_atomic = MagicMock()
|
||||
mock_fallback_atomic.__enter__ = MagicMock(return_value=None)
|
||||
mock_fallback_atomic.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
atomic_call_count = 0
|
||||
|
||||
def atomic_side_effect(*args, **kwargs):
|
||||
nonlocal atomic_call_count
|
||||
atomic_call_count += 1
|
||||
if atomic_call_count == 1:
|
||||
return mock_outer_atomic
|
||||
return mock_fallback_atomic
|
||||
|
||||
with patch(
|
||||
"api.db_utils.transaction.atomic",
|
||||
side_effect=atomic_side_effect,
|
||||
):
|
||||
with patch(
|
||||
"api.db_utils.set_read_db_alias",
|
||||
side_effect=["replica-token", "primary-token"],
|
||||
):
|
||||
with patch("api.db_utils.reset_read_db_alias"):
|
||||
with rls_transaction(tenant_id):
|
||||
wrapper = mock_replica_conn.execute_wrappers[0]
|
||||
mock_execute = MagicMock(
|
||||
side_effect=OperationalError(
|
||||
"server closed the connection"
|
||||
)
|
||||
)
|
||||
mock_context = {"cursor": MagicMock()}
|
||||
wrapper(
|
||||
mock_execute,
|
||||
"SELECT 1",
|
||||
None,
|
||||
False,
|
||||
mock_context,
|
||||
)
|
||||
|
||||
mock_primary_cursor.execute.assert_has_calls(
|
||||
[
|
||||
call(SET_TRANSACTION_READ_ONLY_QUERY),
|
||||
call(
|
||||
SET_CONFIG_QUERY,
|
||||
[POSTGRES_TENANT_VAR, tenant_id],
|
||||
),
|
||||
call("SELECT 1", None),
|
||||
]
|
||||
)
|
||||
|
||||
def test_wrapper_not_installed_on_primary(self, tenants_fixture):
|
||||
"""execute_wrapper is not installed when targeting primary DB."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=None):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute_wrappers = []
|
||||
mock_cursor = MagicMock()
|
||||
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
mock_connections.__getitem__.return_value = mock_conn
|
||||
mock_connections.__contains__.return_value = True
|
||||
|
||||
with patch("api.db_utils.transaction.atomic") as mock_atomic:
|
||||
mock_atomic.return_value.__enter__ = MagicMock(return_value=None)
|
||||
mock_atomic.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
# No wrapper installed on primary
|
||||
assert len(mock_conn.execute_wrappers) == 0
|
||||
|
||||
def test_stale_connection_closed_on_pre_yield_retry(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
):
|
||||
"""Stale connection is closed before each pre-yield retry."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute_wrappers = []
|
||||
mock_cursor = MagicMock()
|
||||
mock_conn.cursor.return_value.__enter__.return_value = mock_cursor
|
||||
mock_connections.__getitem__.return_value = mock_conn
|
||||
mock_connections.__contains__.return_value = True
|
||||
|
||||
call_count = 0
|
||||
|
||||
def atomic_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
raise OperationalError("Connection error")
|
||||
return MagicMock(
|
||||
__enter__=MagicMock(return_value=None),
|
||||
__exit__=MagicMock(return_value=False),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"api.db_utils.transaction.atomic", side_effect=atomic_side_effect
|
||||
):
|
||||
with patch("api.db_utils.time.sleep"):
|
||||
with patch(
|
||||
"api.db_utils.set_read_db_alias", return_value="token"
|
||||
):
|
||||
with patch("api.db_utils.reset_read_db_alias"):
|
||||
with rls_transaction(tenant_id):
|
||||
pass
|
||||
|
||||
# close() called for each failed pre-yield attempt
|
||||
assert mock_conn.close.call_count == 2
|
||||
|
||||
def test_caller_error_propagates_after_successful_failover(
|
||||
self, tenants_fixture, enable_read_replica
|
||||
):
|
||||
"""OperationalError raised by caller after failover is NOT suppressed."""
|
||||
tenant = tenants_fixture[0]
|
||||
tenant_id = str(tenant.id)
|
||||
|
||||
with patch("api.db_utils.get_read_db_alias", return_value=enable_read_replica):
|
||||
with patch("api.db_utils.connections") as mock_connections:
|
||||
(
|
||||
mock_replica_conn,
|
||||
_mock_primary_conn,
|
||||
_mock_primary_cursor,
|
||||
) = self._mock_replica_and_primary_connections(mock_connections)
|
||||
|
||||
# Transaction cleanup succeeds so the caller error should surface.
|
||||
mock_atomic_cm = MagicMock()
|
||||
mock_atomic_cm.__enter__ = MagicMock(return_value=None)
|
||||
mock_atomic_cm.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
with patch(
|
||||
"api.db_utils.transaction.atomic", return_value=mock_atomic_cm
|
||||
):
|
||||
with patch("api.db_utils.time.sleep"):
|
||||
with patch(
|
||||
"api.db_utils.set_read_db_alias", return_value="token"
|
||||
):
|
||||
with patch("api.db_utils.reset_read_db_alias"):
|
||||
with pytest.raises(
|
||||
OperationalError, match="caller error"
|
||||
):
|
||||
with rls_transaction(tenant_id):
|
||||
# Trigger failover (succeeds on primary)
|
||||
wrapper = mock_replica_conn.execute_wrappers[0]
|
||||
mock_execute = MagicMock(
|
||||
side_effect=OperationalError(
|
||||
"server closed the connection"
|
||||
)
|
||||
)
|
||||
mock_context = {"cursor": MagicMock()}
|
||||
wrapper(
|
||||
mock_execute,
|
||||
"SELECT 1",
|
||||
None,
|
||||
False,
|
||||
mock_context,
|
||||
)
|
||||
# Caller errors after successful failover
|
||||
# should still propagate.
|
||||
raise OperationalError("caller error")
|
||||
|
||||
|
||||
class TestPostgresEnumMigration:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
from api.validators import (
|
||||
resolve_lighthouse_openai_compatible_host,
|
||||
validate_lighthouse_openai_compatible_base_url,
|
||||
)
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import override_settings
|
||||
|
||||
|
||||
def test_lighthouse_base_url_rejects_http_scheme():
|
||||
with pytest.raises(ValidationError, match="HTTPS"):
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
"http://openrouter.ai/api/v1",
|
||||
resolve_dns=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base_url",
|
||||
[
|
||||
"https://openrouter.ai:0/api/v1",
|
||||
"https://openrouter.ai:-1/api/v1",
|
||||
"https://openrouter.ai:65536/api/v1",
|
||||
"https://openrouter.ai:invalid/api/v1",
|
||||
],
|
||||
)
|
||||
def test_lighthouse_base_url_rejects_invalid_port(base_url):
|
||||
with pytest.raises(ValidationError, match="port is invalid"):
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
base_url,
|
||||
resolve_dns=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("port", [1, 65535])
|
||||
def test_lighthouse_base_url_accepts_valid_port_boundaries(port):
|
||||
assert (
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
f"https://openrouter.ai:{port}/api/v1",
|
||||
resolve_dns=False,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_lighthouse_base_url_rejects_localhost():
|
||||
with pytest.raises(ValidationError, match="external public endpoint"):
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
"https://localhost/v1",
|
||||
resolve_dns=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ip_address", ["10.0.0.1", "172.16.0.1", "192.168.1.1"])
|
||||
def test_lighthouse_base_url_rejects_private_ip_literal(ip_address):
|
||||
with pytest.raises(ValidationError, match="external public endpoint"):
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
f"https://{ip_address}/v1",
|
||||
resolve_dns=False,
|
||||
)
|
||||
|
||||
|
||||
def test_lighthouse_base_url_rejects_metadata_ip_literal():
|
||||
with pytest.raises(ValidationError, match="external public endpoint"):
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
"https://169.254.169.254/latest/meta-data",
|
||||
resolve_dns=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base_url",
|
||||
[
|
||||
"https://[::ffff:169.254.169.254]/v1",
|
||||
"https://[64:ff9b::a9fe:a9fe]/v1",
|
||||
"https://[2002:a9fe:a9fe::]/v1",
|
||||
],
|
||||
)
|
||||
def test_lighthouse_base_url_rejects_embedded_non_global_ip(base_url):
|
||||
with pytest.raises(ValidationError, match="external public endpoint"):
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
base_url,
|
||||
resolve_dns=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base_url",
|
||||
[
|
||||
"https://[::ffff:93.184.216.34]/v1",
|
||||
"https://[64:ff9b::5db8:d822]/v1",
|
||||
"https://[2002:5db8:d822::]/v1",
|
||||
],
|
||||
)
|
||||
def test_lighthouse_base_url_accepts_embedded_public_ip(base_url):
|
||||
assert (
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
base_url,
|
||||
resolve_dns=False,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_lighthouse_base_url_accepts_hostname_without_dns_resolution():
|
||||
assert (
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
"https://openrouter.ai/api/v1",
|
||||
resolve_dns=False,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_lighthouse_base_url_rejects_post_dns_internal_address(monkeypatch):
|
||||
def resolve_to_metadata(*_args, **_kwargs):
|
||||
return [
|
||||
(
|
||||
socket.AF_INET,
|
||||
socket.SOCK_STREAM,
|
||||
6,
|
||||
"",
|
||||
("169.254.169.254", 443),
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr("api.validators.socket.getaddrinfo", resolve_to_metadata)
|
||||
|
||||
with pytest.raises(ValidationError, match="external public endpoint"):
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
"https://metadata.example.test/v1"
|
||||
)
|
||||
|
||||
|
||||
def test_lighthouse_base_url_accepts_public_resolved_address(monkeypatch):
|
||||
def resolve_to_public(*_args, **_kwargs):
|
||||
return [
|
||||
(
|
||||
socket.AF_INET,
|
||||
socket.SOCK_STREAM,
|
||||
6,
|
||||
"",
|
||||
("93.184.216.34", 443),
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr("api.validators.socket.getaddrinfo", resolve_to_public)
|
||||
|
||||
assert (
|
||||
validate_lighthouse_openai_compatible_base_url("https://openrouter.ai/api/v1")
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["custom-openai.internal"]
|
||||
)
|
||||
def test_lighthouse_base_url_accepts_allowlisted_host_without_resolution(monkeypatch):
|
||||
def fail_resolution(*_args, **_kwargs):
|
||||
raise AssertionError("allowlisted hosts must not be resolved")
|
||||
|
||||
monkeypatch.setattr("api.validators.socket.getaddrinfo", fail_resolution)
|
||||
|
||||
assert (
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
"https://custom-openai.internal/v1"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["custom-openai.internal"]
|
||||
)
|
||||
def test_lighthouse_resolve_returns_allowlisted_hostname_unpinned():
|
||||
assert resolve_lighthouse_openai_compatible_host(
|
||||
"Custom-OpenAI.internal.", 443
|
||||
) == ("custom-openai.internal",)
|
||||
|
||||
|
||||
@override_settings(LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["localhost"])
|
||||
def test_lighthouse_base_url_accepts_allowlisted_blocked_host():
|
||||
assert (
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
"https://localhost/v1",
|
||||
resolve_dns=False,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@override_settings(LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["10.0.0.1"])
|
||||
def test_lighthouse_base_url_accepts_allowlisted_private_ip_literal():
|
||||
assert (
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
"https://10.0.0.1/v1",
|
||||
resolve_dns=False,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=[" Custom-OpenAI.Internal. "]
|
||||
)
|
||||
def test_lighthouse_allowlist_entries_are_normalized():
|
||||
assert (
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
"https://custom-openai.internal/v1",
|
||||
resolve_dns=False,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["custom-openai.internal"]
|
||||
)
|
||||
def test_lighthouse_base_url_rejects_host_not_in_allowlist():
|
||||
with pytest.raises(ValidationError, match="external public endpoint"):
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
"https://localhost/v1",
|
||||
resolve_dns=False,
|
||||
)
|
||||
|
||||
|
||||
@override_settings(LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=[""])
|
||||
def test_lighthouse_allowlist_ignores_empty_entries():
|
||||
with pytest.raises(ValidationError, match="external public endpoint"):
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
"https://localhost/v1",
|
||||
resolve_dns=False,
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=["custom-openai.internal"]
|
||||
)
|
||||
def test_lighthouse_base_url_allowlisted_host_still_requires_https():
|
||||
with pytest.raises(ValidationError, match="HTTPS"):
|
||||
validate_lighthouse_openai_compatible_base_url(
|
||||
"http://custom-openai.internal/v1",
|
||||
resolve_dns=False,
|
||||
)
|
||||
@@ -17308,6 +17308,76 @@ class TestLighthouseProviderConfigViewSet:
|
||||
error_detail = str(resp.json()).lower()
|
||||
assert "base_url" in error_detail
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base_url",
|
||||
[
|
||||
"https://127.0.0.1/v1",
|
||||
"https://169.254.169.254/latest/meta-data",
|
||||
],
|
||||
)
|
||||
def test_openai_compatible_rejects_internal_base_url_on_create(
|
||||
self, authenticated_client, base_url
|
||||
):
|
||||
payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "openai_compatible",
|
||||
"base_url": base_url,
|
||||
"credentials": {"api_key": "compat-key"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "base_url" in str(resp.json()).lower()
|
||||
|
||||
def test_openai_compatible_rejects_internal_base_url_on_update(
|
||||
self, authenticated_client
|
||||
):
|
||||
create_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"attributes": {
|
||||
"provider_type": "openai_compatible",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"credentials": {"api_key": "compat-key-123"},
|
||||
},
|
||||
}
|
||||
}
|
||||
create_resp = authenticated_client.post(
|
||||
reverse("lighthouse-providers-list"),
|
||||
data=create_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
assert create_resp.status_code == status.HTTP_201_CREATED
|
||||
provider_id = create_resp.json()["data"]["id"]
|
||||
|
||||
patch_payload = {
|
||||
"data": {
|
||||
"type": "lighthouse-providers",
|
||||
"id": provider_id,
|
||||
"attributes": {
|
||||
"base_url": "https://169.254.169.254/latest/meta-data",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
patch_resp = authenticated_client.patch(
|
||||
reverse("lighthouse-providers-detail", kwargs={"pk": provider_id}),
|
||||
data=patch_payload,
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
assert patch_resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "base_url" in str(patch_resp.json()).lower()
|
||||
|
||||
def test_openai_compatible_invalid_credentials(self, authenticated_client):
|
||||
payload = {
|
||||
"data": {
|
||||
|
||||
@@ -57,6 +57,7 @@ from api.v1.serializer_utils.lighthouse import (
|
||||
)
|
||||
from api.v1.serializer_utils.processors import ProcessorConfigField
|
||||
from api.v1.serializer_utils.providers import ProviderSecretField
|
||||
from api.validators import validate_lighthouse_openai_compatible_base_url
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import authenticate
|
||||
from django.contrib.auth.models import update_last_login
|
||||
@@ -80,6 +81,21 @@ from rest_framework_simplejwt.utils import get_md5_hash_password
|
||||
# Base
|
||||
|
||||
|
||||
def _validate_lighthouse_base_url_without_dns(base_url: str) -> None:
|
||||
try:
|
||||
validate_lighthouse_openai_compatible_base_url(base_url, resolve_dns=False)
|
||||
except DjangoValidationError as error:
|
||||
raise ValidationError({"base_url": error.messages[0]}) from error
|
||||
|
||||
|
||||
def _reraise_lighthouse_credentials_errors(error: ValidationError) -> None:
|
||||
details = error.detail.copy()
|
||||
for key, value in details.items():
|
||||
error.detail[f"credentials/{key}"] = value
|
||||
del error.detail[key]
|
||||
raise error
|
||||
|
||||
|
||||
class BaseModelSerializerV1(serializers.ModelSerializer):
|
||||
def get_root_meta(self, _resource, _many):
|
||||
return {"version": "v1"}
|
||||
@@ -3645,11 +3661,7 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize
|
||||
raise_exception=True
|
||||
)
|
||||
except ValidationError as e:
|
||||
details = e.detail.copy()
|
||||
for key, value in details.items():
|
||||
e.detail[f"credentials/{key}"] = value
|
||||
del e.detail[key]
|
||||
raise e
|
||||
_reraise_lighthouse_credentials_errors(e)
|
||||
elif (
|
||||
provider_type == LighthouseProviderConfiguration.LLMProviderChoices.BEDROCK
|
||||
):
|
||||
@@ -3658,27 +3670,20 @@ class LighthouseProviderConfigCreateSerializer(RLSSerializer, BaseWriteSerialize
|
||||
raise_exception=True
|
||||
)
|
||||
except ValidationError as e:
|
||||
details = e.detail.copy()
|
||||
for key, value in details.items():
|
||||
e.detail[f"credentials/{key}"] = value
|
||||
del e.detail[key]
|
||||
raise e
|
||||
_reraise_lighthouse_credentials_errors(e)
|
||||
elif (
|
||||
provider_type
|
||||
== LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE
|
||||
):
|
||||
if not base_url:
|
||||
raise ValidationError({"base_url": "Base URL is required."})
|
||||
_validate_lighthouse_base_url_without_dns(base_url)
|
||||
try:
|
||||
OpenAICompatibleCredentialsSerializer(data=credentials).is_valid(
|
||||
raise_exception=True
|
||||
)
|
||||
except ValidationError as e:
|
||||
details = e.detail.copy()
|
||||
for key, value in details.items():
|
||||
e.detail[f"credentials/{key}"] = value
|
||||
del e.detail[key]
|
||||
raise e
|
||||
_reraise_lighthouse_credentials_errors(e)
|
||||
|
||||
return super().validate(attrs)
|
||||
|
||||
@@ -3741,11 +3746,7 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
|
||||
raise_exception=True
|
||||
)
|
||||
except ValidationError as e:
|
||||
details = e.detail.copy()
|
||||
for key, value in details.items():
|
||||
e.detail[f"credentials/{key}"] = value
|
||||
del e.detail[key]
|
||||
raise e
|
||||
_reraise_lighthouse_credentials_errors(e)
|
||||
elif (
|
||||
credentials is not None
|
||||
and provider_type
|
||||
@@ -3769,11 +3770,7 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
|
||||
raise_exception=True
|
||||
)
|
||||
except ValidationError as e:
|
||||
details = e.detail.copy()
|
||||
for key, value in details.items():
|
||||
e.detail[f"credentials/{key}"] = value
|
||||
del e.detail[key]
|
||||
raise e
|
||||
_reraise_lighthouse_credentials_errors(e)
|
||||
|
||||
# Then enforce invariants about not changing the auth method
|
||||
# If the existing config uses an API key, forbid introducing access keys.
|
||||
@@ -3800,24 +3797,23 @@ class LighthouseProviderConfigUpdateSerializer(BaseWriteSerializer):
|
||||
}
|
||||
)
|
||||
elif (
|
||||
credentials is not None
|
||||
and provider_type
|
||||
provider_type
|
||||
== LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE
|
||||
):
|
||||
if base_url is None:
|
||||
pass
|
||||
elif not base_url:
|
||||
effective_base_url = (
|
||||
base_url if "base_url" in attrs else getattr(self.instance, "base_url")
|
||||
)
|
||||
if not effective_base_url:
|
||||
raise ValidationError({"base_url": "Base URL cannot be empty."})
|
||||
try:
|
||||
OpenAICompatibleCredentialsSerializer(data=credentials).is_valid(
|
||||
raise_exception=True
|
||||
)
|
||||
except ValidationError as e:
|
||||
details = e.detail.copy()
|
||||
for key, value in details.items():
|
||||
e.detail[f"credentials/{key}"] = value
|
||||
del e.detail[key]
|
||||
raise e
|
||||
if "base_url" in attrs:
|
||||
_validate_lighthouse_base_url_without_dns(effective_base_url)
|
||||
if credentials is not None:
|
||||
try:
|
||||
OpenAICompatibleCredentialsSerializer(data=credentials).is_valid(
|
||||
raise_exception=True
|
||||
)
|
||||
except ValidationError as e:
|
||||
_reraise_lighthouse_credentials_errors(e)
|
||||
|
||||
return super().validate(attrs)
|
||||
|
||||
|
||||
@@ -1,14 +1,155 @@
|
||||
import ipaddress
|
||||
import socket
|
||||
import string
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
LIGHTHOUSE_OPENAI_COMPATIBLE_ALLOWED_SCHEMES = frozenset({"https"})
|
||||
LIGHTHOUSE_NAT64_WELL_KNOWN_PREFIX = ipaddress.IPv6Network("64:ff9b::/96")
|
||||
LIGHTHOUSE_BLOCKED_METADATA_HOSTS = frozenset(
|
||||
{
|
||||
"169.254.169.254",
|
||||
"169.254.170.2",
|
||||
"fd00:ec2::254",
|
||||
"localhost",
|
||||
"metadata.google.internal",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _normalize_hostname(hostname: str) -> str:
|
||||
return hostname.rstrip(".").lower()
|
||||
|
||||
|
||||
def _lighthouse_openai_compatible_allowed_hosts() -> frozenset[str]:
|
||||
return frozenset(
|
||||
_normalize_hostname(allowed_host.strip())
|
||||
for allowed_host in settings.LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS
|
||||
if allowed_host and allowed_host.strip()
|
||||
)
|
||||
|
||||
|
||||
def _validate_lighthouse_public_ip(address: str) -> None:
|
||||
ip_address = ipaddress.ip_address(address)
|
||||
if isinstance(ip_address, ipaddress.IPv6Address):
|
||||
# Classify transition addresses by their effective IPv4 destination.
|
||||
embedded_ip_address = ip_address.ipv4_mapped or ip_address.sixtofour
|
||||
if (
|
||||
embedded_ip_address is None
|
||||
and ip_address in LIGHTHOUSE_NAT64_WELL_KNOWN_PREFIX
|
||||
):
|
||||
embedded_ip_address = ipaddress.IPv4Address(int(ip_address) & 0xFFFFFFFF)
|
||||
if embedded_ip_address is not None:
|
||||
ip_address = embedded_ip_address
|
||||
if not ip_address.is_global:
|
||||
raise ValidationError(
|
||||
_("Base URL must use an external public endpoint."),
|
||||
code="lighthouse_base_url_not_public",
|
||||
)
|
||||
|
||||
|
||||
def resolve_lighthouse_openai_compatible_host(
|
||||
hostname: str,
|
||||
port: int,
|
||||
*,
|
||||
resolve_dns: bool = True,
|
||||
) -> tuple[str, ...]:
|
||||
"""Return public IP addresses that are safe for Lighthouse outbound use."""
|
||||
hostname = _normalize_hostname(hostname)
|
||||
if hostname in _lighthouse_openai_compatible_allowed_hosts():
|
||||
# Operator-allowlisted hosts skip the public-endpoint checks; returning
|
||||
# the hostname makes the network backend connect through regular DNS
|
||||
# resolution instead of pinned addresses.
|
||||
return (hostname,)
|
||||
|
||||
if hostname in LIGHTHOUSE_BLOCKED_METADATA_HOSTS or hostname.endswith(".localhost"):
|
||||
raise ValidationError(
|
||||
_("Base URL must use an external public endpoint."),
|
||||
code="lighthouse_base_url_blocked_host",
|
||||
)
|
||||
|
||||
try:
|
||||
_validate_lighthouse_public_ip(hostname)
|
||||
except ValueError:
|
||||
if not resolve_dns:
|
||||
return ()
|
||||
else:
|
||||
return (hostname,)
|
||||
|
||||
try:
|
||||
resolved_addresses = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror as error:
|
||||
raise ValidationError(
|
||||
_("Base URL host could not be resolved."),
|
||||
code="lighthouse_base_url_resolution_failed",
|
||||
) from error
|
||||
|
||||
if not resolved_addresses:
|
||||
raise ValidationError(
|
||||
_("Base URL host could not be resolved."),
|
||||
code="lighthouse_base_url_resolution_failed",
|
||||
)
|
||||
|
||||
public_addresses: list[str] = []
|
||||
for resolved_address in resolved_addresses:
|
||||
socket_address = resolved_address[4]
|
||||
resolved_ip_address = socket_address[0]
|
||||
_validate_lighthouse_public_ip(resolved_ip_address)
|
||||
if resolved_ip_address not in public_addresses:
|
||||
public_addresses.append(resolved_ip_address)
|
||||
|
||||
return tuple(public_addresses)
|
||||
|
||||
|
||||
def validate_lighthouse_openai_compatible_base_url(
|
||||
base_url: str,
|
||||
*,
|
||||
resolve_dns: bool = True,
|
||||
) -> None:
|
||||
"""Validate an OpenAI-compatible Lighthouse base URL before outbound use."""
|
||||
parsed = urlparse(str(base_url))
|
||||
if parsed.scheme.lower() not in LIGHTHOUSE_OPENAI_COMPATIBLE_ALLOWED_SCHEMES:
|
||||
raise ValidationError(
|
||||
_("Base URL must use HTTPS."),
|
||||
code="lighthouse_base_url_invalid_scheme",
|
||||
)
|
||||
|
||||
if not parsed.hostname:
|
||||
raise ValidationError(
|
||||
_("Base URL must include a host."),
|
||||
code="lighthouse_base_url_missing_host",
|
||||
)
|
||||
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as error:
|
||||
raise ValidationError(
|
||||
_("Base URL port is invalid."),
|
||||
code="lighthouse_base_url_invalid_port",
|
||||
) from error
|
||||
|
||||
if port is not None and not 1 <= port <= 65535:
|
||||
raise ValidationError(
|
||||
_("Base URL port is invalid."),
|
||||
code="lighthouse_base_url_invalid_port",
|
||||
)
|
||||
|
||||
resolve_lighthouse_openai_compatible_host(
|
||||
parsed.hostname,
|
||||
port or 443,
|
||||
resolve_dns=resolve_dns,
|
||||
)
|
||||
|
||||
|
||||
class MaximumLengthValidator:
|
||||
def __init__(self, max_length=72):
|
||||
self.max_length = max_length
|
||||
|
||||
def validate(self, password, user=None):
|
||||
del user
|
||||
if len(password) > self.max_length:
|
||||
raise ValidationError(
|
||||
_(
|
||||
@@ -31,6 +172,7 @@ class SpecialCharactersValidator:
|
||||
self.min_special_characters = min_special_characters
|
||||
|
||||
def validate(self, password, user=None):
|
||||
del user
|
||||
if (
|
||||
sum(1 for char in password if char in self.special_characters)
|
||||
< self.min_special_characters
|
||||
@@ -55,6 +197,7 @@ class UppercaseValidator:
|
||||
self.min_uppercase = min_uppercase
|
||||
|
||||
def validate(self, password, user=None):
|
||||
del user
|
||||
if sum(1 for char in password if char.isupper()) < self.min_uppercase:
|
||||
raise ValidationError(
|
||||
_(
|
||||
@@ -75,6 +218,7 @@ class LowercaseValidator:
|
||||
self.min_lowercase = min_lowercase
|
||||
|
||||
def validate(self, password, user=None):
|
||||
del user
|
||||
if sum(1 for char in password if char.islower()) < self.min_lowercase:
|
||||
raise ValidationError(
|
||||
_(
|
||||
@@ -95,6 +239,7 @@ class NumericValidator:
|
||||
self.min_numeric = min_numeric
|
||||
|
||||
def validate(self, password, user=None):
|
||||
del user
|
||||
if sum(1 for char in password if char.isdigit()) < self.min_numeric:
|
||||
raise ValidationError(
|
||||
_(
|
||||
|
||||
@@ -317,6 +317,15 @@ ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int(
|
||||
# Valid values: "neo4j" (default, OSS and local dev), "neptune" (hosted).
|
||||
ATTACK_PATHS_SINK_DATABASE = env.str("ATTACK_PATHS_SINK_DATABASE", default="neo4j")
|
||||
|
||||
# Lighthouse AI
|
||||
# Comma-separated hostnames (or IP literals) that bypass the SSRF validation
|
||||
# applied to OpenAI-compatible provider base URLs, so self-hosted deployments
|
||||
# can point Lighthouse AI at internal endpoints. Empty by default: every base
|
||||
# URL must resolve to a public endpoint.
|
||||
LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS = env.list(
|
||||
"LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS", default=[]
|
||||
)
|
||||
|
||||
# Orphan task recovery feature flags. The master switch is OFF by default, so task
|
||||
# recovery is opt-in; enable it with DJANGO_TASK_RECOVERY_ENABLED=true. The per-group
|
||||
# toggles default to enabled, so once the master is on every group recovers unless a
|
||||
|
||||
@@ -70,6 +70,7 @@ API_JSON_CONTENT_TYPE = "application/vnd.api+json"
|
||||
NO_TENANT_HTTP_STATUS = status.HTTP_401_UNAUTHORIZED
|
||||
TEST_USER = "dev@prowler.com"
|
||||
TEST_PASSWORD = "testing_psswd"
|
||||
TEST_REPLICA_ALIAS = "test_replica"
|
||||
|
||||
|
||||
def _install_compliance_catalog_test_cache() -> None:
|
||||
@@ -231,14 +232,15 @@ def create_test_user(_session_test_user, django_db_blocker):
|
||||
"""Re-create the session-scoped test user when a TransactionTestCase
|
||||
has truncated the users table."""
|
||||
with django_db_blocker.unblock():
|
||||
if not User.objects.filter(pk=_session_test_user.pk).exists():
|
||||
User.objects.create_user(
|
||||
user = User.objects.filter(pk=_session_test_user.pk).first()
|
||||
if user is None:
|
||||
user = User.objects.create_user(
|
||||
id=_session_test_user.pk,
|
||||
name="testing",
|
||||
email=TEST_USER,
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
return _session_test_user
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
@@ -2541,8 +2543,27 @@ def pytest_collection_modifyitems(items):
|
||||
"""Ensure test_rbac.py is executed first."""
|
||||
items.sort(key=lambda item: 0 if "test_rbac.py" in item.nodeid else 1)
|
||||
|
||||
if any(item.get_closest_marker("requires_test_replica_alias") for item in items):
|
||||
default_database = settings.DATABASES["default"]
|
||||
if TEST_REPLICA_ALIAS not in settings.DATABASES:
|
||||
settings.DATABASES[TEST_REPLICA_ALIAS] = {
|
||||
**default_database,
|
||||
"TEST": {
|
||||
**default_database.get("TEST", {}),
|
||||
"MIRROR": "default",
|
||||
},
|
||||
}
|
||||
django_connections.databases[TEST_REPLICA_ALIAS] = settings.DATABASES[
|
||||
TEST_REPLICA_ALIAS
|
||||
]
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"requires_test_replica_alias: creates a test-only replica alias mirrored "
|
||||
"to default",
|
||||
)
|
||||
# Apply the mock before the test session starts. This is necessary to avoid admin error when running the
|
||||
# 0004_rbac_missing_admin_roles migration
|
||||
patch("api.db_router.MainRouter.admin_db", new="default").start()
|
||||
|
||||
@@ -14,6 +14,7 @@ from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
|
||||
from prowler.lib.outputs.csv.csv import CSV
|
||||
from prowler.lib.outputs.finding import Finding as FindingOutput
|
||||
from prowler.lib.outputs.html.html import HTML
|
||||
from prowler.lib.outputs.jira.exceptions.exceptions import JiraBaseException
|
||||
from prowler.lib.outputs.ocsf.ocsf import OCSF
|
||||
from prowler.providers.aws.aws_provider import AwsProvider
|
||||
from prowler.providers.aws.lib.s3.s3 import S3
|
||||
@@ -26,6 +27,8 @@ from tasks.utils import batched
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
JIRA_GENERIC_SEND_ERROR = "Failed to create Jira issue."
|
||||
|
||||
|
||||
def get_s3_client_from_integration(
|
||||
integration: Integration,
|
||||
@@ -483,6 +486,7 @@ def send_findings_to_jira(
|
||||
jira_integration = initialize_prowler_integration(integration)
|
||||
|
||||
num_tickets_created = 0
|
||||
error_messages = []
|
||||
for finding_id in finding_ids:
|
||||
with rls_transaction(tenant_id):
|
||||
finding_instance = (
|
||||
@@ -512,35 +516,54 @@ def send_findings_to_jira(
|
||||
recommendation = remediation.get("recommendation", {})
|
||||
remediation_code = remediation.get("code", {})
|
||||
|
||||
# Send the individual finding to Jira
|
||||
result = jira_integration.send_finding(
|
||||
check_id=finding_instance.check_id,
|
||||
check_title=check_metadata.get("checktitle", ""),
|
||||
severity=finding_instance.severity,
|
||||
status=finding_instance.status,
|
||||
status_extended=finding_instance.status_extended or "",
|
||||
provider=finding_instance.scan.provider.provider,
|
||||
region=region,
|
||||
resource_uid=resource_uid,
|
||||
resource_name=resource_name,
|
||||
risk=check_metadata.get("risk", ""),
|
||||
recommendation_text=recommendation.get("text", ""),
|
||||
recommendation_url=recommendation.get("url", ""),
|
||||
remediation_code_native_iac=remediation_code.get("nativeiac", ""),
|
||||
remediation_code_terraform=remediation_code.get("terraform", ""),
|
||||
remediation_code_cli=remediation_code.get("cli", ""),
|
||||
remediation_code_other=remediation_code.get("other", ""),
|
||||
resource_tags=resource_tags,
|
||||
compliance=finding_instance.compliance or {},
|
||||
project_key=project_key,
|
||||
issue_type=issue_type,
|
||||
)
|
||||
try:
|
||||
# Send the individual finding to Jira
|
||||
result = jira_integration.send_finding(
|
||||
check_id=finding_instance.check_id,
|
||||
check_title=check_metadata.get("checktitle", ""),
|
||||
severity=finding_instance.severity,
|
||||
status=finding_instance.status,
|
||||
status_extended=finding_instance.status_extended or "",
|
||||
provider=finding_instance.scan.provider.provider,
|
||||
region=region,
|
||||
resource_uid=resource_uid,
|
||||
resource_name=resource_name,
|
||||
risk=check_metadata.get("risk", ""),
|
||||
recommendation_text=recommendation.get("text", ""),
|
||||
recommendation_url=recommendation.get("url", ""),
|
||||
remediation_code_native_iac=remediation_code.get("nativeiac", ""),
|
||||
remediation_code_terraform=remediation_code.get("terraform", ""),
|
||||
remediation_code_cli=remediation_code.get("cli", ""),
|
||||
remediation_code_other=remediation_code.get("other", ""),
|
||||
resource_tags=resource_tags,
|
||||
compliance=finding_instance.compliance or {},
|
||||
project_key=project_key,
|
||||
issue_type=issue_type,
|
||||
)
|
||||
except JiraBaseException as error:
|
||||
error_message = error.message or JIRA_GENERIC_SEND_ERROR
|
||||
logger.exception(
|
||||
"Failed to send finding %s to Jira: %s", finding_id, error_message
|
||||
)
|
||||
error_messages.append(error_message)
|
||||
continue
|
||||
except Exception:
|
||||
logger.exception("Failed to send finding %s to Jira", finding_id)
|
||||
error_messages.append(JIRA_GENERIC_SEND_ERROR)
|
||||
continue
|
||||
|
||||
if result:
|
||||
num_tickets_created += 1
|
||||
else:
|
||||
logger.error(f"Failed to send finding {finding_id} to Jira")
|
||||
error_message = JIRA_GENERIC_SEND_ERROR
|
||||
logger.error(error_message)
|
||||
error_messages.append(error_message)
|
||||
|
||||
return {
|
||||
result = {
|
||||
"created_count": num_tickets_created,
|
||||
"failed_count": len(finding_ids) - num_tickets_created,
|
||||
}
|
||||
if error_messages:
|
||||
result["error"] = "; ".join(dict.fromkeys(error_messages))
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import ssl
|
||||
from collections.abc import Iterable
|
||||
|
||||
import boto3
|
||||
import httpcore
|
||||
import httpx
|
||||
import openai
|
||||
from api.models import LighthouseProviderConfiguration, LighthouseProviderModels
|
||||
from api.validators import (
|
||||
resolve_lighthouse_openai_compatible_host,
|
||||
validate_lighthouse_openai_compatible_base_url,
|
||||
)
|
||||
from botocore import UNSIGNED
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import BotoCoreError, ClientError
|
||||
@@ -43,6 +52,90 @@ EXCLUDED_OPENAI_MODEL_SUBSTRINGS = (
|
||||
"-instruct", # Legacy instruct models (gpt-3.5-turbo-instruct, etc.)
|
||||
)
|
||||
|
||||
OPENAI_COMPATIBLE_AUTHENTICATION_ERROR = "API key is invalid or missing"
|
||||
OPENAI_COMPATIBLE_CONNECTION_ERROR = "Provider connection failed"
|
||||
|
||||
|
||||
class _OpenAICompatibleProviderError(Exception):
|
||||
"""Sanitized OpenAI-compatible provider error safe for task results."""
|
||||
|
||||
|
||||
def _sanitize_openai_compatible_error(error: Exception) -> str:
|
||||
status_code = getattr(error, "status_code", None)
|
||||
if status_code is None:
|
||||
response = getattr(error, "response", None)
|
||||
status_code = getattr(response, "status_code", None)
|
||||
|
||||
if status_code == 401:
|
||||
return OPENAI_COMPATIBLE_AUTHENTICATION_ERROR
|
||||
return OPENAI_COMPATIBLE_CONNECTION_ERROR
|
||||
|
||||
|
||||
class _LighthouseOpenAICompatibleNetworkBackend(httpcore.SyncBackend):
|
||||
"""Validate and pin DNS results immediately before TCP connections."""
|
||||
|
||||
def connect_tcp(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float | None = None,
|
||||
local_address: str | None = None,
|
||||
socket_options: Iterable[httpcore.SOCKET_OPTION] | None = None,
|
||||
) -> httpcore.NetworkStream:
|
||||
resolved_addresses = resolve_lighthouse_openai_compatible_host(host, port)
|
||||
last_error: httpcore.ConnectError | httpcore.ConnectTimeout | None = None
|
||||
|
||||
for address in resolved_addresses:
|
||||
try:
|
||||
return super().connect_tcp(
|
||||
address,
|
||||
port,
|
||||
timeout=timeout,
|
||||
local_address=local_address,
|
||||
socket_options=socket_options,
|
||||
)
|
||||
except (httpcore.ConnectError, httpcore.ConnectTimeout) as error:
|
||||
last_error = error
|
||||
|
||||
if last_error:
|
||||
raise last_error
|
||||
raise httpcore.ConnectError("No resolved addresses are available")
|
||||
|
||||
|
||||
class _LighthouseOpenAICompatibleHTTPTransport(httpx.HTTPTransport):
|
||||
"""HTTP transport that connects only to validated public IP addresses."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pool = httpcore.ConnectionPool(
|
||||
ssl_context=ssl.create_default_context(),
|
||||
network_backend=_LighthouseOpenAICompatibleNetworkBackend(),
|
||||
)
|
||||
|
||||
|
||||
def _create_openai_compatible_http_client() -> httpx.Client:
|
||||
"""Create the restricted HTTP client used for OpenAI-compatible providers."""
|
||||
return httpx.Client(
|
||||
follow_redirects=False,
|
||||
trust_env=False,
|
||||
transport=_LighthouseOpenAICompatibleHTTPTransport(),
|
||||
)
|
||||
|
||||
|
||||
def _list_openai_compatible_models(base_url: str, api_key: str):
|
||||
validate_lighthouse_openai_compatible_base_url(base_url)
|
||||
try:
|
||||
with _create_openai_compatible_http_client() as http_client:
|
||||
client = openai.OpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
http_client=http_client,
|
||||
)
|
||||
return client.models.list()
|
||||
except Exception as error:
|
||||
raise _OpenAICompatibleProviderError(
|
||||
_sanitize_openai_compatible_error(error)
|
||||
) from error
|
||||
|
||||
|
||||
def _extract_error_message(e: Exception) -> str:
|
||||
"""
|
||||
@@ -114,6 +207,7 @@ def _extract_openai_compatible_params(
|
||||
return None
|
||||
if not isinstance(base_url, str) or not base_url:
|
||||
return None
|
||||
validate_lighthouse_openai_compatible_base_url(base_url, resolve_dns=False)
|
||||
return {"base_url": base_url, "api_key": api_key}
|
||||
|
||||
|
||||
@@ -285,13 +379,7 @@ def check_lighthouse_provider_connection(provider_config_id: str) -> dict:
|
||||
"error": "Base URL or API key is invalid or missing",
|
||||
}
|
||||
|
||||
# Test connection using OpenAI SDK with custom base_url
|
||||
# Note: base_url should include version (e.g., https://openrouter.ai/api/v1)
|
||||
client = openai.OpenAI(
|
||||
api_key=params["api_key"],
|
||||
base_url=params["base_url"],
|
||||
)
|
||||
_ = client.models.list()
|
||||
_ = _list_openai_compatible_models(params["base_url"], params["api_key"])
|
||||
|
||||
else:
|
||||
return {"connected": False, "error": "Unsupported provider type"}
|
||||
@@ -361,8 +449,7 @@ def _fetch_openai_compatible_models(base_url: str, api_key: str) -> dict[str, st
|
||||
|
||||
Note: base_url should include version (e.g., https://openrouter.ai/api/v1)
|
||||
"""
|
||||
client = openai.OpenAI(api_key=api_key, base_url=base_url)
|
||||
models = client.models.list()
|
||||
models = _list_openai_compatible_models(base_url, api_key)
|
||||
|
||||
available_models: dict[str, str] = {}
|
||||
for model in models.data:
|
||||
|
||||
@@ -5,6 +5,10 @@ from api.db_router import READ_REPLICA_ALIAS, MainRouter
|
||||
from api.models import Integration
|
||||
from api.utils import prowler_integration_connection_test
|
||||
from django.db import OperationalError
|
||||
from prowler.lib.outputs.jira.exceptions.exceptions import (
|
||||
JiraRefreshTokenError,
|
||||
JiraRequiredCustomFieldsError,
|
||||
)
|
||||
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection
|
||||
from prowler.providers.common.models import Connection
|
||||
from tasks.jobs.integrations import (
|
||||
@@ -1830,10 +1834,213 @@ class TestJiraIntegration:
|
||||
)
|
||||
|
||||
# Assertions
|
||||
assert result == {"created_count": 2, "failed_count": 1}
|
||||
assert result == {
|
||||
"created_count": 2,
|
||||
"failed_count": 1,
|
||||
"error": "Failed to create Jira issue.",
|
||||
}
|
||||
|
||||
# Verify error was logged for the failed finding
|
||||
mock_logger.error.assert_called_with("Failed to send finding finding-2 to Jira")
|
||||
mock_logger.error.assert_called_with("Failed to create Jira issue.")
|
||||
|
||||
@patch("tasks.jobs.integrations.rls_transaction")
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
@patch("tasks.jobs.integrations.Integration")
|
||||
@patch("tasks.jobs.integrations.initialize_prowler_integration")
|
||||
@patch("tasks.jobs.integrations.logger")
|
||||
def test_send_findings_to_jira_preserves_exception_message(
|
||||
self,
|
||||
mock_logger,
|
||||
mock_initialize_integration,
|
||||
mock_integration_model,
|
||||
mock_finding_model,
|
||||
mock_rls_transaction,
|
||||
):
|
||||
"""Test Jira send exceptions are returned for UI polling."""
|
||||
tenant_id = "tenant-123"
|
||||
integration_id = "integration-456"
|
||||
project_key = "PROJ"
|
||||
issue_type = "Task"
|
||||
finding_ids = ["finding-1"]
|
||||
error_message = "Jira project requires custom fields: Team is required"
|
||||
|
||||
mock_rls_transaction.return_value.__enter__ = MagicMock()
|
||||
mock_rls_transaction.return_value.__exit__ = MagicMock()
|
||||
|
||||
integration = MagicMock()
|
||||
mock_integration_model.objects.get.return_value = integration
|
||||
|
||||
mock_jira_integration = MagicMock()
|
||||
|
||||
mock_jira_integration.send_finding.side_effect = JiraRequiredCustomFieldsError(
|
||||
message=error_message
|
||||
)
|
||||
mock_initialize_integration.return_value = mock_jira_integration
|
||||
|
||||
finding = MagicMock()
|
||||
finding.id = "finding-1"
|
||||
finding.check_id = "check_001"
|
||||
finding.severity = "high"
|
||||
finding.status = "FAIL"
|
||||
finding.status_extended = "Resource is not compliant"
|
||||
finding.compliance = {}
|
||||
finding.resources.exists.return_value = False
|
||||
finding.resources.first.return_value = None
|
||||
finding.scan.provider.provider = "aws"
|
||||
finding.check_metadata = {
|
||||
"checktitle": "Check Title",
|
||||
"risk": "High risk",
|
||||
"remediation": {"recommendation": {}, "code": {}},
|
||||
}
|
||||
mock_select_related = mock_finding_model.all_objects.select_related.return_value
|
||||
mock_finding_query = mock_select_related.prefetch_related.return_value
|
||||
mock_finding_query.get.return_value = finding
|
||||
|
||||
result = send_findings_to_jira(
|
||||
tenant_id, integration_id, project_key, issue_type, finding_ids
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"created_count": 0,
|
||||
"failed_count": 1,
|
||||
"error": error_message,
|
||||
}
|
||||
mock_logger.exception.assert_called_with(
|
||||
"Failed to send finding %s to Jira: %s",
|
||||
"finding-1",
|
||||
error_message,
|
||||
)
|
||||
|
||||
@patch("tasks.jobs.integrations.rls_transaction")
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
@patch("tasks.jobs.integrations.Integration")
|
||||
@patch("tasks.jobs.integrations.initialize_prowler_integration")
|
||||
@patch("tasks.jobs.integrations.logger")
|
||||
def test_send_findings_to_jira_preserves_refresh_token_error_message(
|
||||
self,
|
||||
mock_logger,
|
||||
mock_initialize_integration,
|
||||
mock_integration_model,
|
||||
mock_finding_model,
|
||||
mock_rls_transaction,
|
||||
):
|
||||
"""Test Jira refresh token exceptions return their UI-friendly message."""
|
||||
tenant_id = "tenant-123"
|
||||
integration_id = "integration-456"
|
||||
project_key = "PROJ"
|
||||
issue_type = "Task"
|
||||
finding_ids = ["finding-1"]
|
||||
error_message = "Failed to refresh the access token"
|
||||
|
||||
mock_rls_transaction.return_value.__enter__ = MagicMock()
|
||||
mock_rls_transaction.return_value.__exit__ = MagicMock()
|
||||
|
||||
integration = MagicMock()
|
||||
mock_integration_model.objects.get.return_value = integration
|
||||
|
||||
mock_jira_integration = MagicMock()
|
||||
|
||||
mock_jira_integration.send_finding.side_effect = JiraRefreshTokenError(
|
||||
message=error_message
|
||||
)
|
||||
mock_initialize_integration.return_value = mock_jira_integration
|
||||
|
||||
finding = MagicMock()
|
||||
finding.id = "finding-1"
|
||||
finding.check_id = "check_001"
|
||||
finding.severity = "high"
|
||||
finding.status = "FAIL"
|
||||
finding.status_extended = "Resource is not compliant"
|
||||
finding.compliance = {}
|
||||
finding.resources.exists.return_value = False
|
||||
finding.resources.first.return_value = None
|
||||
finding.scan.provider.provider = "aws"
|
||||
finding.check_metadata = {
|
||||
"checktitle": "Check Title",
|
||||
"risk": "High risk",
|
||||
"remediation": {"recommendation": {}, "code": {}},
|
||||
}
|
||||
mock_select_related = mock_finding_model.all_objects.select_related.return_value
|
||||
mock_finding_query = mock_select_related.prefetch_related.return_value
|
||||
mock_finding_query.get.return_value = finding
|
||||
|
||||
result = send_findings_to_jira(
|
||||
tenant_id, integration_id, project_key, issue_type, finding_ids
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"created_count": 0,
|
||||
"failed_count": 1,
|
||||
"error": error_message,
|
||||
}
|
||||
mock_logger.exception.assert_called_with(
|
||||
"Failed to send finding %s to Jira: %s",
|
||||
"finding-1",
|
||||
error_message,
|
||||
)
|
||||
|
||||
@patch("tasks.jobs.integrations.rls_transaction")
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
@patch("tasks.jobs.integrations.Integration")
|
||||
@patch("tasks.jobs.integrations.initialize_prowler_integration")
|
||||
@patch("tasks.jobs.integrations.logger")
|
||||
def test_send_findings_to_jira_sanitizes_unexpected_exception_message(
|
||||
self,
|
||||
mock_logger,
|
||||
mock_initialize_integration,
|
||||
mock_integration_model,
|
||||
mock_finding_model,
|
||||
mock_rls_transaction,
|
||||
):
|
||||
"""Test unexpected Jira send exceptions do not leak raw details to UI."""
|
||||
tenant_id = "tenant-123"
|
||||
integration_id = "integration-456"
|
||||
project_key = "PROJ"
|
||||
issue_type = "Task"
|
||||
finding_ids = ["finding-1"]
|
||||
|
||||
mock_rls_transaction.return_value.__enter__ = MagicMock()
|
||||
mock_rls_transaction.return_value.__exit__ = MagicMock()
|
||||
|
||||
integration = MagicMock()
|
||||
mock_integration_model.objects.get.return_value = integration
|
||||
|
||||
mock_jira_integration = MagicMock()
|
||||
mock_jira_integration.send_finding.side_effect = Exception("token=secret-value")
|
||||
mock_initialize_integration.return_value = mock_jira_integration
|
||||
|
||||
finding = MagicMock()
|
||||
finding.id = "finding-1"
|
||||
finding.check_id = "check_001"
|
||||
finding.severity = "high"
|
||||
finding.status = "FAIL"
|
||||
finding.status_extended = "Resource is not compliant"
|
||||
finding.compliance = {}
|
||||
finding.resources.exists.return_value = False
|
||||
finding.resources.first.return_value = None
|
||||
finding.scan.provider.provider = "aws"
|
||||
finding.check_metadata = {
|
||||
"checktitle": "Check Title",
|
||||
"risk": "High risk",
|
||||
"remediation": {"recommendation": {}, "code": {}},
|
||||
}
|
||||
mock_select_related = mock_finding_model.all_objects.select_related.return_value
|
||||
mock_finding_query = mock_select_related.prefetch_related.return_value
|
||||
mock_finding_query.get.return_value = finding
|
||||
|
||||
result = send_findings_to_jira(
|
||||
tenant_id, integration_id, project_key, issue_type, finding_ids
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"created_count": 0,
|
||||
"failed_count": 1,
|
||||
"error": "Failed to create Jira issue.",
|
||||
}
|
||||
assert "secret-value" not in result["error"]
|
||||
mock_logger.exception.assert_called_with(
|
||||
"Failed to send finding %s to Jira", "finding-1"
|
||||
)
|
||||
|
||||
@patch("tasks.jobs.integrations.rls_transaction")
|
||||
@patch("tasks.jobs.integrations.Finding")
|
||||
|
||||
@@ -3,6 +3,7 @@ from contextlib import contextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
import pytest
|
||||
from api.models import (
|
||||
@@ -20,6 +21,7 @@ from django_celery_results.models import TaskResult
|
||||
from tasks.jobs.lighthouse_providers import (
|
||||
_create_bedrock_client,
|
||||
_extract_bedrock_credentials,
|
||||
_LighthouseOpenAICompatibleNetworkBackend,
|
||||
)
|
||||
from tasks.tasks import (
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
@@ -1566,7 +1568,7 @@ class TestCheckLighthouseProviderConnectionTask:
|
||||
(
|
||||
LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
|
||||
{"api_key": "sk-test123"},
|
||||
"https://openrouter.ai/api/v1",
|
||||
"https://93.184.216.34/api/v1",
|
||||
{"connected": True, "error": None},
|
||||
),
|
||||
(
|
||||
@@ -1641,7 +1643,7 @@ class TestCheckLighthouseProviderConnectionTask:
|
||||
(
|
||||
LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
|
||||
{"api_key": "sk-invalid"},
|
||||
"https://openrouter.ai/api/v1",
|
||||
"https://93.184.216.34/api/v1",
|
||||
openai.APIConnectionError(request=MagicMock()),
|
||||
),
|
||||
(
|
||||
@@ -1755,6 +1757,166 @@ class TestCheckLighthouseProviderConnectionTask:
|
||||
provider_cfg.refresh_from_db()
|
||||
assert provider_cfg.is_active is False
|
||||
|
||||
def test_openai_compatible_connection_rejects_metadata_base_url_without_request(
|
||||
self, tenants_fixture
|
||||
):
|
||||
provider_cfg = LighthouseProviderConfiguration(
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
|
||||
base_url="https://169.254.169.254/latest/meta-data",
|
||||
is_active=True,
|
||||
)
|
||||
provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
|
||||
provider_cfg.save()
|
||||
|
||||
with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai:
|
||||
eager_result = check_lighthouse_provider_connection_task.apply(
|
||||
kwargs={
|
||||
"provider_config_id": str(provider_cfg.id),
|
||||
"tenant_id": str(tenants_fixture[0].id),
|
||||
}
|
||||
)
|
||||
|
||||
assert eager_result.successful()
|
||||
result = eager_result.result
|
||||
assert result["connected"] is False
|
||||
assert "base url" in result["error"].lower()
|
||||
mock_openai.assert_not_called()
|
||||
provider_cfg.refresh_from_db()
|
||||
assert provider_cfg.is_active is False
|
||||
|
||||
def test_openai_compatible_connection_disables_redirects(self, tenants_fixture):
|
||||
provider_cfg = LighthouseProviderConfiguration(
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
|
||||
base_url="https://93.184.216.34/api/v1",
|
||||
is_active=False,
|
||||
)
|
||||
provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
|
||||
provider_cfg.save()
|
||||
|
||||
with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai:
|
||||
mock_client = MagicMock()
|
||||
mock_client.models.list.return_value = MagicMock()
|
||||
mock_openai.return_value = mock_client
|
||||
|
||||
eager_result = check_lighthouse_provider_connection_task.apply(
|
||||
kwargs={
|
||||
"provider_config_id": str(provider_cfg.id),
|
||||
"tenant_id": str(tenants_fixture[0].id),
|
||||
}
|
||||
)
|
||||
|
||||
assert eager_result.successful()
|
||||
result = eager_result.result
|
||||
assert result == {"connected": True, "error": None}
|
||||
http_client = mock_openai.call_args.kwargs["http_client"]
|
||||
assert http_client.follow_redirects is False
|
||||
assert http_client.trust_env is False
|
||||
|
||||
def test_openai_compatible_connection_masks_remote_http_error(
|
||||
self, tenants_fixture
|
||||
):
|
||||
provider_cfg = LighthouseProviderConfiguration(
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
|
||||
base_url="https://93.184.216.34/api/v1",
|
||||
is_active=True,
|
||||
)
|
||||
provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
|
||||
provider_cfg.save()
|
||||
remote_body = "<!DOCTYPE HTML><p>remote 404 body</p>"
|
||||
response = httpx.Response(
|
||||
404,
|
||||
request=httpx.Request("GET", "https://provider.example/v1/models"),
|
||||
)
|
||||
|
||||
with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai:
|
||||
mock_client = MagicMock()
|
||||
mock_client.models.list.side_effect = openai.NotFoundError(
|
||||
remote_body,
|
||||
response=response,
|
||||
body=remote_body,
|
||||
)
|
||||
mock_openai.return_value = mock_client
|
||||
|
||||
eager_result = check_lighthouse_provider_connection_task.apply(
|
||||
kwargs={
|
||||
"provider_config_id": str(provider_cfg.id),
|
||||
"tenant_id": str(tenants_fixture[0].id),
|
||||
}
|
||||
)
|
||||
|
||||
assert eager_result.successful()
|
||||
result = eager_result.result
|
||||
assert result == {"connected": False, "error": "Provider connection failed"}
|
||||
assert remote_body not in result["error"]
|
||||
provider_cfg.refresh_from_db()
|
||||
assert provider_cfg.is_active is False
|
||||
|
||||
def test_openai_compatible_connection_masks_remote_auth_error(
|
||||
self, tenants_fixture
|
||||
):
|
||||
provider_cfg = LighthouseProviderConfiguration(
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
|
||||
base_url="https://93.184.216.34/api/v1",
|
||||
is_active=True,
|
||||
)
|
||||
provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
|
||||
provider_cfg.save()
|
||||
remote_body = {"error": {"message": "remote auth detail"}}
|
||||
response = httpx.Response(
|
||||
401,
|
||||
request=httpx.Request("GET", "https://provider.example/v1/models"),
|
||||
)
|
||||
|
||||
with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai:
|
||||
mock_client = MagicMock()
|
||||
mock_client.models.list.side_effect = openai.AuthenticationError(
|
||||
"Unauthorized",
|
||||
response=response,
|
||||
body=remote_body,
|
||||
)
|
||||
mock_openai.return_value = mock_client
|
||||
|
||||
eager_result = check_lighthouse_provider_connection_task.apply(
|
||||
kwargs={
|
||||
"provider_config_id": str(provider_cfg.id),
|
||||
"tenant_id": str(tenants_fixture[0].id),
|
||||
}
|
||||
)
|
||||
|
||||
assert eager_result.successful()
|
||||
result = eager_result.result
|
||||
assert result == {"connected": False, "error": "API key is invalid or missing"}
|
||||
assert "remote auth detail" not in result["error"]
|
||||
provider_cfg.refresh_from_db()
|
||||
assert provider_cfg.is_active is False
|
||||
|
||||
def test_openai_compatible_network_backend_uses_validated_ip(self, monkeypatch):
|
||||
backend = _LighthouseOpenAICompatibleNetworkBackend()
|
||||
stream = MagicMock()
|
||||
|
||||
def resolve_to_public_ip(host, port):
|
||||
del host, port
|
||||
return ("93.184.216.34",)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tasks.jobs.lighthouse_providers.resolve_lighthouse_openai_compatible_host",
|
||||
resolve_to_public_ip,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.lighthouse_providers.httpcore.SyncBackend.connect_tcp",
|
||||
return_value=stream,
|
||||
) as mock_connect_tcp:
|
||||
result = backend.connect_tcp("provider.example", 443, timeout=1.0)
|
||||
|
||||
assert result is stream
|
||||
assert mock_connect_tcp.call_args.args[:2] == ("93.184.216.34", 443)
|
||||
assert mock_connect_tcp.call_args.kwargs["timeout"] == 1.0
|
||||
|
||||
def test_check_connection_provider_does_not_exist(self, tenants_fixture):
|
||||
"""Test that checking non-existent provider raises DoesNotExist."""
|
||||
non_existent_id = str(uuid.uuid4())
|
||||
@@ -1784,7 +1946,7 @@ class TestRefreshLighthouseProviderModelsTask:
|
||||
(
|
||||
LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
|
||||
{"api_key": "sk-test123"},
|
||||
"https://openrouter.ai/api/v1",
|
||||
"https://93.184.216.34/api/v1",
|
||||
{"model-1": "Model One", "model-2": "Model Two"},
|
||||
2,
|
||||
),
|
||||
@@ -1864,6 +2026,106 @@ class TestRefreshLighthouseProviderModelsTask:
|
||||
== expected_count
|
||||
)
|
||||
|
||||
def test_refresh_models_rejects_metadata_base_url_without_request(
|
||||
self, tenants_fixture
|
||||
):
|
||||
provider_cfg = LighthouseProviderConfiguration(
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
|
||||
base_url="https://169.254.169.254/latest/meta-data",
|
||||
is_active=True,
|
||||
)
|
||||
provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
|
||||
provider_cfg.save()
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.lighthouse_providers._fetch_openai_compatible_models"
|
||||
) as mock_fetch:
|
||||
eager_result = refresh_lighthouse_provider_models_task.apply(
|
||||
kwargs={
|
||||
"provider_config_id": str(provider_cfg.id),
|
||||
"tenant_id": str(tenants_fixture[0].id),
|
||||
}
|
||||
)
|
||||
|
||||
assert eager_result.successful()
|
||||
result = eager_result.result
|
||||
assert result["created"] == 0
|
||||
assert result["updated"] == 0
|
||||
assert result["deleted"] == 0
|
||||
assert "base url" in result["error"].lower()
|
||||
mock_fetch.assert_not_called()
|
||||
|
||||
def test_refresh_models_disables_redirects(self, tenants_fixture):
|
||||
provider_cfg = LighthouseProviderConfiguration(
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
|
||||
base_url="https://93.184.216.34/api/v1",
|
||||
is_active=True,
|
||||
)
|
||||
provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
|
||||
provider_cfg.save()
|
||||
|
||||
with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai:
|
||||
mock_client = MagicMock()
|
||||
mock_client.models.list.return_value = MagicMock(data=[])
|
||||
mock_openai.return_value = mock_client
|
||||
|
||||
eager_result = refresh_lighthouse_provider_models_task.apply(
|
||||
kwargs={
|
||||
"provider_config_id": str(provider_cfg.id),
|
||||
"tenant_id": str(tenants_fixture[0].id),
|
||||
}
|
||||
)
|
||||
|
||||
assert eager_result.successful()
|
||||
result = eager_result.result
|
||||
assert result["created"] == 0
|
||||
assert result["updated"] == 0
|
||||
assert result["deleted"] == 0
|
||||
http_client = mock_openai.call_args.kwargs["http_client"]
|
||||
assert http_client.follow_redirects is False
|
||||
assert http_client.trust_env is False
|
||||
|
||||
def test_refresh_models_masks_remote_http_error(self, tenants_fixture):
|
||||
provider_cfg = LighthouseProviderConfiguration(
|
||||
tenant_id=tenants_fixture[0].id,
|
||||
provider_type=LighthouseProviderConfiguration.LLMProviderChoices.OPENAI_COMPATIBLE,
|
||||
base_url="https://93.184.216.34/api/v1",
|
||||
is_active=True,
|
||||
)
|
||||
provider_cfg.credentials_decoded = {"api_key": "compatible-key"}
|
||||
provider_cfg.save()
|
||||
remote_body = "<!DOCTYPE HTML><p>remote 404 body</p>"
|
||||
response = httpx.Response(
|
||||
404,
|
||||
request=httpx.Request("GET", "https://provider.example/v1/models"),
|
||||
)
|
||||
|
||||
with patch("tasks.jobs.lighthouse_providers.openai.OpenAI") as mock_openai:
|
||||
mock_client = MagicMock()
|
||||
mock_client.models.list.side_effect = openai.NotFoundError(
|
||||
remote_body,
|
||||
response=response,
|
||||
body=remote_body,
|
||||
)
|
||||
mock_openai.return_value = mock_client
|
||||
|
||||
eager_result = refresh_lighthouse_provider_models_task.apply(
|
||||
kwargs={
|
||||
"provider_config_id": str(provider_cfg.id),
|
||||
"tenant_id": str(tenants_fixture[0].id),
|
||||
}
|
||||
)
|
||||
|
||||
assert eager_result.successful()
|
||||
result = eager_result.result
|
||||
assert result["created"] == 0
|
||||
assert result["updated"] == 0
|
||||
assert result["deleted"] == 0
|
||||
assert result["error"] == "Provider connection failed"
|
||||
assert remote_body not in result["error"]
|
||||
|
||||
def test_refresh_models_mixed_operations(self, tenants_fixture):
|
||||
"""Test mixed create, update, and delete operations."""
|
||||
# Create provider configuration
|
||||
|
||||
@@ -7,4 +7,4 @@ metadata:
|
||||
data:
|
||||
{{- range $key, $value := .Values.api.djangoConfig }}
|
||||
{{ $key }}: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -26,4 +26,4 @@ roleRef:
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: {{ include "prowler.api.serviceAccountName" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
|
||||
@@ -210,7 +210,9 @@ For more detailed guidance on subscription management and permissions:
|
||||
The following security checks require the `ProwlerRole` permissions for execution. Ensure the role is assigned to the identity assumed by Prowler before running these checks:
|
||||
|
||||
- `app_function_access_keys_configured`
|
||||
- `app_function_application_insights_enabled`
|
||||
- `app_function_ftps_deployment_disabled`
|
||||
- `app_function_latest_runtime_version`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -128,6 +128,25 @@ To connect a provider:
|
||||
3. Configure in Lighthouse AI:
|
||||
- **API Key**: OpenRouter API key
|
||||
- **Base URL**: `https://openrouter.ai/api/v1`
|
||||
|
||||
### Base URL Validation
|
||||
|
||||
To prevent server-side request forgery (SSRF), Prowler API validates the base URL before connecting to it:
|
||||
|
||||
- The URL must use HTTPS.
|
||||
- The host must resolve to a public IP address. Private, loopback, link-local, and cloud metadata addresses are rejected.
|
||||
|
||||
<Warning>
|
||||
This validation can break configurations that point to internal endpoints, such as a self-hosted Ollama server. This is intentional: it fixes a security issue where the Prowler API could be directed to internal services. Internal endpoints must now be allowed explicitly through `LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS`.
|
||||
</Warning>
|
||||
|
||||
To allow internal endpoints, set a comma-separated list of hostnames or IP addresses in the Prowler API environment (for Docker Compose deployments, the shared `.env` file):
|
||||
|
||||
```bash
|
||||
LIGHTHOUSE_AI_OPENAI_COMPATIBLE_ALLOWED_HOSTS=custom-openai.internal,10.0.0.20
|
||||
```
|
||||
|
||||
Hosts in this list skip the public-endpoint validation. HTTPS is still required, so the endpoint needs a certificate the Prowler API trusts.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"codebuild:BatchGet*",
|
||||
"codebuild:ListReportGroups",
|
||||
"cognito-idp:GetUserPoolMfaConfig",
|
||||
"datapipeline:DescribePipelines",
|
||||
"datapipeline:GetPipelineDefinition",
|
||||
"datapipeline:ListPipelines",
|
||||
"dlm:Get*",
|
||||
"drs:Describe*",
|
||||
"ds:Get*",
|
||||
|
||||
@@ -109,6 +109,9 @@ Resources:
|
||||
- "codebuild:BatchGet*"
|
||||
- "codebuild:ListReportGroups"
|
||||
- "cognito-idp:GetUserPoolMfaConfig"
|
||||
- "datapipeline:DescribePipelines"
|
||||
- "datapipeline:GetPipelineDefinition"
|
||||
- "datapipeline:ListPipelines"
|
||||
- "dlm:Get*"
|
||||
- "drs:Describe*"
|
||||
- "ds:Get*"
|
||||
|
||||
@@ -4,6 +4,19 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
<!-- changelog: release notes start -->
|
||||
|
||||
|
||||
## [5.33.1] (Prowler v5.33.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- ECS task definition resource limits now select the latest task definitions by registration date instead of relying on ARN ordering [(#11891)](https://github.com/prowler-cloud/prowler/pull/11891)
|
||||
- `dlm_ebs_snapshot_lifecycle_policy_exists` no longer initializes the full EC2 inventory just to detect EBS snapshots, avoiding slow scans when checking DLM lifecycle policies [(#11900)](https://github.com/prowler-cloud/prowler/pull/11900)
|
||||
- `dms_instance_no_public_access` no longer initializes the full EC2 service when there are no DMS replication instances [(#11902)](https://github.com/prowler-cloud/prowler/pull/11902)
|
||||
- `organizations_scp_check_deny_regions` no longer reports false `FAIL` for AWS Organizations that restrict regions with Allow-based SCPs; the Allow path now checks the statement `Effect` instead of an always-false comparison that made it unreachable [(#11915)](https://github.com/prowler-cloud/prowler/pull/11915)
|
||||
- Jira issue creation failures now preserve safe structured response details from Jira [(#11925)](https://github.com/prowler-cloud/prowler/pull/11925)
|
||||
- Azure Function App optional permission failures now log as warnings, and Function App environment variable fields use the correct spelling internally [(#11926)](https://github.com/prowler-cloud/prowler/pull/11926)
|
||||
|
||||
---
|
||||
## [5.33.0] (Prowler v5.33.0)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
`ec2_ami_account_block_public_access` check for AWS provider, verifying AMI block public access is enabled at the account level in each Region so AMIs cannot be shared publicly
|
||||
@@ -0,0 +1 @@
|
||||
`app_function_ensure_http_is_redirected_to_https` check for Azure provider, verifying that Function Apps enforce HTTPS-only traffic
|
||||
@@ -0,0 +1 @@
|
||||
`datapipeline_pipeline_no_secrets_in_definition` check for AWS provider, scanning Data Pipeline object fields, parameter objects, and parameter values for hardcoded secrets with Kingfisher
|
||||
@@ -1 +0,0 @@
|
||||
`dlm_ebs_snapshot_lifecycle_policy_exists` no longer initializes the full EC2 inventory just to detect EBS snapshots, avoiding slow scans when checking DLM lifecycle policies
|
||||
@@ -1 +0,0 @@
|
||||
`dms_instance_no_public_access` no longer initializes the full EC2 service when there are no DMS replication instances
|
||||
@@ -1 +0,0 @@
|
||||
ECS task definition resource limits now select the latest task definitions by registration date instead of relying on ARN ordering
|
||||
@@ -1 +0,0 @@
|
||||
`organizations_scp_check_deny_regions` no longer reports false `FAIL` for AWS Organizations that restrict regions with Allow-based SCPs; the Allow path now checks the statement `Effect` instead of an always-false comparison that made it unreachable
|
||||
@@ -0,0 +1 @@
|
||||
Add missing trailing newlines to compliance, region, and fixture data files for POSIX compliance
|
||||
@@ -1607,4 +1607,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3611,4 +3611,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,39 @@ class JiraConnection(Connection):
|
||||
issue_types: dict = None
|
||||
|
||||
|
||||
def _format_jira_issue_creation_error(response_json: object, status_code: int) -> str:
|
||||
"""Build a safe Jira issue creation error message from structured fields.
|
||||
|
||||
Args:
|
||||
response_json: Parsed Jira response body.
|
||||
status_code: HTTP status code returned by Jira.
|
||||
|
||||
Returns:
|
||||
Safe issue creation error message for user-facing propagation.
|
||||
"""
|
||||
message_parts = []
|
||||
|
||||
if not isinstance(response_json, dict):
|
||||
return f"Failed to create Jira issue: Jira returned status code {status_code}."
|
||||
|
||||
errors = response_json.get("errors")
|
||||
if isinstance(errors, dict):
|
||||
message_parts.extend(
|
||||
f"'{field}': '{message}'" for field, message in errors.items() if message
|
||||
)
|
||||
|
||||
error_messages = response_json.get("errorMessages")
|
||||
if isinstance(error_messages, list):
|
||||
message_parts.extend(str(message) for message in error_messages if message)
|
||||
elif isinstance(error_messages, str) and error_messages:
|
||||
message_parts.append(error_messages)
|
||||
|
||||
if message_parts:
|
||||
return f"Failed to create Jira issue: {'; '.join(message_parts)}"
|
||||
|
||||
return f"Failed to create Jira issue: Jira returned status code {status_code}."
|
||||
|
||||
|
||||
class MarkdownToADFConverter:
|
||||
"""Helper to convert Markdown strings into Atlassian Document Format blocks."""
|
||||
|
||||
@@ -2060,6 +2093,7 @@ class Jira:
|
||||
Raises:
|
||||
- JiraRefreshTokenError: Failed to refresh the access token
|
||||
- JiraRefreshTokenResponseError: Failed to refresh the access token, response code did not match 200
|
||||
- JiraNoTokenError: Failed to get an access token
|
||||
- JiraCreateIssueError: Failed to create an issue in Jira
|
||||
- JiraSendFindingsResponseError: Failed to send the finding to Jira
|
||||
- JiraRequiredCustomFieldsError: Jira project requires custom fields that are not supported
|
||||
@@ -2155,31 +2189,45 @@ class Jira:
|
||||
try:
|
||||
response_json = response.json()
|
||||
except (ValueError, requests.exceptions.JSONDecodeError):
|
||||
response_error = f"Failed to send finding: {response.status_code} - {response.text}"
|
||||
response_error = _format_jira_issue_creation_error(
|
||||
{}, response.status_code
|
||||
)
|
||||
logger.error(response_error)
|
||||
return False
|
||||
raise JiraSendFindingsResponseError(
|
||||
message=response_error, file=os.path.basename(__file__)
|
||||
)
|
||||
|
||||
# Check if the error is due to required custom fields
|
||||
if response.status_code == 400 and "errors" in response_json:
|
||||
if (
|
||||
response.status_code == 400
|
||||
and isinstance(response_json, dict)
|
||||
and "errors" in response_json
|
||||
):
|
||||
errors = response_json.get("errors", {})
|
||||
# Look for custom field errors (fields starting with "customfield_")
|
||||
custom_field_errors = {
|
||||
k: v for k, v in errors.items() if k.startswith("customfield_")
|
||||
}
|
||||
custom_field_errors = {}
|
||||
if isinstance(errors, dict):
|
||||
custom_field_errors = {
|
||||
k: v
|
||||
for k, v in errors.items()
|
||||
if k.startswith("customfield_")
|
||||
}
|
||||
if custom_field_errors:
|
||||
custom_fields_formatted = ", ".join(
|
||||
[f"'{k}': '{v}'" for k, v in custom_field_errors.items()]
|
||||
)
|
||||
logger.error(
|
||||
f"Jira project requires custom fields that are not supported: {custom_fields_formatted}"
|
||||
raise JiraRequiredCustomFieldsError(
|
||||
message=f"Jira project requires custom fields that are not supported: {custom_fields_formatted}",
|
||||
file=os.path.basename(__file__),
|
||||
)
|
||||
return False
|
||||
|
||||
response_error = (
|
||||
f"Failed to send finding: {response.status_code} - {response_json}"
|
||||
response_error = _format_jira_issue_creation_error(
|
||||
response_json, response.status_code
|
||||
)
|
||||
logger.error(response_error)
|
||||
return False
|
||||
raise JiraSendFindingsResponseError(
|
||||
message=response_error, file=os.path.basename(__file__)
|
||||
)
|
||||
else:
|
||||
try:
|
||||
response_json = response.json()
|
||||
@@ -2191,13 +2239,17 @@ class Jira:
|
||||
return True
|
||||
except JiraRequiredCustomFieldsError as custom_fields_error:
|
||||
logger.error(f"Custom fields error: {custom_fields_error}")
|
||||
return False
|
||||
raise custom_fields_error
|
||||
except JiraSendFindingsResponseError as response_error:
|
||||
logger.error(f"Jira response error: {response_error}")
|
||||
raise response_error
|
||||
except JiraRefreshTokenError as refresh_error:
|
||||
logger.error(f"Token refresh error: {refresh_error}")
|
||||
return False
|
||||
raise refresh_error
|
||||
except JiraRefreshTokenResponseError as response_error:
|
||||
logger.error(f"Token response error: {response_error}")
|
||||
return False
|
||||
raise response_error
|
||||
except JiraNoTokenError as no_token_error:
|
||||
raise no_token_error
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send finding: {e}")
|
||||
return False
|
||||
|
||||
@@ -13607,4 +13607,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from prowler.providers.aws.services.datapipeline.datapipeline_service import (
|
||||
DataPipeline,
|
||||
)
|
||||
from prowler.providers.common.provider import Provider
|
||||
|
||||
datapipeline_client = DataPipeline(Provider.get_global_provider())
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "datapipeline_pipeline_no_secrets_in_definition",
|
||||
"CheckTitle": "Data Pipeline definition has no sensitive credentials",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"TTPs/Credential Access",
|
||||
"Effects/Data Exposure",
|
||||
"Sensitive Data Identifications/Security"
|
||||
],
|
||||
"ServiceName": "datapipeline",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:datapipeline:region:account-id:pipeline/pipeline-id",
|
||||
"Severity": "high",
|
||||
"ResourceType": "AwsDataPipelinePipeline",
|
||||
"ResourceGroup": "analytics",
|
||||
"Description": "AWS Data Pipeline definitions are inspected for hardcoded secrets, such as keys, tokens, passwords, or database credentials embedded directly in pipeline objects, parameters, or parameter values.",
|
||||
"Risk": "Plaintext secrets in Data Pipeline definitions can be viewed by users with pipeline read permissions and may leak through scripts, SQL commands, logs, or exported definitions. Exposed credentials can enable unauthorized access to databases, storage, and downstream systems.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/datapipeline/latest/APIReference/API_GetPipelineDefinition.html",
|
||||
"https://docs.aws.amazon.com/datapipeline/latest/DeveloperGuide/dp-object-pipeline.html",
|
||||
"https://docs.prowler.com/developer-guide/secret-scanning-checks"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Review the Data Pipeline definition.\n2. Remove hardcoded credentials from pipeline objects, parameter objects, and parameter values.\n3. Store secrets in AWS Secrets Manager or AWS Systems Manager Parameter Store.\n4. Grant the pipeline role least-privilege access to retrieve secrets securely at runtime.\n5. Rotate any exposed credentials.",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Avoid embedding secrets in AWS Data Pipeline definitions. Store sensitive values in AWS Secrets Manager or AWS Systems Manager Parameter Store and reference them securely at runtime with least-privilege IAM permissions.",
|
||||
"Url": "https://hub.prowler.com/check/datapipeline_pipeline_no_secrets_in_definition"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"secrets"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "AWS Data Pipeline has been closed to new customers since July 25, 2024. Accounts without pre-existing pipelines will not return findings for this check, as the service cannot be used by new customers."
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import json
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.lib.utils.utils import (
|
||||
SecretsScanError,
|
||||
annotate_verified_secrets,
|
||||
detect_secrets_scan_batch,
|
||||
)
|
||||
from prowler.providers.aws.services.datapipeline.datapipeline_client import (
|
||||
datapipeline_client,
|
||||
)
|
||||
|
||||
|
||||
class datapipeline_pipeline_no_secrets_in_definition(Check):
|
||||
"""Check that AWS Data Pipeline definitions contain no hardcoded secrets."""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the Data Pipeline definition secret scan."""
|
||||
findings = []
|
||||
secrets_ignore_patterns = datapipeline_client.audit_config.get(
|
||||
"secrets_ignore_patterns", []
|
||||
)
|
||||
validate = datapipeline_client.audit_config.get("secrets_validate", False)
|
||||
pipelines = list(datapipeline_client.pipelines.values())
|
||||
line_context_by_pipeline = {}
|
||||
payloads = []
|
||||
for pipeline_index, pipeline in enumerate(pipelines):
|
||||
payload, line_context = _build_definition_payload(pipeline.definition)
|
||||
line_context_by_pipeline[pipeline_index] = line_context
|
||||
if payload:
|
||||
payloads.append((pipeline_index, payload))
|
||||
|
||||
scan_error = None
|
||||
try:
|
||||
batch_results = detect_secrets_scan_batch(
|
||||
payloads, excluded_secrets=secrets_ignore_patterns, validate=validate
|
||||
)
|
||||
except SecretsScanError as error:
|
||||
batch_results = {}
|
||||
scan_error = error
|
||||
|
||||
for pipeline_index, pipeline in enumerate(pipelines):
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=pipeline)
|
||||
report.resource_tags = pipeline.tags
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"No secrets found in Data Pipeline {pipeline.name} definition."
|
||||
)
|
||||
|
||||
line_context = line_context_by_pipeline.get(pipeline_index, {})
|
||||
if line_context:
|
||||
if scan_error:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not scan Data Pipeline {pipeline.name} definition "
|
||||
f"for secrets: {scan_error}; manual review is required."
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
detect_secrets_output = batch_results.get(pipeline_index)
|
||||
if detect_secrets_output:
|
||||
secrets_string = ", ".join(
|
||||
[
|
||||
f"{secret['type']} in {line_context.get(secret['line_number'], 'definition')}"
|
||||
for secret in detect_secrets_output
|
||||
]
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Potential {'secrets' if len(detect_secrets_output) > 1 else 'secret'} "
|
||||
f"found in Data Pipeline {pipeline.name} definition -> {secrets_string}."
|
||||
)
|
||||
annotate_verified_secrets(report, detect_secrets_output)
|
||||
|
||||
findings.append(report)
|
||||
return findings
|
||||
|
||||
|
||||
def _build_definition_payload(definition: dict) -> tuple[str, dict[int, str]]:
|
||||
"""Build a line-oriented scan payload and map each line to a definition field."""
|
||||
lines = []
|
||||
line_context = {}
|
||||
|
||||
def add_line(context: str, value) -> None:
|
||||
if value is None:
|
||||
return
|
||||
lines.append(json.dumps({context: value}))
|
||||
line_context[len(lines)] = context
|
||||
|
||||
for pipeline_object in definition.get("pipelineObjects", []):
|
||||
object_name = pipeline_object.get("name") or pipeline_object.get("id")
|
||||
for field in pipeline_object.get("fields", []):
|
||||
field_name = field.get("key")
|
||||
field_value = field.get("stringValue") or field.get("refValue")
|
||||
add_line(f"object {object_name} field {field_name}", field_value)
|
||||
|
||||
for parameter_object in definition.get("parameterObjects", []):
|
||||
parameter_name = parameter_object.get("id")
|
||||
for attribute in parameter_object.get("attributes", []):
|
||||
attribute_name = attribute.get("key")
|
||||
attribute_value = attribute.get("stringValue")
|
||||
add_line(
|
||||
f"parameter object {parameter_name} attribute {attribute_name}",
|
||||
attribute_value,
|
||||
)
|
||||
|
||||
for parameter_value in definition.get("parameterValues", []):
|
||||
parameter_id = parameter_value.get("id")
|
||||
add_line(f"parameter value {parameter_id}", parameter_value.get("stringValue"))
|
||||
|
||||
return "\n".join(lines), line_context
|
||||
@@ -0,0 +1,96 @@
|
||||
from botocore.exceptions import ClientError
|
||||
from pydantic.v1 import BaseModel, Field
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.lib.scan_filters.scan_filters import is_resource_filtered
|
||||
from prowler.providers.aws.lib.service.service import AWSService
|
||||
|
||||
|
||||
class Pipeline(BaseModel):
|
||||
"""Represents an AWS Data Pipeline pipeline."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
arn: str
|
||||
region: str
|
||||
definition: dict = Field(default_factory=dict)
|
||||
tags: list[dict] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DataPipeline(AWSService):
|
||||
"""AWS Data Pipeline service class to list pipelines and definitions."""
|
||||
|
||||
def __init__(self, provider):
|
||||
"""Initialize the AWS Data Pipeline service."""
|
||||
super().__init__(__class__.__name__, provider)
|
||||
self.pipelines = {}
|
||||
self.__threading_call__(self._list_pipelines)
|
||||
if self.pipelines:
|
||||
self.__threading_call__(
|
||||
self._get_pipeline_definition, self.pipelines.values()
|
||||
)
|
||||
|
||||
def _list_pipelines(self, regional_client) -> None:
|
||||
"""List AWS Data Pipeline pipelines in a region."""
|
||||
logger.info("DataPipeline - Listing pipelines...")
|
||||
try:
|
||||
list_pipelines_paginator = regional_client.get_paginator("list_pipelines")
|
||||
for page in list_pipelines_paginator.paginate():
|
||||
for pipeline in page.get("pipelineIdList", []):
|
||||
pipeline_id = pipeline.get("id")
|
||||
pipeline_name = pipeline.get("name", pipeline_id)
|
||||
pipeline_arn = (
|
||||
f"arn:{self.audited_partition}:datapipeline:"
|
||||
f"{regional_client.region}:{self.audited_account}:pipeline/{pipeline_id}"
|
||||
)
|
||||
if not self.audit_resources or is_resource_filtered(
|
||||
pipeline_arn, self.audit_resources
|
||||
):
|
||||
self.pipelines[pipeline_arn] = Pipeline(
|
||||
id=pipeline_id,
|
||||
name=pipeline_name,
|
||||
arn=pipeline_arn,
|
||||
region=regional_client.region,
|
||||
)
|
||||
except ClientError as error:
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_pipeline_definition(self, pipeline: Pipeline) -> None:
|
||||
"""Get the full definition for an AWS Data Pipeline pipeline."""
|
||||
logger.info(f"DataPipeline - Getting definition for pipeline {pipeline.id}...")
|
||||
try:
|
||||
regional_client = self.regional_clients[pipeline.region]
|
||||
try:
|
||||
pipeline_descriptions = regional_client.describe_pipelines(
|
||||
pipelineIds=[pipeline.id]
|
||||
).get("pipelineDescriptionList", [])
|
||||
if pipeline_descriptions:
|
||||
pipeline.tags = pipeline_descriptions[0].get("tags", [])
|
||||
except ClientError as error:
|
||||
logger.error(
|
||||
f"{pipeline.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{pipeline.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
definition = regional_client.get_pipeline_definition(pipelineId=pipeline.id)
|
||||
pipeline.definition = {
|
||||
"pipelineObjects": definition.get("pipelineObjects", []),
|
||||
"parameterObjects": definition.get("parameterObjects", []),
|
||||
"parameterValues": definition.get("parameterValues", []),
|
||||
}
|
||||
except ClientError as error:
|
||||
logger.error(
|
||||
f"{pipeline.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{pipeline.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "ec2_ami_account_block_public_access",
|
||||
"CheckTitle": "AMI block public access is enabled at the account level",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Effects/Data Exposure"
|
||||
],
|
||||
"ServiceName": "ec2",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "Other",
|
||||
"ResourceGroup": "compute",
|
||||
"Description": "AMI block public access configuration is assessed to see whether public sharing of AMIs is blocked in the account and Region. When enabled (`block-new-sharing`), no AMI in the Region can be made public regardless of individual image permissions.",
|
||||
"Risk": "Without blocking public access, AMIs could be accidentally or maliciously shared publicly, exposing baked-in secrets, source code, and infrastructure details to unauthorized actors.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/sharingamis-intro.html",
|
||||
"https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/image-block-public-access.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws ec2 enable-image-block-public-access --image-block-public-access-state block-new-sharing",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. In the AWS console, select the target Region in the top-right.\n2. Go to EC2 > AMIs.\n3. In the AMIs page, choose Block public access for AMIs (or EC2 Dashboard > Account attributes > Data protection and security).\n4. Choose Manage and enable Block public access.\n5. Save changes.",
|
||||
"Terraform": "```hcl\nresource \"aws_ec2_image_block_public_access\" \"<example_resource_name>\" {\n state = \"block-new-sharing\" # Blocks new public sharing of AMIs in the configured Region\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable AMI block public access (`block-new-sharing`) in every active Region. Apply guardrails (SCPs) to prevent it from being disabled, and review any AMIs that are currently shared publicly.",
|
||||
"Url": "https://hub.prowler.com/check/ec2_ami_account_block_public_access"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.ec2.ec2_client import ec2_client
|
||||
|
||||
|
||||
class ec2_ami_account_block_public_access(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for state in ec2_client.ami_block_public_access_states:
|
||||
report = Check_Report_AWS(
|
||||
metadata=self.metadata(),
|
||||
resource=state,
|
||||
)
|
||||
report.resource_id = ec2_client.audited_account
|
||||
report.resource_arn = ec2_client.account_arn_template
|
||||
|
||||
if state.status == "block-new-sharing":
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"AMI Block Public Access is enabled in {state.region}."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"AMI Block Public Access is disabled in {state.region}."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -52,6 +52,8 @@ class EC2(AWSService):
|
||||
self.__threading_call__(self._describe_ec2_addresses)
|
||||
self.ebs_block_public_access_snapshots_states = []
|
||||
self.__threading_call__(self._get_snapshot_block_public_access_state)
|
||||
self.ami_block_public_access_states = []
|
||||
self.__threading_call__(self._get_ami_block_public_access_state)
|
||||
self.instance_metadata_defaults = []
|
||||
self.__threading_call__(self._get_instance_metadata_defaults)
|
||||
self.launch_templates = []
|
||||
@@ -498,6 +500,21 @@ class EC2(AWSService):
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_ami_block_public_access_state(self, regional_client):
|
||||
try:
|
||||
self.ami_block_public_access_states.append(
|
||||
AmiBlockPublicAccess(
|
||||
status=regional_client.get_image_block_public_access_state()[
|
||||
"ImageBlockPublicAccessState"
|
||||
],
|
||||
region=regional_client.region,
|
||||
)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_instance_metadata_defaults(self, regional_client):
|
||||
try:
|
||||
instances_in_region = self.attributes_for_regions.get(
|
||||
@@ -798,6 +815,11 @@ class EbsSnapshotBlockPublicAccess(BaseModel):
|
||||
region: str
|
||||
|
||||
|
||||
class AmiBlockPublicAccess(BaseModel):
|
||||
status: str
|
||||
region: str
|
||||
|
||||
|
||||
class InstanceMetadataDefaults(BaseModel):
|
||||
http_tokens: Optional[str]
|
||||
instances: bool
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user